Merge branch 'MDL-62472-master' of git://github.com/bmbrands/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 16 May 2018 16:29:46 +0000 (18:29 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 16 May 2018 16:29:46 +0000 (18:29 +0200)
55 files changed:
admin/roles/classes/privacy/provider.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/expired_contexts_manager.php
admin/tool/dataprivacy/classes/manager_observer.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/metadata_registry.php
admin/tool/dataprivacy/classes/task/initiate_data_request_task.php
admin/tool/dataprivacy/classes/task/process_data_request_task.php
admin/tool/dataprivacy/db/messages.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/tests/manager_observer_test.php [new file with mode: 0644]
admin/tool/dataprivacy/version.php
admin/tool/log/store/standard/classes/privacy/provider.php
admin/tool/policy/classes/privacy/provider.php
admin/tool/policy/lang/en/tool_policy.php
admin/tool/policy/tests/privacy_provider_test.php
auth/oauth2/classes/privacy/provider.php
blocks/recent_activity/classes/privacy/provider.php
blocks/recent_activity/lang/en/block_recent_activity.php
blocks/rss_client/classes/privacy/provider.php
blocks/rss_client/tests/privacy_test.php
calendar/classes/privacy/provider.php
cohort/classes/privacy/provider.php
enrol/classes/privacy/provider.php
enrol/flatfile/classes/privacy/provider.php
enrol/flatfile/lang/en/enrol_flatfile.php
enrol/flatfile/tests/privacy_provider_test.php [new file with mode: 0644]
grade/classes/privacy/provider.php
grade/tests/privacy_test.php
lang/en/grades.php
lang/en/moodle.php
lang/en/portfolio.php
lang/en/user.php
lib/classes/privacy/provider.php
mod/assignment/classes/privacy/provider.php
mod/chat/classes/privacy/provider.php
mod/chat/lang/en/chat.php
mod/forum/classes/privacy/provider.php
mod/forum/lang/en/forum.php
mod/glossary/classes/privacy/provider.php
mod/lti/classes/privacy/provider.php
mod/lti/lang/en/lti.php
mod/lti/tests/privacy_provider_test.php
portfolio/classes/privacy/provider.php
portfolio/tests/privacy_provider_test.php
privacy/classes/manager.php
privacy/classes/manager_observer.php [new file with mode: 0644]
privacy/tests/fixtures/provider_a.php [new file with mode: 0644]
privacy/tests/fixtures/provider_throwing_exception.php [new file with mode: 0644]
privacy/tests/manager_test.php
privacy/tests/provider_test.php
question/type/calculated/question.php
question/type/calculated/tests/variablesubstituter_test.php
theme/boost/scss/moodle/core.scss
user/classes/privacy/provider.php
user/templates/add_bulk_note.mustache

index ee8bcea..e420f9e 100644 (file)
@@ -313,9 +313,6 @@ class provider implements
         // Don't belong to the modifier user.
 
         // Remove data from role_assignments.
-        if (empty($context)) {
-            return;
-        }
         $DB->delete_records('role_assignments', ['contextid' => $context->id]);
     }
     /**
index 7bffc9f..05efe9c 100644 (file)
@@ -451,7 +451,7 @@ class api {
         $message->courseid          = $SITE->id;
         $message->component         = 'tool_dataprivacy';
         $message->name              = 'contactdataprotectionofficer';
-        $message->userfrom          = $requestedby;
+        $message->userfrom          = $requestedby->id;
         $message->replyto           = $requestedby->email;
         $message->replytoname       = $requestedby->fullname;
         $message->subject           = $subject;
index e71422d..3d20e5d 100644 (file)
@@ -91,6 +91,7 @@ abstract class expired_contexts_manager {
         }
 
         $privacymanager = new \core_privacy\manager();
+        $privacymanager->set_observer(new \tool_dataprivacy\manager_observer());
 
         foreach ($this->get_context_levels() as $level) {
 
diff --git a/admin/tool/dataprivacy/classes/manager_observer.php b/admin/tool/dataprivacy/classes/manager_observer.php
new file mode 100644 (file)
index 0000000..ca613a4
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class \tool_dataprivacy\manager_observer.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_dataprivacy;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * A failure observer for the \core_privacy\manager.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager_observer implements \core_privacy\manager_observer {
+    /**
+     * Notifies all DPOs that an exception occurred.
+     *
+     * @param \Throwable $e
+     * @param string $component
+     * @param string $interface
+     * @param string $methodname
+     * @param array $params
+     */
+    public function handle_component_failure($e, $component, $interface, $methodname, array $params) {
+        // Get the list of the site Data Protection Officers.
+        $dpos = api::get_site_dpos();
+
+        $messagesubject = get_string('exceptionnotificationsubject', 'tool_dataprivacy');
+        $a = (object)[
+            'fullmethodname' => \core_privacy\manager::get_provider_classname_for_component($component) . '::' . $methodname,
+            'component' => $component,
+            'message' => $e->getMessage(),
+            'backtrace' => $e->getTraceAsString()
+        ];
+        $messagebody = get_string('exceptionnotificationbody', 'tool_dataprivacy', $a);
+
+        // Email the data request to the Data Protection Officer(s)/Admin(s).
+        foreach ($dpos as $dpo) {
+            $message = new \core\message\message();
+            $message->courseid          = SITEID;
+            $message->component         = 'tool_dataprivacy';
+            $message->name              = 'notifyexceptions';
+            $message->userfrom          = \core_user::get_noreply_user();
+            $message->subject           = $messagesubject;
+            $message->fullmessageformat = FORMAT_HTML;
+            $message->notification      = 1;
+            $message->userto            = $dpo;
+            $message->fullmessagehtml   = $messagebody;
+            $message->fullmessage       = html_to_text($messagebody);
+
+            // Send message.
+            message_send($message);
+        }
+    }
+}
index 7607643..6282cc4 100644 (file)
@@ -40,6 +40,8 @@ class metadata_registry {
      */
     public function get_registry_metadata() {
         $manager = new \core_privacy\manager();
+        $manager->set_observer(new \tool_dataprivacy\manager_observer());
+
         $pluginman = \core_plugin_manager::instance();
         $contributedplugins = $this->get_contrib_list();
         $metadata = $manager->get_metadata_for_components();
index 0cebeb0..2ee4a0a 100644 (file)
@@ -97,6 +97,8 @@ class initiate_data_request_task extends adhoc_task {
 
         // Add the list of relevant contexts to the request, and mark all as pending approval.
         $privacymanager = new \core_privacy\manager();
+        $privacymanager->set_observer(new \tool_dataprivacy\manager_observer());
+
         $contextlistcollection = $privacymanager->get_contexts_for_userid($datarequest->get('userid'));
         api::add_request_contexts_with_status($contextlistcollection, $requestid, contextlist_context::STATUS_PENDING);
 
index 589ef01..6a93217 100644 (file)
@@ -88,6 +88,8 @@ class process_data_request_task extends adhoc_task {
 
             // Export the data.
             $manager = new \core_privacy\manager();
+            $manager->set_observer(new \tool_dataprivacy\manager_observer());
+
             $exportedcontent = $manager->export_user_data($approvedclcollection);
 
             $fs = get_file_storage();
@@ -110,6 +112,8 @@ class process_data_request_task extends adhoc_task {
 
             // Delete the data.
             $manager = new \core_privacy\manager();
+            $manager->set_observer(new \tool_dataprivacy\manager_observer());
+
             $manager->delete_data_for_user($approvedclcollection);
         }
 
index 685d9b2..2c1d239 100644 (file)
@@ -41,4 +41,12 @@ $messageproviders = [
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
         ]
     ],
+
+    // Notify Data Protection Officer about exceptions.
+    'notifyexceptions' => [
+        'defaults' => [
+            'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
+        ],
+        'capability'  => 'tool/dataprivacy:managedatarequests'
+    ],
 ];
index 8b089c7..40dc1e7 100644 (file)
@@ -101,6 +101,8 @@ $string['errorrequestalreadyexists'] = 'You already have an ongoing request.';
 $string['errorrequestnotfound'] = 'Request not found';
 $string['errorrequestnotwaitingforapproval'] = 'The request is not awaiting approval. Either it is not yet ready or it has already been processed.';
 $string['errorsendingmessagetodpo'] = 'An error was encountered while trying to send a message to {$a}.';
+$string['exceptionnotificationsubject'] = "Exception occured while processing privacy data";
+$string['exceptionnotificationbody'] = "<p>Exception occured while calling <b>{\$a->fullmethodname}</b>.<br>This means that plugin <b>{\$a->component}</b> did not complete processing data. Below you can find exception information that can be passed to the plugin developer.</p><pre>{\$a->message}<br>\n\n{\$a->backtrace}</pre>";
 $string['expiredretentionperiodtask'] = 'Expired retention period';
 $string['expiry'] = 'Expiry';
 $string['expandplugin'] = 'Expand and collapse plugin.';
@@ -148,6 +150,7 @@ $string['lawfulbases'] = 'Lawful bases';
 $string['lawfulbases_help'] = 'Select at least one option that will serve as the lawful basis for processing personal data. For details on these lawful bases, please see <a href="https://gdpr-info.eu/art-6-gdpr/" target="_blank">GDPR Art. 6.1</a>';
 $string['messageprovider:contactdataprotectionofficer'] = 'Data requests';
 $string['messageprovider:datarequestprocessingresults'] = 'Data request processing results';
+$string['messageprovider:notifyexceptions'] = 'Data requests exceptions notifications';
 $string['message'] = 'Message';
 $string['messagelabel'] = 'Message:';
 $string['moduleinstancename'] = '{$a->instancename} ({$a->modulename})';
diff --git a/admin/tool/dataprivacy/tests/manager_observer_test.php b/admin/tool/dataprivacy/tests/manager_observer_test.php
new file mode 100644 (file)
index 0000000..6c71027
--- /dev/null
@@ -0,0 +1,117 @@
+<?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/>.
+
+/**
+ * Tests for the manager observer.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * API tests.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_manager_observer_testcase extends advanced_testcase {
+
+    /**
+     * Helper to set andn return two users who are DPOs.
+     */
+    protected function setup_site_dpos() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $generator = new testing_data_generator();
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+
+        $context = context_system::instance();
+
+        // Give the manager role with the capability to manage data requests.
+        $managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $managerroleid, $context->id, true);
+
+        // Assign both users as manager.
+        role_assign($managerroleid, $u1->id, $context->id);
+        role_assign($managerroleid, $u2->id, $context->id);
+
+        // Only map the manager role to the DPO role.
+        set_config('dporoles', $managerroleid, 'tool_dataprivacy');
+
+        return \tool_dataprivacy\api::get_site_dpos();
+    }
+
+    /**
+     * Ensure that when users are configured as DPO, they are sent an message upon failure.
+     */
+    public function test_handle_component_failure() {
+        $this->resetAfterTest();
+
+        // Create another user who is not a DPO.
+        $this->getDataGenerator()->create_user();
+
+        // Create the DPOs.
+        $dpos = $this->setup_site_dpos();
+
+        $observer = new \tool_dataprivacy\manager_observer();
+
+        // Handle the failure, catching messages.
+        $mailsink = $this->redirectMessages();
+        $mailsink->clear();
+        $observer->handle_component_failure(new \Exception('error'), 'foo', 'bar', 'baz', ['foobarbaz', 'bum']);
+
+        // Messages should be sent to both DPOs only.
+        $this->assertEquals(2, $mailsink->count());
+
+        $messages = $mailsink->get_messages();
+        $messageusers = array_map(function($message) {
+            return $message->useridto;
+        }, $messages);
+
+        $this->assertEquals(array_keys($dpos), $messageusers, '', 0.0, 0, true);
+    }
+
+    /**
+     * Ensure that when no user is configured as DPO, the message is sent to admin instead.
+     */
+    public function test_handle_component_failure_no_dpo() {
+        $this->resetAfterTest();
+
+        // Create another user who is not a DPO or admin.
+        $this->getDataGenerator()->create_user();
+
+        $observer = new \tool_dataprivacy\manager_observer();
+
+        $mailsink = $this->redirectMessages();
+        $mailsink->clear();
+        $observer->handle_component_failure(new \Exception('error'), 'foo', 'bar', 'baz', ['foobarbaz', 'bum']);
+
+        // Messages should have been sent only to the admin.
+        $this->assertEquals(1, $mailsink->count());
+
+        $messages = $mailsink->get_messages();
+        $message = reset($messages);
+
+        $admin = \core_user::get_user_by_username('admin');
+        $this->assertEquals($admin->id, $message->useridto);
+    }
+}
index 3561d57..7b7bde4 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018051400;
+$plugin->version   = 2018051401;
 $plugin->requires  = 2018050800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index 2761ca2..4ee8007 100644 (file)
@@ -51,7 +51,7 @@ class provider implements
      * @return collection A listing of user data stored through this system.
      */
     public static function get_metadata(collection $collection) : collection {
-        $collection->add_database_table('log', [
+        $collection->add_database_table('logstore_standard_log', [
             'eventname' => 'privacy:metadata:log:eventname',
             'userid' => 'privacy:metadata:log:userid',
             'relateduserid' => 'privacy:metadata:log:relateduserid',
index 7de5edc..67e4e51 100644 (file)
@@ -49,11 +49,11 @@ class provider implements
     /**
      * Return the fields which contain personal data.
      *
-     * @param collection $items A reference to the collection to use to store the metadata.
-     * @return collection The updated collection of metadata items.
+     * @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 $items) : collection {
-        $items->add_database_table(
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table(
             'tool_policy_acceptances',
             [
                 'policyversionid' => 'privacy:metadata:acceptances:policyversionid',
@@ -68,7 +68,29 @@ class provider implements
             'privacy:metadata:acceptances'
         );
 
-        return $items;
+        $collection->add_database_table(
+            'tool_policy_versions',
+            [
+                'name' => 'privacy:metadata:versions:name',
+                'type' => 'privacy:metadata:versions:type',
+                'audience' => 'privacy:metadata:versions:audience',
+                'archived' => 'privacy:metadata:versions:archived',
+                'usermodified' => 'privacy:metadata:versions:usermodified',
+                'timecreated' => 'privacy:metadata:versions:timecreated',
+                'timemodified' => 'privacy:metadata:versions:timemodified',
+                'policyid' => 'privacy:metadata:versions:policyid',
+                'revision' => 'privacy:metadata:versions:revision',
+                'summary' => 'privacy:metadata:versions:summary',
+                'summaryformat' => 'privacy:metadata:versions:summaryformat',
+                'content' => 'privacy:metadata:versions:content',
+                'contentformat' => 'privacy:metadata:versions:contentformat',
+            ],
+            'privacy:metadata:versions'
+        );
+
+        $collection->add_subsystem_link('core_files', [], 'privacy:metadata:subsystem:corefiles');
+
+        return $collection;
     }
 
     /**
@@ -79,11 +101,34 @@ class provider implements
      */
     public static function get_contexts_for_userid(int $userid) : contextlist {
         $contextlist = new contextlist();
-        $contextlist->add_from_sql('SELECT DISTINCT c.id
-            FROM {tool_policy_acceptances} a
-            JOIN {context} c ON a.userid = c.instanceid AND c.contextlevel = ?
-            WHERE a.userid = ? OR a.usermodified = ?',
-            [CONTEXT_USER, $userid, $userid]);
+
+        // Policies a user has modified.
+        $sql = "SELECT c.id
+                  FROM {context} c
+                  JOIN {tool_policy_versions} v ON v.usermodified = :userid
+                 WHERE c.contextlevel = :contextlevel";
+        $params = [
+            'contextlevel' => CONTEXT_SYSTEM,
+            'userid' => $userid,
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
+        // Policies a user has accepted.
+        $sql = "SELECT c.id
+                  FROM {context} c
+                  JOIN {tool_policy_acceptances} a ON c.instanceid = a.userid
+                 WHERE
+                    c.contextlevel = :contextlevel
+                   AND (
+                    a.userid = :userid OR a.usermodified = :usermodified
+                   )";
+        $params = [
+            'contextlevel' => CONTEXT_USER,
+            'userid' => $userid,
+            'usermodified' => $userid,
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
         return $contextlist;
     }
 
@@ -94,39 +139,13 @@ class provider implements
      */
     public static function export_user_data(approved_contextlist $contextlist) {
         global $DB;
+
+        // Export user agreements.
         foreach ($contextlist->get_contexts() as $context) {
-            if ($context->contextlevel != CONTEXT_USER) {
-                continue;
-            }
-            $user = $contextlist->get_user();
-            $agreements = $DB->get_records_sql('SELECT a.id, a.userid, v.name, v.revision, a.usermodified, a.timecreated,
-                  a.timemodified, a.note, v.archived, p.currentversionid, a.status, a.policyversionid
-                FROM {tool_policy_acceptances} a
-                JOIN {tool_policy_versions} v ON v.id=a.policyversionid
-                JOIN {tool_policy} p ON v.policyid = p.id
-                WHERE a.userid = ? AND (a.userid = ? OR a.usermodified = ?)
-                ORDER BY a.userid, v.archived, v.timecreated DESC',
-                [$context->instanceid, $user->id, $user->id]);
-            foreach ($agreements as $agreement) {
-                $context = \context_user::instance($agreement->userid);
-                $subcontext = [
-                    get_string('userpoliciesagreements', 'tool_policy'),
-                    transform::user($agreement->userid)
-                ];
-                $name = 'policyagreement-' . $agreement->policyversionid;
-                $agreementcontent = (object) [
-                    'userid' => transform::user($agreement->userid),
-                    'status' => $agreement->status,
-                    'versionid' => $agreement->policyversionid,
-                    'name' => $agreement->name,
-                    'revision' => $agreement->revision,
-                    'isactive' => transform::yesno($agreement->policyversionid == $agreement->currentversionid),
-                    'usermodified' => transform::user($agreement->usermodified),
-                    'timecreated' => transform::datetime($agreement->timecreated),
-                    'timemodified' => transform::datetime($agreement->timemodified),
-                    'note' => $agreement->note,
-                ];
-                writer::with_context($context)->export_related_data($subcontext, $name, $agreementcontent);
+            if ($context->contextlevel == CONTEXT_USER) {
+                static::export_policy_agreements_for_context($context);
+            } else if ($context->contextlevel == CONTEXT_SYSTEM) {
+                static::export_authored_policies($contextlist->get_user());
             }
         }
     }
@@ -135,6 +154,7 @@ class provider implements
      * Delete all data for all users in the specified context.
      *
      * We never delete user agreements to the policies because they are part of privacy data.
+     * We never delete policy versions because they are part of privacy data.
      *
      * @param \context $context The context to delete in.
      */
@@ -145,9 +165,149 @@ class provider implements
      * Delete all user data for the specified user, in the specified contexts.
      *
      * We never delete user agreements to the policies because they are part of privacy data.
+     * We never delete policy versions because they are part of privacy data.
      *
      * @param approved_contextlist $contextlist A list of contexts approved for deletion.
      */
     public static function delete_data_for_user(approved_contextlist $contextlist) {
     }
+
+    /**
+     * Export all policy agreements relating to the specified user context.
+     *
+     * @param \context_user $context The context to export
+     */
+    protected static function export_policy_agreements_for_context(\context_user $context) {
+        global $DB;
+
+        $sysctx = \context_system::instance();
+        $fs = get_file_storage();
+        $agreementsql = "
+            SELECT
+                a.id AS agreementid, a.userid, a.timemodified, a.note, a.status,
+                a.policyversionid AS versionid, a.usermodified, a.timecreated,
+                v.id, v.archived, v.name, v.revision,
+                v.summary, v.summaryformat,
+                v.content, v.contentformat,
+                p.currentversionid
+             FROM {tool_policy_acceptances} a
+             JOIN {tool_policy_versions} v ON v.id = a.policyversionid
+             JOIN {tool_policy} p ON v.policyid = p.id
+            WHERE a.userid = :userid OR a.usermodified = :usermodified";
+
+        // Fetch all agreements related to this user.
+        $agreements = $DB->get_recordset_sql($agreementsql, [
+            'userid' => $context->instanceid,
+            'usermodified' => $context->instanceid,
+        ]);
+
+        $basecontext = [
+            get_string('privacyandpolicies', 'admin'),
+            get_string('useracceptances', 'tool_policy'),
+        ];
+
+        foreach ($agreements as $agreement) {
+            $subcontext = array_merge($basecontext, [get_string('policynamedversion', 'tool_policy', $agreement)]);
+
+            $summary = writer::with_context($context)->rewrite_pluginfile_urls(
+                $subcontext,
+                'tool_policy',
+                'policydocumentsummary',
+                $agreement->versionid,
+                $agreement->summary
+            );
+            $content = writer::with_context($context)->rewrite_pluginfile_urls(
+                $subcontext,
+                'tool_policy',
+                'policydocumentcontent',
+                $agreement->versionid,
+                $agreement->content
+            );
+            $agreementcontent = (object) [
+                'name' => $agreement->name,
+                'revision' => $agreement->revision,
+                'isactive' => transform::yesno($agreement->versionid == $agreement->currentversionid),
+                'isagreed' => transform::yesno($agreement->status),
+                'agreedby' => transform::user($agreement->usermodified),
+                'timecreated' => transform::datetime($agreement->timecreated),
+                'timemodified' => transform::datetime($agreement->timemodified),
+                'note' => $agreement->note,
+                'summary' => format_text($summary, $agreement->summaryformat),
+                'content' => format_text($content, $agreement->contentformat),
+            ];
+
+            writer::with_context($context)->export_data($subcontext, $agreementcontent);
+            // Manually export the files as they reside in the system context so we can't use
+            // the write's helper methods.
+            foreach ($fs->get_area_files($sysctx->id, 'tool_policy', 'policydocumentsummary', $agreement->versionid) as $file) {
+                writer::with_context($context)->export_file($subcontext, $file);
+            }
+            foreach ($fs->get_area_files($sysctx->id, 'tool_policy', 'policydocumentcontent', $agreement->versionid) as $file) {
+                writer::with_context($context)->export_file($subcontext, $file);
+            }
+        }
+        $agreements->close();
+    }
+
+    /**
+     * Export all policy agreements that the user authored.
+     *
+     * @param stdClass $user The user who has created the policies to export.
+     */
+    protected static function export_authored_policies(\stdClass $user) {
+        global $DB;
+
+        // Authored policies are exported against the system.
+        $context = \context_system::instance();
+        $basecontext = [
+            get_string('policydocuments', 'tool_policy'),
+        ];
+
+        $sql = "SELECT v.id,
+                       v.name,
+                       v.revision,
+                       v.summary,
+                       v.content,
+                       v.archived,
+                       v.usermodified,
+                       v.timecreated,
+                       v.timemodified,
+                       p.currentversionid
+                  FROM {tool_policy_versions} v
+                  JOIN {tool_policy} p ON p.id = v.policyid
+                 WHERE v.usermodified = :userid";
+        $versions = $DB->get_recordset_sql($sql, ['userid' => $user->id]);
+        foreach ($versions as $version) {
+            $subcontext = array_merge($basecontext, [get_string('policynamedversion', 'tool_policy', $version)]);
+
+            $versioncontent = (object) [
+                'name' => $version->name,
+                'revision' => $version->revision,
+                'summary' => writer::with_context($context)->rewrite_pluginfile_urls(
+                    $subcontext,
+                    'tool_policy',
+                    'policydocumentsummary',
+                    $version->id,
+                    $version->summary
+                ),
+                'content' => writer::with_context($context)->rewrite_pluginfile_urls(
+                    $subcontext,
+                    'tool_policy',
+                    'policydocumentcontent',
+                    $version->id,
+                    $version->content
+                ),
+                'isactive' => transform::yesno($version->id == $version->currentversionid),
+                'isarchived' => transform::yesno($version->archived),
+                'createdbyme' => transform::yesno($version->usermodified == $user->id),
+                'timecreated' => transform::datetime($version->timecreated),
+                'timemodified' => transform::datetime($version->timemodified),
+            ];
+            writer::with_context($context)
+                ->export_data($subcontext, $versioncontent)
+                ->export_area_files($subcontext, 'tool_policy', 'policydocumentsummary', $version->id)
+                ->export_area_files($subcontext, 'tool_policy', 'policydocumentcontent', $version->id);
+        }
+        $versions->close();
+    }
 }
index efd944d..c451c1a 100644 (file)
@@ -124,18 +124,35 @@ $string['policydoctype0'] = 'Site policy';
 $string['policydoctype1'] = 'Privacy policy';
 $string['policydoctype2'] = 'Third parties policy';
 $string['policydoctype99'] = 'Other policy';
+$string['policydocuments'] = 'Policy documents';
+$string['policynamedversion'] = 'Policy {$a->name} (version {$a->revision} - {$a->id})';
 $string['policyversionacceptedinbehalf'] = 'Consent for this policy has been given on your behalf.';
 $string['policyversionacceptedinotherlang'] = 'Consent for this policy version has been given in a different language.';
 $string['previousversions'] = '{$a} previous versions';
-$string['privacy:metadata:acceptances'] = 'Information from policy agreements made by site users';
-$string['privacy:metadata:acceptances:policyversionid'] = 'The ID of the accepted version 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.';
-$string['privacy:metadata:acceptances:timecreated'] = 'The time when the user agreed to the policy';
-$string['privacy:metadata:acceptances:timemodified'] = 'The time when the user modified their agreement';
-$string['privacy:metadata:acceptances:note'] = 'Any comments added by a user when giving consent on behalf of another user';
+$string['privacy:metadata:acceptances'] = 'Information about policy agreements made by users.';
+$string['privacy:metadata:acceptances:policyversionid'] = 'The version of the policy for which consent was given.';
+$string['privacy:metadata:acceptances:userid'] = 'The user for whom this policy agreement relates to.';
+$string['privacy:metadata:acceptances:status'] = 'The status of the agreement.';
+$string['privacy:metadata:acceptances:lang'] = 'The language used to display the policy when consent was given.';
+$string['privacy:metadata:acceptances:usermodified'] = 'The user who gave consent for the policy, if made on behalf of another user.';
+$string['privacy:metadata:acceptances:timecreated'] = 'The time when the user agreed to the policy.';
+$string['privacy:metadata:acceptances:timemodified'] = 'The time when the user updated their agreement.';
+$string['privacy:metadata:acceptances:note'] = 'Any comments added by a user when giving consent on behalf of another user.';
+$string['privacy:metadata:subsystem:corefiles'] = 'The policy tool stores files included in the summary and full policy.';
+$string['privacy:metadata:versions'] = 'Policy version information.';
+$string['privacy:metadata:versions:name'] = 'The name of the policy.';
+$string['privacy:metadata:versions:type'] = 'Policy type.';
+$string['privacy:metadata:versions:audience'] = 'The type of users required to give their consent.';
+$string['privacy:metadata:versions:archived'] = 'The policy status (active or inactive).';
+$string['privacy:metadata:versions:usermodified'] = 'The user who modified the policy.';
+$string['privacy:metadata:versions:timecreated'] = 'The time that this version of the policy was created.';
+$string['privacy:metadata:versions:timemodified'] = 'The time that this version of the policy was updated.';
+$string['privacy:metadata:versions:policyid'] = 'The policy that this version is associated with.';
+$string['privacy:metadata:versions:revision'] = 'The revision name of this version of the policy.';
+$string['privacy:metadata:versions:summary'] = 'The summary of this version of the policy.';
+$string['privacy:metadata:versions:summaryformat'] = 'The format of the summary field.';
+$string['privacy:metadata:versions:content'] = 'The content of this version of the policy.';
+$string['privacy:metadata:versions:contentformat'] = 'The format of the content field.';
 $string['privacysettings'] = 'Privacy settings';
 $string['readpolicy'] = 'Please read our {$a}';
 $string['refertofullpolicytext'] = 'Please refer to the full {$a} if you would like to review the text.';
@@ -154,7 +171,6 @@ $string['status2'] = 'Inactive';
 $string['useracceptancecount'] = '{$a->agreedcount} of {$a->userscount} ({$a->percent}%)';
 $string['useracceptancecountna'] = 'N/A';
 $string['useracceptances'] = 'User agreements';
-$string['userpoliciesagreements'] = 'User agreements to policies';
 $string['userpolicysettings'] = 'Policies';
 $string['usersaccepted'] = 'Agreements';
 $string['viewarchived'] = 'View previous versions';
index d392cc1..78556c9 100644 (file)
@@ -42,6 +42,9 @@ class tool_policy_privacy_provider_testcase extends \core_privacy\tests\provider
     /** @var stdClass The user object. */
     protected $user;
 
+    /** @var stdClass The manager user object. */
+    protected $manager;
+
     /**
      * Setup function. Will create a user.
      */
@@ -50,31 +53,15 @@ class tool_policy_privacy_provider_testcase extends \core_privacy\tests\provider
 
         $generator = $this->getDataGenerator();
         $this->user = $generator->create_user();
-    }
 
-    /**
-     * Test for provider::get_metadata().
-     */
-    public function test_get_metadata() {
-        $collection = new collection('tool_policy');
-        $newcollection = provider::get_metadata($collection);
-        $itemcollection = $newcollection->get_collection();
-        $this->assertCount(1, $itemcollection);
-
-        $table = reset($itemcollection);
-        $this->assertEquals('tool_policy_acceptances', $table->get_name());
-
-        $privacyfields = $table->get_privacy_fields();
-        $this->assertArrayHasKey('policyversionid', $privacyfields);
-        $this->assertArrayHasKey('userid', $privacyfields);
-        $this->assertArrayHasKey('status', $privacyfields);
-        $this->assertArrayHasKey('lang', $privacyfields);
-        $this->assertArrayHasKey('usermodified', $privacyfields);
-        $this->assertArrayHasKey('timecreated', $privacyfields);
-        $this->assertArrayHasKey('timemodified', $privacyfields);
-        $this->assertArrayHasKey('note', $privacyfields);
-
-        $this->assertEquals('privacy:metadata:acceptances', $table->get_summary());
+        // Create manager user.
+        $this->manager = $generator->create_user();
+        $syscontext = context_system::instance();
+        $rolemanagerid = create_role('Policy manager', 'policymanager', 'Can manage policy documents');
+        assign_capability('tool/policy:managedocs', CAP_ALLOW, $rolemanagerid, $syscontext->id);
+        assign_capability('tool/policy:acceptbehalf', CAP_ALLOW, $rolemanagerid, $syscontext->id);
+        role_assign($rolemanagerid, $this->manager->id, $syscontext->id);
+        accesslib_clear_all_caches_for_unit_testing();
     }
 
     /**
@@ -84,16 +71,22 @@ class tool_policy_privacy_provider_testcase extends \core_privacy\tests\provider
         global $CFG;
 
         // When there are no policies or agreements context list is empty.
+        $contextlist = \tool_policy\privacy\provider::get_contexts_for_userid($this->manager->id);
+        $this->assertEmpty($contextlist);
         $contextlist = \tool_policy\privacy\provider::get_contexts_for_userid($this->user->id);
         $this->assertEmpty($contextlist);
 
         // Create a policy.
-        $this->setAdminUser();
+        $this->setUser($this->manager);
         $CFG->sitepolicyhandler = 'tool_policy';
         $policy = $this->add_policy();
         api::make_current($policy->get('id'));
 
-        // When there are no agreements context list is empty.
+        // After creating a policy, there should be manager context.
+        $contextlist = \tool_policy\privacy\provider::get_contexts_for_userid($this->manager->id);
+        $this->assertEquals(1, $contextlist->count());
+
+        // But when there are no agreements, user context list is empty.
         $contextlist = \tool_policy\privacy\provider::get_contexts_for_userid($this->user->id);
         $this->assertEmpty($contextlist);
 
@@ -106,13 +99,23 @@ class tool_policy_privacy_provider_testcase extends \core_privacy\tests\provider
         $this->assertEquals(1, $contextlist->count());
     }
 
-    public function test_export_own_agreements() {
-        global $CFG, $USER;
+    public function test_export_agreements() {
+        global $CFG;
+
+        $otheruser = $this->getDataGenerator()->create_user();
+        $otherusercontext = \context_user::instance($otheruser->id);
 
-        // Create policies and agree to them as admin.
-        $this->setAdminUser();
-        $admin = fullclone($USER);
-        $admincontext = \context_user::instance($admin->id);
+        // Create policies and agree to them as manager.
+        $this->setUser($this->manager);
+        $managercontext = \context_user::instance($this->manager->id);
+        $systemcontext = \context_system::instance();
+        $agreementsubcontext = [
+            get_string('privacyandpolicies', 'admin'),
+            get_string('useracceptances', 'tool_policy')
+        ];
+        $versionsubcontext = [
+            get_string('policydocuments', 'tool_policy')
+        ];
         $CFG->sitepolicyhandler = 'tool_policy';
         $policy1 = $this->add_policy();
         api::make_current($policy1->get('id'));
@@ -127,94 +130,161 @@ class tool_policy_privacy_provider_testcase extends \core_privacy\tests\provider
 
         // Request export for this user.
         $contextlist = provider::get_contexts_for_userid($this->user->id);
+        $this->assertCount(1, $contextlist);
         $this->assertEquals([$usercontext->id], $contextlist->get_contextids());
 
         $approvedcontextlist = new approved_contextlist($this->user, 'tool_policy', [$usercontext->id]);
         provider::export_user_data($approvedcontextlist);
 
-        // User can not see admin's agreements but can see his own.
-        $writer = writer::with_context($admincontext);
-        $dataadmin = $writer->get_related_data([get_string('userpoliciesagreements', 'tool_policy'), $admin->id]);
-        $this->assertEmpty($dataadmin);
+        // User can not see manager's agreements but can see his own.
+        $writer = writer::with_context($managercontext);
+        $this->assertFalse($writer->has_any_data());
 
         $writer = writer::with_context($usercontext);
-        $datauser = $writer->get_related_data([get_string('userpoliciesagreements', 'tool_policy'), $this->user->id]);
-        $this->assertCount(2, (array) $datauser);
-        $this->assertEquals($policy1->get('name'), $datauser['policyagreement-'.$policy1->get('id')]->name);
-        $this->assertEquals($this->user->id, $datauser['policyagreement-'.$policy1->get('id')]->usermodified);
-        $this->assertEquals($policy2->get('name'), $datauser['policyagreement-'.$policy2->get('id')]->name);
-        $this->assertEquals($this->user->id, $datauser['policyagreement-'.$policy2->get('id')]->usermodified);
+        $this->assertTrue($writer->has_any_data());
+
+        // Test policy 1.
+        $subcontext = array_merge($agreementsubcontext, [get_string('policynamedversion', 'tool_policy', $policy1->to_record())]);
+        $datauser = $writer->get_data($subcontext);
+        $this->assertEquals($policy1->get('name'), $datauser->name);
+        $this->assertEquals($this->user->id, $datauser->agreedby);
+        $this->assertEquals(strip_tags($policy1->get('summary')), strip_tags($datauser->summary));
+        $this->assertEquals(strip_tags($policy1->get('content')), strip_tags($datauser->content));
+
+        // Test policy 2.
+        $subcontext = array_merge($agreementsubcontext, [get_string('policynamedversion', 'tool_policy', $policy2->to_record())]);
+        $datauser = $writer->get_data($subcontext);
+        $this->assertEquals($policy2->get('name'), $datauser->name);
+        $this->assertEquals($this->user->id, $datauser->agreedby);
+        $this->assertEquals(strip_tags($policy2->get('summary')), strip_tags($datauser->summary));
+        $this->assertEquals(strip_tags($policy2->get('content')), strip_tags($datauser->content));
     }
 
-    public function test_export_agreements_on_behalf() {
-        global $CFG, $USER;
+    public function test_export_agreements_for_other() {
+        global $CFG;
+
+        $managercontext = \context_user::instance($this->manager->id);
+        $systemcontext = \context_system::instance();
+        $usercontext = \context_user::instance($this->user->id);
 
-        // Create policies.
-        $this->setAdminUser();
-        $admin = fullclone($USER);
+        // Create policies and agree to them as manager.
+        $this->setUser($this->manager);
+        $agreementsubcontext = [
+            get_string('privacyandpolicies', 'admin'),
+            get_string('useracceptances', 'tool_policy')
+        ];
+        $versionsubcontext = [
+            get_string('policydocuments', 'tool_policy')
+        ];
         $CFG->sitepolicyhandler = 'tool_policy';
         $policy1 = $this->add_policy();
         api::make_current($policy1->get('id'));
         $policy2 = $this->add_policy();
         api::make_current($policy2->get('id'));
-
-        // Agree to the policies for oneself and for another user.
-        $usercontext = \context_user::instance($this->user->id);
-        $admincontext = \context_user::instance($USER->id);
         api::accept_policies([$policy1->get('id'), $policy2->get('id')]);
-        api::accept_policies([$policy1->get('id'), $policy2->get('id')], $this->user->id, 'Mynote');
 
-        // Request export for this user.
-        $contextlist = provider::get_contexts_for_userid($this->user->id);
-        $this->assertEquals([$usercontext->id], $contextlist->get_contextids());
-
-        $writer = writer::with_context($usercontext);
-        $this->assertFalse($writer->has_any_data());
+        // Agree to the other user's policies.
+        api::accept_policies([$policy1->get('id'), $policy2->get('id')], $this->user->id, 'My note');
+
+        // Request export for the manager.
+        $contextlist = provider::get_contexts_for_userid($this->manager->id);
+        $this->assertCount(3, $contextlist);
+        $this->assertEquals(
+            [$managercontext->id, $usercontext->id, $systemcontext->id],
+            $contextlist->get_contextids(),
+            '',
+            0.0,
+            1,
+            true
+        );
 
         $approvedcontextlist = new approved_contextlist($this->user, 'tool_policy', [$usercontext->id]);
         provider::export_user_data($approvedcontextlist);
 
-        // User can not see admin's agreements but can see his own.
-        $writer = writer::with_context($admincontext);
-        $dataadmin = $writer->get_related_data([get_string('userpoliciesagreements', 'tool_policy'), $admin->id]);
-        $this->assertEmpty($dataadmin);
+        // The user context has data.
+        $writer = writer::with_context($usercontext);
+        $this->assertTrue($writer->has_any_data());
 
+        // Test policy 1.
         $writer = writer::with_context($usercontext);
-        $datauser = $writer->get_related_data([get_string('userpoliciesagreements', 'tool_policy'), $this->user->id]);
-        $this->assertCount(2, (array) $datauser);
-        $this->assertEquals($policy1->get('name'), $datauser['policyagreement-'.$policy1->get('id')]->name);
-        $this->assertEquals($admin->id, $datauser['policyagreement-'.$policy1->get('id')]->usermodified);
-        $this->assertEquals('Mynote', $datauser['policyagreement-'.$policy1->get('id')]->note);
-        $this->assertEquals($policy2->get('name'), $datauser['policyagreement-'.$policy2->get('id')]->name);
-        $this->assertEquals($admin->id, $datauser['policyagreement-'.$policy2->get('id')]->usermodified);
-        $this->assertEquals('Mynote', $datauser['policyagreement-'.$policy2->get('id')]->note);
-
-        // Request export for the admin.
-        writer::reset();
-        $contextlist = provider::get_contexts_for_userid($USER->id);
-        $this->assertEquals([$admincontext->id, $usercontext->id], $contextlist->get_contextids(), '', 0.0, 10, true);
-
-        $approvedcontextlist = new approved_contextlist($USER, 'tool_policy', $contextlist->get_contextids());
-        provider::export_user_data($approvedcontextlist);
+        $subcontext = array_merge($agreementsubcontext, [get_string('policynamedversion', 'tool_policy', $policy1->to_record())]);
+        $datauser = $writer->get_data($subcontext);
+        $this->assertEquals($policy1->get('name'), $datauser->name);
+        $this->assertEquals($this->manager->id, $datauser->agreedby);
+        $this->assertEquals(strip_tags($policy1->get('summary')), strip_tags($datauser->summary));
+        $this->assertEquals(strip_tags($policy1->get('content')), strip_tags($datauser->content));
+
+        // Test policy 2.
+        $subcontext = array_merge($agreementsubcontext, [get_string('policynamedversion', 'tool_policy', $policy2->to_record())]);
+        $datauser = $writer->get_data($subcontext);
+        $this->assertEquals($policy2->get('name'), $datauser->name);
+        $this->assertEquals($this->manager->id, $datauser->agreedby);
+        $this->assertEquals(strip_tags($policy2->get('summary')), strip_tags($datauser->summary));
+        $this->assertEquals(strip_tags($policy2->get('content')), strip_tags($datauser->content));
+    }
 
-        // Admin can see all four agreements.
-        $writer = writer::with_context($admincontext);
-        $dataadmin = $writer->get_related_data([get_string('userpoliciesagreements', 'tool_policy'), $admin->id]);
-        $this->assertCount(2, (array) $dataadmin);
-        $this->assertEquals($policy1->get('name'), $dataadmin['policyagreement-'.$policy1->get('id')]->name);
-        $this->assertEquals($admin->id, $dataadmin['policyagreement-'.$policy1->get('id')]->usermodified);
-        $this->assertEquals($policy2->get('name'), $dataadmin['policyagreement-'.$policy2->get('id')]->name);
-        $this->assertEquals($admin->id, $dataadmin['policyagreement-'.$policy2->get('id')]->usermodified);
+    public function test_export_created_policies() {
+        global $CFG;
 
-        $writer = writer::with_context($usercontext);
-        $datauser = $writer->get_related_data([get_string('userpoliciesagreements', 'tool_policy'), $this->user->id]);
-        $this->assertCount(2, (array) $datauser);
-        $this->assertEquals($policy1->get('name'), $datauser['policyagreement-'.$policy1->get('id')]->name);
-        $this->assertEquals($admin->id, $datauser['policyagreement-'.$policy1->get('id')]->usermodified);
-        $this->assertEquals('Mynote', $datauser['policyagreement-'.$policy1->get('id')]->note);
-        $this->assertEquals($policy2->get('name'), $datauser['policyagreement-'.$policy2->get('id')]->name);
-        $this->assertEquals($admin->id, $datauser['policyagreement-'.$policy2->get('id')]->usermodified);
-        $this->assertEquals('Mynote', $datauser['policyagreement-'.$policy2->get('id')]->note);
+        // Create policies and agree to them as manager.
+        $this->setUser($this->manager);
+        $managercontext = \context_user::instance($this->manager->id);
+        $systemcontext = \context_system::instance();
+        $agreementsubcontext = [
+            get_string('privacyandpolicies', 'admin'),
+            get_string('useracceptances', 'tool_policy')
+        ];
+        $versionsubcontext = [
+            get_string('policydocuments', 'tool_policy')
+        ];
+        $CFG->sitepolicyhandler = 'tool_policy';
+        $policy1 = $this->add_policy();
+        api::make_current($policy1->get('id'));
+        $policy2 = $this->add_policy();
+        api::make_current($policy2->get('id'));
+        api::accept_policies([$policy1->get('id'), $policy2->get('id')]);
+
+        // Agree to the policies for oneself.
+        $contextlist = provider::get_contexts_for_userid($this->manager->id);
+        $this->assertCount(2, $contextlist);
+        $this->assertEquals([$managercontext->id, $systemcontext->id], $contextlist->get_contextids(), '', 0.0, 1, true);
+
+        $approvedcontextlist = new approved_contextlist($this->manager, 'tool_policy', $contextlist->get_contextids());
+        provider::export_user_data($approvedcontextlist);
+
+        // User has agreed to policies.
+        $writer = writer::with_context($managercontext);
+        $this->assertTrue($writer->has_any_data());
+
+        // Test policy 1.
+        $subcontext = array_merge($agreementsubcontext, [get_string('policynamedversion', 'tool_policy', $policy1->to_record())]);
+        $datauser = $writer->get_data($subcontext);
+        $this->assertEquals($policy1->get('name'), $datauser->name);
+        $this->assertEquals($this->manager->id, $datauser->agreedby);
+        $this->assertEquals(strip_tags($policy1->get('summary')), strip_tags($datauser->summary));
+        $this->assertEquals(strip_tags($policy1->get('content')), strip_tags($datauser->content));
+
+        // Test policy 2.
+        $subcontext = array_merge($agreementsubcontext, [get_string('policynamedversion', 'tool_policy', $policy2->to_record())]);
+        $datauser = $writer->get_data($subcontext);
+        $this->assertEquals($policy2->get('name'), $datauser->name);
+        $this->assertEquals($this->manager->id, $datauser->agreedby);
+        $this->assertEquals(strip_tags($policy2->get('summary')), strip_tags($datauser->summary));
+        $this->assertEquals(strip_tags($policy2->get('content')), strip_tags($datauser->content));
+
+        // User can see policy documents.
+        $writer = writer::with_context($systemcontext);
+        $this->assertTrue($writer->has_any_data());
+
+        $subcontext = array_merge($versionsubcontext, [get_string('policynamedversion', 'tool_policy', $policy1->to_record())]);
+        $dataversion = $writer->get_data($subcontext);
+        $this->assertEquals($policy1->get('name'), $dataversion->name);
+        $this->assertEquals(get_string('yes'), $dataversion->createdbyme);
+
+        $subcontext = array_merge($versionsubcontext, [get_string('policynamedversion', 'tool_policy', $policy2->to_record())]);
+        $dataversion = $writer->get_data($subcontext);
+        $this->assertEquals($policy2->get('name'), $dataversion->name);
+        $this->assertEquals(get_string('yes'), $dataversion->createdbyme);
     }
 
     /**
index 5a3476a..76d34a5 100644 (file)
@@ -120,10 +120,6 @@ class provider implements
      * @param  \context $context The context to delete data for.
      */
     public static function delete_data_for_all_users_in_context(\context $context) {
-        if (empty($context)) {
-            return;
-        }
-
         if ($context->contextlevel != CONTEXT_USER) {
             return;
         }
index 8e998d5..b024b5b 100644 (file)
 
 namespace block_recent_activity\privacy;
 
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -33,15 +37,61 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\null_provider {
+class provider implements \core_privacy\local\metadata\provider,
+            \core_privacy\local\request\plugin\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 {
+
+        // This plugin defines a db table but it is not considered personal data and, therefore, not exported or deleted.
+        $collection->add_database_table('block_recent_activity', [
+            'courseid' => 'privacy:metadata:block_recent_activity:courseid',
+            'cmid' => 'privacy:metadata:block_recent_activity:cmid',
+            'timecreated' => 'privacy:metadata:block_recent_activity:timecreated',
+            'userid' => 'privacy:metadata:block_recent_activity:userid',
+            'action' => 'privacy:metadata:block_recent_activity:action',
+            'modname' => 'privacy:metadata:block_recent_activity:modname'
+        ], 'privacy:metadata:block_recent_activity');
+
+        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 {
+        return new 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) {
+    }
+
+    /**
+     * 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) {
+    }
 
     /**
-     * Get the language string identifier with the component's language
-     * file to explain why this plugin stores no data.
+     * Delete all user data for the specified user, in the specified contexts.
      *
-     * @return string
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
      */
-    public static function get_reason() : string {
-        return 'privacy:metadata';
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
     }
 }
index 3f6eadb..a569a7a 100644 (file)
 
 $string['pluginname'] = 'Recent activity';
 $string['privacy:metadata'] = 'The recent activity block contains a cache of data stored elsewhere in Moodle.';
+$string['privacy:metadata:block_recent_activity'] = 'Temporary log of recent teacher activity. Removed after two days';
+$string['privacy:metadata:block_recent_activity:action'] = 'Action: created, updated or deleted';
+$string['privacy:metadata:block_recent_activity:cmid'] = 'Course module id';
+$string['privacy:metadata:block_recent_activity:courseid'] = 'Course id';
+$string['privacy:metadata:block_recent_activity:modname'] = 'Module type name (for delete action)';
+$string['privacy:metadata:block_recent_activity:timecreated'] = 'Time when action was performed';
+$string['privacy:metadata:block_recent_activity:userid'] = 'User performing the action';
 $string['recent_activity:addinstance'] = 'Add a new recent activity block';
 $string['recent_activity:viewaddupdatemodule'] = 'View added and updated modules in recent activity block';
 $string['recent_activity:viewdeletemodule'] = 'View deleted modules in recent activity block';
index edc8a2b..c1ea54f 100644 (file)
@@ -87,16 +87,21 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
      * @param approved_contextlist $contextlist The approved contexts to export information for.
      */
     public static function export_user_data(approved_contextlist $contextlist) {
+        $rssdata = [];
         $results = static::get_records($contextlist->get_user()->id);
         foreach ($results as $result) {
-            $data = (object) [
+            $rssdata[] = (object) [
                 'title' => $result->title,
                 'preferredtitle' => $result->preferredtitle,
                 'description' => $result->description,
                 'shared' => \core_privacy\local\request\transform::yesno($result->shared),
                 'url' => $result->url
             ];
-
+        }
+        if (!empty($rssdata)) {
+            $data = (object) [
+                'feeds' => $rssdata,
+            ];
             \core_privacy\local\request\writer::with_context($contextlist->current())->export_data([
                     get_string('pluginname', 'block_rss_client')], $data);
         }
index dfd37c1..901d198 100644 (file)
@@ -63,6 +63,7 @@ class block_rss_client_testcase extends provider_testcase {
         $user = $this->getDataGenerator()->create_user();
         $context = context_user::instance($user->id);
 
+        $this->add_rss_feed($user);
         $this->add_rss_feed($user);
 
         $writer = \core_privacy\local\request\writer::with_context($context);
@@ -70,11 +71,13 @@ class block_rss_client_testcase extends provider_testcase {
         $this->export_context_data_for_user($user->id, $context, 'block_rss_client');
 
         $data = $writer->get_data([get_string('pluginname', 'block_rss_client')]);
-        $this->assertEquals('BBC News - World', $data->title);
-        $this->assertEquals('World News', $data->preferredtitle);
-        $this->assertEquals('Description: BBC News - World', $data->description);
-        $this->assertEquals(get_string('no'), $data->shared);
-        $this->assertEquals('http://feeds.bbci.co.uk/news/world/rss.xml?edition=uk', $data->url);
+        $this->assertCount(2, $data->feeds);
+        $feed1 = reset($data->feeds);
+        $this->assertEquals('BBC News - World', $feed1->title);
+        $this->assertEquals('World News', $feed1->preferredtitle);
+        $this->assertEquals('Description: BBC News - World', $feed1->description);
+        $this->assertEquals(get_string('no'), $feed1->shared);
+        $this->assertEquals('http://feeds.bbci.co.uk/news/world/rss.xml?edition=uk', $feed1->url);
     }
 
     /**
index d859862..4fc58de 100644 (file)
@@ -187,10 +187,6 @@ class provider implements
      * @param   context $context Transform the specific context to delete data for.
      */
     public static function delete_data_for_all_users_in_context(\context $context) {
-        if (empty($context)) {
-            return;
-        }
-
         // Delete all Calendar Events in the specified context in batches.
         if ($eventids = array_keys(self::get_calendar_event_ids_by_context($context))) {
             self::delete_batch_records('event', 'id', $eventids);
index 7b8bb62..021cef9 100644 (file)
@@ -143,10 +143,6 @@ class provider implements
      * @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;
         }
index 46a0c63..e4f33ed 100644 (file)
@@ -164,9 +164,6 @@ class provider implements
     public static function delete_data_for_all_users_in_context(\context $context) {
         global $DB;
 
-        if (empty($context)) {
-            return;
-        }
         // Sanity check that context is at the User context level.
         if ($context->contextlevel == CONTEXT_COURSE) {
             $sql = "SELECT ue.id
@@ -230,4 +227,16 @@ class provider implements
         $DB->delete_records_select('user_enrolments', "id $sql", $params);
     }
 
+    /**
+     * Get the subcontext for export.
+     *
+     * @param array $subcontext Any additional subcontext to use.
+     * @return array The array containing the full subcontext, i.e. [enrolments, subcontext]
+     */
+    public static function get_subcontext(array $subcontext) {
+        return array_merge(
+            [get_string('privacy:metadata:user_enrolments', 'core_enrol')],
+            $subcontext
+        );
+    }
 }
index 5e403cd..93e5d83 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 namespace enrol_flatfile\privacy;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\context;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\writer;
+use core_privacy\local\request\transform;
+
 defined('MOODLE_INTERNAL') || die();
 /**
  * Privacy Subsystem for enrol_flatfile implementing null_provider.
@@ -28,14 +35,148 @@ defined('MOODLE_INTERNAL') || die();
  * @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\null_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\plugin\provider {
+
     /**
-     * Get the language string identifier with the component's language
-     * file to explain why this plugin stores no data.
+     * Returns meta data about this system.
      *
-     * @return  string
+     * @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_reason() : string {
-        return 'privacy:metadata';
+    public static function get_metadata(collection $collection) : collection {
+        return $collection->add_database_table('enrol_flatfile', [
+            'action' => 'privacy:metadata:enrol_flatfile:action',
+            'roleid' => 'privacy:metadata:enrol_flatfile:roleid',
+            'userid' => 'privacy:metadata:enrol_flatfile:userid',
+            'courseid' => 'privacy:metadata:enrol_flatfile:courseid',
+            'timestart' => 'privacy:metadata:enrol_flatfile:timestart',
+            'timeend' => 'privacy:metadata:enrol_flatfile:timeend',
+            'timemodified' => 'privacy:metadata:enrol_flatfile:timemodified'
+        ], 'privacy:metadata:enrol_flatfile');
     }
-}
\ No newline at end of file
+
+    /**
+     * 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 c.id
+                  FROM {enrol_flatfile} ef
+                  JOIN {context} c ON c.contextlevel = ? AND c.instanceid = ef.courseid
+                 WHERE ef.userid = ?";
+        $params = [CONTEXT_COURSE, $userid];
+
+        $contextlist = new contextlist();
+        $contextlist->set_component('enrol_flatfile');
+        return $contextlist->add_from_sql($sql, $params);
+    }
+
+    /**
+     * 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;
+
+        // Ensure all contexts are CONTEXT_COURSE.
+        $contexts = static::validate_contextlist_contexts($contextlist);
+        if (empty($contexts)) {
+            return;
+        }
+
+        // Get the context instance ids from the contexts. These  are the course ids..
+        $contextinstanceids = array_map(function($context) {
+            return $context->instanceid;
+        }, $contexts);
+        $userid = $contextlist->get_user()->id;
+
+        // Now, we just need to fetch and format all entries corresponding to the contextids provided.
+        $sql = "SELECT ef.action, r.shortname, ef.courseid, ef.timestart, ef.timeend, ef.timemodified
+                  FROM {enrol_flatfile} ef
+                  JOIN {context} c ON c.contextlevel = :contextlevel AND c.instanceid = ef.courseid
+                  JOIN {role} r ON r.id = ef.roleid
+                 WHERE ef.userid = :userid";
+        $params = ['contextlevel' => CONTEXT_COURSE, 'userid' => $userid];
+        list($insql, $inparams) = $DB->get_in_or_equal($contextinstanceids, SQL_PARAMS_NAMED);
+        $sql .= " AND ef.courseid $insql";
+        $params = array_merge($params, $inparams);
+
+        $futureenrolments = $DB->get_recordset_sql($sql, $params);
+        $enrolmentdata = [];
+        foreach ($futureenrolments as $futureenrolment) {
+            // It's possible to have more than one future enrolment per course.
+            $futureenrolment->timestart = transform::datetime($futureenrolment->timestart);
+            $futureenrolment->timeend = transform::datetime($futureenrolment->timeend);
+            $futureenrolment->timemodified = transform::datetime($futureenrolment->timemodified);
+            $enrolmentdata[$futureenrolment->courseid][] = $futureenrolment;
+        }
+        $futureenrolments->close();
+
+        // And finally, write out the data to the relevant course contexts.
+        $subcontext = \core_enrol\privacy\provider::get_subcontext([get_string('pluginname', 'enrol_flatfile')]);
+        foreach ($enrolmentdata as $courseid => $enrolments) {
+            $data = (object) [
+                'pendingenrolments' => $enrolments,
+            ];
+            writer::with_context(\context_course::instance($courseid))->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) {
+        if ($context->contextlevel != CONTEXT_COURSE) {
+            return;
+        }
+        global $DB;
+        $DB->delete_records('enrol_flatfile', ['courseid' => $context->instanceid]);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        // Only delete data from contexts which are at the COURSE_MODULE contextlevel.
+        $contexts = self::validate_contextlist_contexts($contextlist);
+        if (empty($contexts)) {
+            return;
+        }
+
+        // Get the course ids based on the provided contexts.
+        $contextinstanceids = array_map(function($context) {
+            return $context->instanceid;
+        }, $contextlist->get_contexts());
+
+        global $DB;
+        $user = $contextlist->get_user();
+        list($insql, $inparams) = $DB->get_in_or_equal($contextinstanceids, SQL_PARAMS_NAMED);
+        $params = array_merge(['userid' => $user->id], $inparams);
+        $sql = "userid = :userid AND courseid $insql";
+        $DB->delete_records_select('enrol_flatfile', $sql, $params);
+    }
+
+    /**
+     * Simple sanity check on the contextlist contexts, making sure they're of CONTEXT_COURSE contextlevel.
+     *
+     * @param approved_contextlist $contextlist
+     * @return array the array of contexts filtered to only include those of CONTEXT_COURSE contextlevel.
+     */
+    protected static function validate_contextlist_contexts(approved_contextlist $contextlist) {
+        return array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_COURSE) {
+                $carry[] = $context;
+            }
+            return $carry;
+        }, []);
+    }
+}
index 6f444e3..e815bf9 100644 (file)
@@ -29,6 +29,7 @@ $string['filelockedmail'] = 'The text file you are using for file-based enrolmen
 $string['filelockedmailsubject'] = 'Important error: Enrolment file';
 $string['flatfile:manage'] = 'Manage user enrolments manually';
 $string['flatfile:unenrol'] = 'Unenrol users from the course manually';
+$string['flatfileenrolments'] = 'Flat file (CSV) enrolments';
 $string['flatfilesync'] = 'Flat file enrolment sync';
 $string['location'] = 'File location';
 $string['location_desc'] = 'Specify full path to the enrolment file. The file is automatically deleted after processing.';
@@ -61,4 +62,11 @@ It could look something like this:
    del, student, 17, CF101
    add, student, 21, CF101, 1091115000, 1091215000
 </pre>';
-$string['privacy:metadata'] = 'The Flat file (CSV) enrolment plugin does not store any personal data.';
+$string['privacy:metadata:enrol_flatfile'] = 'The Flat file (CSV) enrolment plugin may store personal data relating to future enrolments in the enrol_flatfile table.';
+$string['privacy:metadata:enrol_flatfile:action'] = 'The enrolment action expected at the given date.';
+$string['privacy:metadata:enrol_flatfile:courseid'] = 'The courseid to which the enrolment relates.';
+$string['privacy:metadata:enrol_flatfile:roleid'] = 'The id of the role to be assigned or revoked.';
+$string['privacy:metadata:enrol_flatfile:timestart'] = 'The time at which the enrolment change starts.';
+$string['privacy:metadata:enrol_flatfile:timeend'] = 'The time at which the enrolment change ends.';
+$string['privacy:metadata:enrol_flatfile:timemodified'] = 'The modification time of this enrolment change.';
+$string['privacy:metadata:enrol_flatfile:userid'] = 'The id of the user to which the role assignment relates.';
diff --git a/enrol/flatfile/tests/privacy_provider_test.php b/enrol/flatfile/tests/privacy_provider_test.php
new file mode 100644 (file)
index 0000000..5ad5dbb
--- /dev/null
@@ -0,0 +1,243 @@
+<?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 tests for enrol_flatfile.
+ *
+ * @package    enrol_flatfile
+ * @category   test
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\collection;
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\writer;
+use enrol_flatfile\privacy\provider;
+
+/**
+ * Privacy tests for enrol_flatfile.
+ *
+ * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class enrol_flatfile_privacy_testcase extends provider_testcase {
+
+    /** @var \stdClass $user1 a test user.*/
+    protected $user1;
+
+    /** @var \stdClass $user2 a test user.*/
+    protected $user2;
+
+    /** @var \context $coursecontext1 a course context.*/
+    protected $coursecontext1;
+
+    /** @var \context $coursecontext2 a course context.*/
+    protected $coursecontext2;
+
+    /** @var \context $coursecontext3 a course context.*/
+    protected $coursecontext3;
+
+    /**
+     * Called before every test.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Verify that get_metadata returns the database table mapping.
+     */
+    public function test_get_metadata() {
+        $collection = new collection('enrol_flatfile');
+        $collection = provider::get_metadata($collection);
+        $collectiondata = $collection->get_collection();
+        $this->assertNotEmpty($collectiondata);
+        $this->assertInstanceOf(\core_privacy\local\metadata\types\database_table::class, $collectiondata[0]);
+    }
+
+    /**
+     * Verify that the relevant course contexts are returned for users with pending enrolment records.
+     */
+    public function test_get_contexts_for_user() {
+        global $DB;
+        // Create, via flatfile syncing, the future enrolments entries in the enrol_flatfile table.
+        $this->create_future_enrolments();
+
+        $this->assertEquals(3, $DB->count_records('enrol_flatfile'));
+
+        // We expect to see 2 entries for user1, in course1 and course3.
+        $contextlist = provider::get_contexts_for_userid($this->user1->id);
+        $this->assertEquals(2, $contextlist->count());
+        $contextids = $contextlist->get_contextids();
+        $this->assertContains($this->coursecontext1->id, $contextids);
+        $this->assertContains($this->coursecontext3->id, $contextids);
+
+        // And 1 for user2 on course2.
+        $contextlist = provider::get_contexts_for_userid($this->user2->id);
+        $this->assertEquals(1, $contextlist->count());
+        $contextids = $contextlist->get_contextids();
+        $this->assertContains($this->coursecontext2->id, $contextids);
+    }
+
+    /**
+     * Verify the export includes any future enrolment records for the user.
+     */
+    public function test_export_user_data() {
+        // Create, via flatfile syncing, the future enrolments entries in the enrol_flatfile table.
+        $this->create_future_enrolments();
+
+        // Get contexts containing user data.
+        $contextlist = provider::get_contexts_for_userid($this->user1->id);
+        $this->assertEquals(2, $contextlist->count());
+
+        $approvedcontextlist = new approved_contextlist(
+            $this->user1,
+            'enrol_flatfile',
+            $contextlist->get_contextids()
+        );
+
+        // Export for the approved contexts.
+        provider::export_user_data($approvedcontextlist);
+
+        // Verify we see one future course enrolment in course1, and one in course3.
+        $subcontext = \core_enrol\privacy\provider::get_subcontext([get_string('pluginname', 'enrol_flatfile')]);
+
+        $writer = writer::with_context($this->coursecontext1);
+        $this->assertNotEmpty($writer->get_data($subcontext));
+
+        $writer = writer::with_context($this->coursecontext3);
+        $this->assertNotEmpty($writer->get_data($subcontext));
+
+        // Verify we have nothing in course 2 for this user.
+        $writer = writer::with_context($this->coursecontext2);
+        $this->assertEmpty($writer->get_data($subcontext));
+    }
+
+    /**
+     * Verify export will limit any future enrolment records to only those contextids provided.
+     */
+    public function test_export_user_data_restricted_context_subset() {
+        // Create, via flatfile syncing, the future enrolments entries in the enrol_flatfile table.
+        $this->create_future_enrolments();
+
+        // Now, limit the export scope to just course1's context and verify only that data is seen in any export.
+        $subsetapprovedcontextlist = new approved_contextlist(
+            $this->user1,
+            'enrol_flatfile',
+            [$this->coursecontext1->id]
+        );
+
+        // Export for the approved contexts.
+        provider::export_user_data($subsetapprovedcontextlist);
+
+        // Verify we see one future course enrolment in course1 only.
+        $subcontext = \core_enrol\privacy\provider::get_subcontext([get_string('pluginname', 'enrol_flatfile')]);
+
+        $writer = writer::with_context($this->coursecontext1);
+        $this->assertNotEmpty($writer->get_data($subcontext));
+
+        // And nothing in the course3 context.
+        $writer = writer::with_context($this->coursecontext3);
+        $this->assertEmpty($writer->get_data($subcontext));
+    }
+
+    /**
+     * Verify that records can be deleted by context.
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        // Create, via flatfile syncing, the future enrolments entries in the enrol_flatfile table.
+        $this->create_future_enrolments();
+
+        // Verify we have 1 future enrolments for course 1.
+        $this->assertEquals(1, $DB->count_records('enrol_flatfile', ['courseid' => $this->coursecontext1->instanceid]));
+
+        // Now, run delete by context and confirm that record is removed.
+        provider::delete_data_for_all_users_in_context($this->coursecontext1);
+        $this->assertEquals(0, $DB->count_records('enrol_flatfile', ['courseid' => $this->coursecontext1->instanceid]));
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        // Create, via flatfile syncing, the future enrolments entries in the enrol_flatfile table.
+        $this->create_future_enrolments();
+
+        // Verify we have 2 future enrolments for course 1 and course 3.
+        $contextlist = provider::get_contexts_for_userid($this->user1->id);
+        $this->assertEquals(2, $contextlist->count());
+        $contextids = $contextlist->get_contextids();
+        $this->assertContains($this->coursecontext1->id, $contextids);
+        $this->assertContains($this->coursecontext3->id, $contextids);
+
+        $approvedcontextlist = new approved_contextlist(
+            $this->user1,
+            'enrol_flatfile',
+            $contextids
+        );
+
+        // Now, run delete for user and confirm that both records are removed.
+        provider::delete_data_for_user($approvedcontextlist);
+        $contextlist = provider::get_contexts_for_userid($this->user1->id);
+        $this->assertEquals(0, $contextlist->count());
+        $this->assertEquals(0, $DB->count_records('enrol_flatfile', ['userid' => $this->user1->id]));
+    }
+
+    /**
+     * Helper to sync a file and create the enrol_flatfile DB entries, for use with the get, export and delete tests.
+     */
+    protected function create_future_enrolments() {
+        global $CFG;
+        $this->user1 = $this->getDataGenerator()->create_user(['idnumber' => 'u1']);
+        $this->user2 = $this->getDataGenerator()->create_user(['idnumber' => 'u2']);
+
+        $course1 = $this->getDataGenerator()->create_course(['idnumber' => 'c1']);
+        $course2 = $this->getDataGenerator()->create_course(['idnumber' => 'c2']);
+        $course3 = $this->getDataGenerator()->create_course(['idnumber' => 'c3']);
+        $this->coursecontext1 = context_course::instance($course1->id);
+        $this->coursecontext2 = context_course::instance($course2->id);
+        $this->coursecontext3 = context_course::instance($course3->id);
+
+        $now = time();
+        $future = $now + 60 * 60 * 5;
+        $farfuture = $now + 60 * 60 * 24 * 5;
+
+        $file = "$CFG->dataroot/enrol.txt";
+        $data = "add,student,u1,c1,$future,0
+                 add,student,u2,c2,$future,0
+                 add,student,u1,c3,$future,$farfuture";
+        file_put_contents($file, $data);
+
+        $trace = new null_progress_trace();
+        $this->enable_plugin();
+        $flatfileplugin = enrol_get_plugin('flatfile');
+        $flatfileplugin->set_config('location', $file);
+        $flatfileplugin->sync($trace);
+    }
+
+    /**
+     * Enables the flatfile plugin for testing.
+     */
+    protected function enable_plugin() {
+        $enabled = enrol_get_plugins(true);
+        $enabled['flatfile'] = true;
+        $enabled = array_keys($enabled);
+        set_config('enrol_plugins_enabled', implode(',', $enabled));
+    }
+}
index 282253a..39ec59c 100644 (file)
@@ -60,6 +60,7 @@ class provider implements
      */
     public static function get_metadata(collection $collection) : collection {
 
+        // Tables without 'real' user information.
         $collection->add_database_table('grade_outcomes', [
             'timemodified' => 'privacy:metadata:outcomes:timemodified',
             'usermodified' => 'privacy:metadata:outcomes:usermodified',
@@ -80,6 +81,18 @@ class provider implements
             'loggeduser' => 'privacy:metadata:history:loggeduser',
         ], 'privacy:metadata:itemshistory');
 
+        $collection->add_database_table('scale', [
+            'userid' => 'privacy:metadata:scale:userid',
+            'timemodified' => 'privacy:metadata:scale:timemodified',
+        ], 'privacy:metadata:scale');
+
+        $collection->add_database_table('scale_history', [
+            'userid' => 'privacy:metadata:scale:userid',
+            'timemodified' => 'privacy:metadata:history:timemodified',
+            'loggeduser' => 'privacy:metadata:history:loggeduser',
+        ], 'privacy:metadata:scalehistory');
+
+        // Table with user information.
         $gradescommonfields = [
             'userid' => 'privacy:metadata:grades:userid',
             'usermodified' => 'privacy:metadata:grades:usermodified',
@@ -97,9 +110,24 @@ class provider implements
             'loggeduser' => 'privacy:metadata:history:loggeduser',
         ]), 'privacy:metadata:gradeshistory');
 
-        // The table grade_import_values is not reported because its data is temporary and only
+        // The following tables are reported but not exported/deleted because their data is temporary and only
         // used during an import. It's content is deleted after a successful, or failed, import.
 
+        $collection->add_database_table('grade_import_newitem', [
+            'itemname' => 'privacy:metadata:grade_import_newitem:itemname',
+            'importcode' => 'privacy:metadata:grade_import_newitem:importcode',
+            'importer' => 'privacy:metadata:grade_import_newitem:importer'
+        ], 'privacy:metadata:grade_import_newitem');
+
+        $collection->add_database_table('grade_import_values', [
+            'userid' => 'privacy:metadata:grade_import_values:userid',
+            'finalgrade' => 'privacy:metadata:grade_import_values:finalgrade',
+            'feedback' => 'privacy:metadata:grade_import_values:feedback',
+            'importcode' => 'privacy:metadata:grade_import_values:importcode',
+            'importer' => 'privacy:metadata:grade_import_values:importer',
+            'importonlyfeedback' => 'privacy:metadata:grade_import_values:importonlyfeedback'
+        ], 'privacy:metadata:grade_import_values');
+
         return $collection;
     }
 
@@ -118,18 +146,29 @@ class provider implements
               FROM {grade_outcomes} go
               JOIN {context} ctx
                 ON (go.courseid > 0 AND ctx.instanceid = go.courseid AND ctx.contextlevel = :courselevel)
-                OR (ctx.id = :syscontextid)
+                OR ((go.courseid IS NULL OR go.courseid < 1) AND ctx.id = :syscontextid)
              WHERE go.usermodified = :userid";
         $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
         $contextlist->add_from_sql($sql, $params);
 
-        // Add where appear in the history of outcomes, categories or items.
+        // Add where we modified scales.
+        $sql = "
+            SELECT DISTINCT ctx.id
+              FROM {scale} s
+              JOIN {context} ctx
+                ON (s.courseid > 0 AND ctx.instanceid = s.courseid AND ctx.contextlevel = :courselevel)
+                OR (s.courseid = 0 AND ctx.id = :syscontextid)
+             WHERE s.userid = :userid";
+        $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
+        $contextlist->add_from_sql($sql, $params);
+
+        // Add where appear in the history of outcomes, categories, scales or items.
         $sql = "
             SELECT DISTINCT ctx.id
               FROM {context} ctx
          LEFT JOIN {grade_outcomes_history} goh ON goh.loggeduser = :userid1 AND (
                    (goh.courseid > 0 AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1)
-                OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid)
+                OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid1)
             )
          LEFT JOIN {grade_categories_history} gch ON gch.loggeduser = :userid2 AND (
                    gch.courseid = ctx.instanceid
@@ -139,17 +178,28 @@ class provider implements
                    gih.courseid = ctx.instanceid
                AND ctx.contextlevel = :courselevel3
             )
+         LEFT JOIN {scale_history} sh
+                ON (sh.userid = :userid4 OR sh.loggeduser = :userid5)
+               AND (
+                       (sh.courseid > 0 AND sh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel4)
+                    OR (sh.courseid = 0 AND ctx.id = :syscontextid2)
+            )
              WHERE goh.id IS NOT NULL
                 OR gch.id IS NOT NULL
-                OR gih.id IS NOT NULL";
+                OR gih.id IS NOT NULL
+                OR sh.id IS NOT NULL";
         $params = [
-            'syscontextid' => SYSCONTEXTID,
+            'syscontextid1' => SYSCONTEXTID,
+            'syscontextid2' => SYSCONTEXTID,
             'courselevel1' => CONTEXT_COURSE,
             'courselevel2' => CONTEXT_COURSE,
             'courselevel3' => CONTEXT_COURSE,
+            'courselevel4' => CONTEXT_COURSE,
             'userid1' => $userid,
             'userid2' => $userid,
             'userid3' => $userid,
+            'userid4' => $userid,
+            'userid5' => $userid,
         ];
         $contextlist->add_from_sql($sql, $params);
 
@@ -240,6 +290,9 @@ class provider implements
         // Export the outcomes.
         static::export_user_data_outcomes_in_contexts($contextlist);
 
+        // Export the scales.
+        static::export_user_data_scales_in_contexts($contextlist);
+
         // Export the historical grades which have become orphans (their grade items were deleted).
         // We place those in ther user context of the graded user.
         $userids = array_values(array_map(function($context) {
@@ -659,6 +712,100 @@ class provider implements
         });
     }
 
+    /**
+     * Export the user data related to scales.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     * @return void
+     */
+    protected static function export_user_data_scales_in_contexts(approved_contextlist $contextlist) {
+        global $DB;
+
+        $rootpath = [get_string('grades', 'core_grades')];
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+        $userid = $contextlist->get_user()->id;
+
+        // Reorganise the contexts.
+        $reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_SYSTEM) {
+                $carry['in_system'] = true;
+            } else if ($context->contextlevel == CONTEXT_COURSE) {
+                $carry['courseids'][] = $context->instanceid;
+            }
+            return $carry;
+        }, [
+            'in_system' => false,
+            'courseids' => []
+        ]);
+
+        // Construct SQL.
+        $sqltemplateparts = [];
+        $templateparams = [];
+        if ($reduced['in_system']) {
+            $sqltemplateparts[] = '{prefix}.courseid = 0';
+        }
+        if (!empty($reduced['courseids'])) {
+            list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
+            $sqltemplateparts[] = "{prefix}.courseid $insql";
+            $templateparams = array_merge($templateparams, $inparams);
+        }
+        if (empty($sqltemplateparts)) {
+            return;
+        }
+        $sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
+
+        // Export edited scales.
+        $sqlwhere = str_replace('{prefix}', 's', $sqltemplate);
+        $sql = "
+            SELECT s.id, s.courseid, s.name, s.timemodified
+              FROM {scale} s
+             WHERE $sqlwhere
+               AND s.userid = :userid
+          ORDER BY s.courseid, s.timemodified, s.id";
+        $params = array_merge($templateparams, ['userid' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
+            $carry[] = [
+                'name' => $record->name,
+                'timemodified' => transform::datetime($record->timemodified),
+                'created_or_modified_by_you' => transform::yesno(true)
+            ];
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = $courseid ? context_course::instance($courseid) : context_system::instance();
+            writer::with_context($context)->export_related_data($relatedtomepath, 'scales',
+                (object) ['scales' => $data]);
+        });
+
+        // Export edits of scales history.
+        $sqlwhere = str_replace('{prefix}', 'sh', $sqltemplate);
+        $sql = "
+            SELECT sh.id, sh.courseid, sh.name, sh.userid, sh.timemodified, sh.action, sh.loggeduser
+              FROM {scale_history} sh
+             WHERE $sqlwhere
+               AND sh.loggeduser = :userid1
+                OR sh.userid = :userid2
+          ORDER BY sh.courseid, sh.timemodified, sh.id";
+        $params = array_merge($templateparams, ['userid1' => $userid, 'userid2' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) use ($userid) {
+            $carry[] = [
+                'name' => $record->name,
+                'timemodified' => transform::datetime($record->timemodified),
+                'author_of_change_was_you' => transform::yesno($record->userid == $userid),
+                'author_of_action_was_you' => transform::yesno($record->loggeduser == $userid),
+                'action' => static::transform_history_action($record->action)
+            ];
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = $courseid ? context_course::instance($courseid) : context_system::instance();
+            writer::with_context($context)->export_related_data($relatedtomepath, 'scales_history',
+                (object) ['modified_records' => $data]);
+        });
+    }
+
     /**
      * Extract grade_grade from a record.
      *
index 83605a1..f7dfc15 100644 (file)
@@ -64,6 +64,11 @@ class core_grades_privacy_testcase extends provider_testcase {
         $u4 = $dg->create_user();
         $u5 = $dg->create_user();
         $u6 = $dg->create_user();
+        $u7 = $dg->create_user();
+        $u8 = $dg->create_user();
+        $u9 = $dg->create_user();
+        $u10 = $dg->create_user();
+        $u11 = $dg->create_user();
 
         $sysctx = context_system::instance();
         $c1ctx = context_course::instance($c1->id);
@@ -80,16 +85,22 @@ class core_grades_privacy_testcase extends provider_testcase {
             'fullname' => 'go2']), false);
 
         // Nothing as of now.
-        foreach ([$u1, $u2, $u3, $u4] as $u) {
+        foreach ([$u1, $u2, $u3, $u4, $u5, $u6, $u7, $u8, $u9, $u10, $u11] as $u) {
             $contexts = array_flip(provider::get_contexts_for_userid($u->id)->get_contextids());
             $this->assertEmpty($contexts);
         }
 
         $go0 = new grade_outcome(['shortname' => 'go0', 'fullname' => 'go0', 'usermodified' => $u1->id]);
         $go0->insert();
-        $go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u1->id]);
+        $go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u11->id]);
         $go1->insert();
 
+        // Create scales.
+        $s1 = new grade_scale(['name' => 's1', 'scale' => 'a,b', 'userid' => $u7->id, 'courseid' => 0, 'description' => '']);
+        $s1->insert();
+        $s2 = new grade_scale(['name' => 's2', 'scale' => 'a,b', 'userid' => $u8->id, 'courseid' => $c1->id, 'description' => '']);
+        $s2->insert();
+
         // User 2 creates history.
         $this->setUser($u2);
         $go0->shortname .= ' edited';
@@ -118,11 +129,18 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->setUser($u6);
         $gi2a->delete();
 
+        // User 9 creates history.
+        $this->setUser($u9);
+        $s1->name .= ' edited';
+        $s1->update();
+
         // Assert contexts.
         $contexts = array_flip(provider::get_contexts_for_userid($u1->id)->get_contextids());
-        $this->assertCount(2, $contexts);
-        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $this->assertCount(1, $contexts);
         $this->assertArrayHasKey($sysctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u11->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
         $contexts = array_flip(provider::get_contexts_for_userid($u2->id)->get_contextids());
         $this->assertCount(2, $contexts);
         $this->assertArrayHasKey($sysctx->id, $contexts);
@@ -140,6 +158,23 @@ class core_grades_privacy_testcase extends provider_testcase {
         $contexts = array_flip(provider::get_contexts_for_userid($u6->id)->get_contextids());
         $this->assertCount(1, $contexts);
         $this->assertArrayHasKey($c2ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u7->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($sysctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u8->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u9->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($sysctx->id, $contexts);
+
+        // User 10 creates history.
+        $this->setUser($u10);
+        $s2->delete();
+
+        $contexts = array_flip(provider::get_contexts_for_userid($u10->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
     }
 
     public function test_get_contexts_for_userid_grades_and_history() {
@@ -609,6 +644,10 @@ class core_grades_privacy_testcase extends provider_testcase {
         $u4 = $dg->create_user();
         $u5 = $dg->create_user();
         $u6 = $dg->create_user();
+        $u7 = $dg->create_user();
+        $u8 = $dg->create_user();
+        $u9 = $dg->create_user();
+        $u10 = $dg->create_user();
 
         $sysctx = context_system::instance();
         $u1ctx = context_user::instance($u1->id);
@@ -641,6 +680,14 @@ class core_grades_privacy_testcase extends provider_testcase {
         $go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u1->id]);
         $go1->insert();
 
+        // Create scales.
+        $s1 = new grade_scale(['name' => 's1', 'scale' => 'a,b', 'userid' => $u7->id, 'courseid' => 0, 'description' => '']);
+        $s1->insert();
+        $s2 = new grade_scale(['name' => 's2', 'scale' => 'a,b', 'userid' => $u8->id, 'courseid' => $c1->id, 'description' => '']);
+        $s2->insert();
+        $s3 = new grade_scale(['name' => 's3', 'scale' => 'a,b', 'userid' => $u8->id, 'courseid' => $c2->id, 'description' => '']);
+        $s3->insert();
+
         // User 2 creates history.
         $this->setUser($u2);
         $go0->shortname .= ' edited';
@@ -669,6 +716,15 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->setUser($u6);
         $gi2a->delete();
 
+        // User 9 creates history.
+        $this->setUser($u9);
+        $s1->name .= ' edited';
+        $s1->update();
+
+        // User 10 creates history.
+        $this->setUser($u10);
+        $s3->delete();
+
         $this->setAdminUser();
 
         // Export data for u1.
@@ -755,6 +811,74 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
         $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'),
             $data->modified_records[0]['action']);
+
+        // Export data for u7.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u7, 'core_grades', $allcontexts));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertCount(1, $data->scales);
+        $this->assertEquals($s1->name, $data->scales[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->scales[0]['created_or_modified_by_you']);
+
+        // Export data for u8.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u8, 'core_grades', $allcontexts));
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertCount(1, $data->scales);
+        $this->assertEquals($s2->name, $data->scales[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->scales[0]['created_or_modified_by_you']);
+        $data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'scales_history');
+        $this->assertCount(2, $data->modified_records);
+        $this->assertEquals($s3->name, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['author_of_change_was_you']);
+        $this->assertEquals(transform::yesno(false), $data->modified_records[0]['author_of_action_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactioninsert', 'core_grades'),
+            $data->modified_records[0]['action']);
+        $this->assertEquals($s3->name, $data->modified_records[1]['name']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[1]['author_of_change_was_you']);
+        $this->assertEquals(transform::yesno(false), $data->modified_records[1]['author_of_action_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'),
+            $data->modified_records[1]['action']);
+
+        // Export data for u9.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u9, 'core_grades', $allcontexts));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'scales_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($s1->name, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(false), $data->modified_records[0]['author_of_change_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['author_of_action_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactionupdate', 'core_grades'),
+            $data->modified_records[0]['action']);
+
+        // Export data for u10.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u10, 'core_grades', $allcontexts));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'scales');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'scales_history');
+        $this->assertEmpty($data);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'scales_history');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'scales_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($s3->name, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(false), $data->modified_records[0]['author_of_change_was_you']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['author_of_action_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'),
+            $data->modified_records[0]['action']);
     }
 
     /**
index aec0710..b3d26eb 100644 (file)
@@ -615,6 +615,17 @@ $string['prefrows'] = 'Special rows';
 $string['prefshow'] = 'Show/hide toggles';
 $string['previewrows'] = 'Preview rows';
 $string['privacy:metadata:categorieshistory'] = 'A record of previous versions of grade categories';
+$string['privacy:metadata:grade_import_newitem'] = 'Temporary table for storing new grade_item names from grade import';
+$string['privacy:metadata:grade_import_newitem:importcode'] = 'A unique batch code for identifying one batch of imports';
+$string['privacy:metadata:grade_import_newitem:importer'] = 'User importing the data';
+$string['privacy:metadata:grade_import_newitem:itemname'] = 'New grade item name';
+$string['privacy:metadata:grade_import_values'] = 'Temporary table for importing grades';
+$string['privacy:metadata:grade_import_values:feedback'] = 'Grade feedback';
+$string['privacy:metadata:grade_import_values:finalgrade'] = 'Raw grade value';
+$string['privacy:metadata:grade_import_values:importcode'] = 'A unique batch code for identifying one batch of imports';
+$string['privacy:metadata:grade_import_values:importer'] = 'User importing the data';
+$string['privacy:metadata:grade_import_values:importonlyfeedback'] = 'Flag if only feedback was imported';
+$string['privacy:metadata:grade_import_values:userid'] = 'User whose grade was imported';
 $string['privacy:metadata:grades'] = 'A record of grades';
 $string['privacy:metadata:grades:aggregationstatus'] = 'The aggregation status';
 $string['privacy:metadata:grades:aggregationweight'] = 'The weight in aggregation';
@@ -632,6 +643,10 @@ $string['privacy:metadata:outcomes'] = 'A record of outcomes';
 $string['privacy:metadata:outcomes:timemodified'] = 'Time at which the record was modified';
 $string['privacy:metadata:outcomes:usermodified'] = 'The user who last modified the record';
 $string['privacy:metadata:outcomeshistory'] = 'A record of previous versions of outcomes';
+$string['privacy:metadata:scale'] = 'A record of scales';
+$string['privacy:metadata:scale:timemodified'] = 'Time at which the record was last modified';
+$string['privacy:metadata:scale:userid'] = 'The user who last modified the record';
+$string['privacy:metadata:scalehistory'] = 'A record of previous versions of scales';
 $string['privacy:path:relatedtome'] = 'Related to me';
 $string['privacy:request:historyactiondelete'] = 'Delete';
 $string['privacy:request:historyactioninsert'] = 'Insert';
index d3b47b5..e83cbf3 100644 (file)
@@ -1563,6 +1563,16 @@ $string['privacy:metadata:events_queue:eventdata'] = 'The data stored in the eve
 $string['privacy:metadata:events_queue:stackdump'] = 'Any stacktrace associated with this event.';
 $string['privacy:metadata:events_queue:timecreated'] = 'The time that this event was created.';
 $string['privacy:metadata:events_queue:userid'] = 'The userid associated with this event.';
+$string['privacy:metadata:log'] = 'A collection of past events';
+$string['privacy:metadata:log:action'] = 'A description of the action';
+$string['privacy:metadata:log:cmid'] = 'cmid';
+$string['privacy:metadata:log:course'] = 'course';
+$string['privacy:metadata:log:info'] = 'Additional information';
+$string['privacy:metadata:log:ip'] = 'The IP address used at the time of the event';
+$string['privacy:metadata:log:module'] = 'module';
+$string['privacy:metadata:log:time'] = 'The date at wich the action took place';
+$string['privacy:metadata:log:url'] = 'The URL related to the event';
+$string['privacy:metadata:log:userid'] = 'The ID of the user who performed the action';
 $string['privacy:metadata:task_adhoc'] = 'The status of adhoc tasks.';
 $string['privacy:metadata:task_adhoc:component'] = 'The component owning the task.';
 $string['privacy:metadata:task_adhoc:nextruntime'] = 'The earliest time to run this task.';
index 792cb6e..32d846a 100644 (file)
@@ -170,6 +170,16 @@ $string['privacy:metadata'] = 'The portfolio subsystem acts as a channel, passin
 $string['privacy:metadata:name'] = 'Name of the preference.';
 $string['privacy:metadata:instance'] = 'Identifier for the portfolio.';
 $string['privacy:metadata:instancesummary'] = 'This stores portfolio both instances and preferences for the portfolios user is using.';
+$string['privacy:metadata:portfolio_log'] = 'Log of portfolio transfers (used to later check for duplicates)';
+$string['privacy:metadata:portfolio_log:caller_class'] = 'Name of the class used to create the transfer';
+$string['privacy:metadata:portfolio_log:caller_component'] = 'Component name responsible for exporting';
+$string['privacy:metadata:portfolio_log:time'] = 'Time of transfer (in the case of a queued transfer this is the time the actual transfer ran, not when the user started)';
+$string['privacy:metadata:portfolio_log:userid'] = 'User who exported content';
+$string['privacy:metadata:portfolio_tempdata'] = 'Stores temporary data for portfolio exports, cleaned by cron after one day';
+$string['privacy:metadata:portfolio_tempdata:data'] = 'Export data';
+$string['privacy:metadata:portfolio_tempdata:expirytime'] = 'Time this record will expire';
+$string['privacy:metadata:portfolio_tempdata:instance'] = 'Portfolio plugin instance being used';
+$string['privacy:metadata:portfolio_tempdata:userid'] = 'User performing export';
 $string['privacy:metadata:value'] = 'Value for the preference';
 $string['privacy:metadata:userid'] = 'The user Identifier.';
 $string['privacy:path'] = 'Portfolio instances';
index 8a4d933..8b0fd76 100644 (file)
@@ -75,6 +75,10 @@ $string['privacy:metadata:middlename'] = 'The middle name of the user.';
 $string['privacy:metadata:mnethostid'] = 'An identifier for the mnet host if used.';
 $string['privacy:metadata:model'] = 'The device name, occam or iPhone etc..';
 $string['privacy:metadata:msn'] = 'The MSN identifier of the user.';
+$string['privacy:metadata:my_pages'] = 'User pages - dashboard and profile. This table does not contain personal data and only used to link dashboard blocks to users';
+$string['privacy:metadata:my_pages:name'] = 'Page name';
+$string['privacy:metadata:my_pages:private'] = 'Whether or not the page is private (dashboard) or public (profile)';
+$string['privacy:metadata:my_pages:userid'] = 'The user who owns this page or 0 for system defaults';
 $string['privacy:metadata:password'] = 'The password for this user to log into the system.';
 $string['privacy:metadata:passwordresettablesummary'] = 'A table tracking password reset confirmation tokens';
 $string['privacy:metadata:passwordtablesummary'] = 'A rotating log of hashes of previously used passwords for the user.';
@@ -87,6 +91,10 @@ $string['privacy:metadata:reason'] = 'The reason for requesting this course.';
 $string['privacy:metadata:requester'] = 'An identifier to a user that requested this course.';
 $string['privacy:metadata:requestsummary'] = 'Stores information about requests for courses that users make.';
 $string['privacy:metadata:suspended'] = 'A flag to show if the user has been suspended on this system.';
+$string['privacy:metadata:user_preferences'] = 'Preferences associated with the given user';
+$string['privacy:metadata:user_preferences:name'] = 'Preference name';
+$string['privacy:metadata:user_preferences:userid'] = 'User id';
+$string['privacy:metadata:user_preferences:value'] = 'Preference value';
 $string['privacy:metadata:username'] = 'The username for this user.';
 $string['privacy:metadata:secret'] = 'Secret.. not sure.';
 $string['privacy:metadata:sessdata'] = 'Session content';
index e9e737f..0e41d71 100644 (file)
@@ -91,6 +91,16 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
                 'timecreated'   => 'privacy:metadata:events_queue:timecreated',
             ], 'privacy:metadata:events_queue');
 
+        // The log table is defined in core but used in logstore_legacy.
+        $collection->add_database_table('log', [
+            'time' => 'privacy:metadata:log:time',
+            'userid' => 'privacy:metadata:log:userid',
+            'ip' => 'privacy:metadata:log:ip',
+            'action' => 'privacy:metadata:log:action',
+            'url' => 'privacy:metadata:log:url',
+            'info' => 'privacy:metadata:log:info'
+        ], 'privacy:metadata:log');
+
         return $collection;
     }
 
index bc4dea1..6ed8802 100644 (file)
@@ -215,10 +215,6 @@ class provider implements
     public static function delete_data_for_all_users_in_context(\context $context) {
         global $DB;
 
-        if (empty($context)) {
-            return;
-        }
-
         if ($context->contextlevel == CONTEXT_MODULE) {
             // Delete all assignment submissions for the assignment associated with the context module.
             $assignment = self::get_assignment_by_context($context);
index 4cdc875..c536633 100644 (file)
@@ -65,10 +65,26 @@ class provider implements
             'timestamp' => 'privacy:metadata:messages:timestamp',
         ], 'privacy:metadata:messages');
 
-        // The tables chat_messages_current and chat_users are not reported here
+        // The tables chat_messages_current and chat_users are not exported/deleted
         // because they are considered as short-lived data and are deleted on a
-        // regular basis by cron, or during normal requests. MDL-62006 was raised
-        // to discuss and/or implement support for those tables.
+        // regular basis by cron, or during normal requests. TODO MDL-62006.
+
+        $collection->add_database_table('chat_messages_current', [
+            'userid' => 'privacy:metadata:messages:userid',
+            'message' => 'privacy:metadata:messages:message',
+            'issystem' => 'privacy:metadata:messages:issystem',
+            'timestamp' => 'privacy:metadata:messages:timestamp'
+        ], 'privacy:metadata:chat_messages_current');
+
+        $collection->add_database_table('chat_users', [
+            'userid' => 'privacy:metadata:chat_users:userid',
+            'version' => 'privacy:metadata:chat_users:version',
+            'ip' => 'privacy:metadata:chat_users:ip',
+            'firstping' => 'privacy:metadata:chat_users:firstping',
+            'lastping' => 'privacy:metadata:chat_users:lastping',
+            'lastmessageping' => 'privacy:metadata:chat_users:lastmessageping',
+            'lang' => 'privacy:metadata:chat_users:lang'
+        ], 'privacy:metadata:chat_users');
 
         return $collection;
     }
index 66772ae..50d75be 100644 (file)
@@ -118,6 +118,15 @@ $string['page-mod-chat-x'] = 'Any chat module page';
 $string['pastchats'] = 'Past chat sessions';
 $string['pluginadministration'] = 'Chat administration';
 $string['pluginname'] = 'Chat';
+$string['privacy:metadata:chat_messages_current'] = 'Current chat session. This data is temporary and is deleted after the chat session is deleted';
+$string['privacy:metadata:chat_users'] = 'Keeps track of which users are in which chat rooms';
+$string['privacy:metadata:chat_users:firstping'] = 'Time of the first access to chat room';
+$string['privacy:metadata:chat_users:ip'] = 'User IP';
+$string['privacy:metadata:chat_users:lang'] = 'User language';
+$string['privacy:metadata:chat_users:lastmessageping'] = 'Time of the last message in this chat room';
+$string['privacy:metadata:chat_users:lastping'] = 'Time of the last access to chat room';
+$string['privacy:metadata:chat_users:userid'] = 'User id';
+$string['privacy:metadata:chat_users:version'] = 'How user accessed the chat (sockets/basic/ajax/header_js)';
 $string['privacy:metadata:messages'] = 'A record of the messages sent during a chat session';
 $string['privacy:metadata:messages:issystem'] = 'Whether the message is a system-generated message';
 $string['privacy:metadata:messages:message'] = 'The message';
index 4bb37ae..bf4b5e3 100644 (file)
@@ -117,6 +117,14 @@ class provider implements
             'forumid' => 'privacy:metadata:forum_track_prefs:forumid',
         ], 'privacy:metadata:forum_track_prefs');
 
+        // The 'forum_queue' table stores temporary data that is not exported/deleted.
+        $items->add_database_table('forum_queue', [
+            'userid' => 'privacy:metadata:forum_queue:userid',
+            'discussionid' => 'privacy:metadata:forum_queue:discussionid',
+            'postid' => 'privacy:metadata:forum_queue:postid',
+            'timemodified' => 'privacy:metadata:forum_queue:timemodified'
+        ], 'privacy:metadata:forum_queue');
+
         // Forum posts can be tagged and rated.
         $items->link_subsystem('core_tag', 'privacy:metadata:core_tag');
         $items->link_subsystem('core_rating', 'privacy:metadata:core_rating');
index 3b5884e..467609c 100644 (file)
@@ -457,6 +457,11 @@ $string['privacy:metadata:forum_posts:parent'] = 'The parent post that was repli
 $string['privacy:metadata:forum_posts:subject'] = 'The subject of the forum post.';
 $string['privacy:metadata:forum_posts:totalscore'] = 'The message of the forum post.';
 $string['privacy:metadata:forum_posts:userid'] = 'The ID of the user who authored the forum post.';
+$string['privacy:metadata:forum_queue'] = 'Temporary log of posts that will be mailed in digest form';
+$string['privacy:metadata:forum_queue:discussionid'] = 'Forum discussion id';
+$string['privacy:metadata:forum_queue:postid'] = 'Forum post id';
+$string['privacy:metadata:forum_queue:timemodified'] = 'The modified time of the original post';
+$string['privacy:metadata:forum_queue:userid'] = 'User who needs to be notified of the post';
 $string['privacy:metadata:forum_read'] = 'Information about which posts have been read by the user.';
 $string['privacy:metadata:forum_read:discussionid'] = 'The discussion that the post is in.';
 $string['privacy:metadata:forum_read:firstread'] = 'The first time that the post was read.';
index c610a41..e4eb689 100644 (file)
@@ -130,18 +130,33 @@ class provider implements
                   FROM {glossary_entries} ge
                   JOIN {glossary} g ON ge.glossaryid = g.id
                   JOIN {course_modules} cm ON g.id = cm.instance
-                  JOIN {context} c ON cm.id = c.instanceid
+                  JOIN {modules} m ON cm.module = m.id AND m.name = :modulename
+                  JOIN {context} c ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
                  WHERE c.id {$contextsql}
-                   AND ge.userid = :userid
-             OR EXISTS (SELECT 1 FROM {comments} com WHERE com.commentarea = :commentarea AND com.itemid = ge.id
-                        AND com.userid = :commentuserid)
-             OR EXISTS (SELECT 1 FROM {rating} r WHERE r.contextid = c.id AND r.itemid  = ge.id
-                        AND r.component = :ratingcomponent
-                   AND r.ratingarea = :ratingarea
-                   AND r.userid = :ratinguserid)
+                   AND (
+                        ge.userid = :userid
+                        OR
+                        EXISTS (
+                                SELECT 1
+                                  FROM {comments} com
+                                 WHERE com.commentarea = :commentarea AND com.itemid = ge.id AND com.userid = :commentuserid
+                            )
+                        OR
+                        EXISTS (
+                                SELECT 1
+                                  FROM {rating} r
+                                 WHERE r.contextid = c.id
+                                   AND r.itemid  = ge.id
+                                   AND r.component = :ratingcomponent
+                                   AND r.ratingarea = :ratingarea
+                                   AND r.userid = :ratinguserid
+                            )
+                    )
                ORDER BY ge.id, cm.id";
         $params = [
             'userid' => $user->id,
+            'modulename' => 'glossary',
+            'contextlevel' => CONTEXT_MODULE,
             'commentarea' => 'glossary_entry',
             'commentuserid' => $user->id,
             'ratingcomponent' => 'mod_glossary',
@@ -240,9 +255,6 @@ class provider implements
      */
     public static function delete_data_for_all_users_in_context(\context $context) {
         global $DB;
-        if (empty($context)) {
-            return;
-        }
 
         if ($context->contextlevel != CONTEXT_MODULE) {
             return;
index 022e4c0..b5c7d41 100644 (file)
@@ -49,6 +49,25 @@ class provider implements
      * @return collection the updated collection of metadata items.
      */
     public static function get_metadata(collection $items) : collection {
+        $items->add_external_location_link(
+            'lti_provider',
+            [
+                'userid' => 'privacy:metadata:userid',
+                'username' => 'privacy:metadata:username',
+                'useridnumber' => 'privacy:metadata:useridnumber',
+                'firstname' => 'privacy:metadata:firstname',
+                'lastname' => 'privacy:metadata:lastname',
+                'fullname' => 'privacy:metadata:fullname',
+                'email' => 'privacy:metadata:email',
+                'role' => 'privacy:metadata:role',
+                'courseid' => 'privacy:metadata:courseid',
+                'courseidnumber' => 'privacy:metadata:courseidnumber',
+                'courseshortname' => 'privacy:metadata:courseshortname',
+                'coursefullname' => 'privacy:metadata:coursefullname',
+            ],
+            'privacy:metadata:externalpurpose'
+        );
+
         $items->add_database_table(
             'lti_submission',
             [
index b567b3a..792762f 100644 (file)
@@ -356,7 +356,16 @@ $string['preferwidget'] = 'Prefer widget launch';
 $string['preferwidth'] = 'Preferred width';
 $string['press_to_submit'] = 'Press to launch this activity';
 $string['privacy'] = 'Privacy';
+$string['privacy:metadata:courseid'] = 'The ID of the course the user is accessing the LTI Consumer from';
+$string['privacy:metadata:courseidnumber'] = 'The ID number of the course the user is accessing the LTI Consumer from';
+$string['privacy:metadata:coursefullname'] = 'The fullname of the course the user is accessing the LTI Consumer from';
+$string['privacy:metadata:courseshortname'] = 'The shortname of the course the user is accessing the LTI Consumer from';
 $string['privacy:metadata:createdby'] = 'The user who created the record';
+$string['privacy:metadata:email'] = 'The email address of the user accessing the LTI Consumer';
+$string['privacy:metadata:externalpurpose'] = 'The LTI Consumer provides user information and context to the LTI Tool Provider.';
+$string['privacy:metadata:firstname'] = 'The firstname of the user accessing the LTI Consumer';
+$string['privacy:metadata:fullname'] = 'The fullname of the user accessing the LTI Consumer';
+$string['privacy:metadata:lastname'] = 'The lastname of the user accessing the LTI Consumer';
 $string['privacy:metadata:lti_submission'] = 'LTI submission';
 $string['privacy:metadata:lti_submission:datesubmitted'] = 'The timestamp indicating when the submission was made';
 $string['privacy:metadata:lti_submission:dateupdated'] = 'The timestamp indicating when the submission was modified';
@@ -367,8 +376,12 @@ $string['privacy:metadata:lti_tool_proxies'] = 'LTI proxies';
 $string['privacy:metadata:lti_tool_proxies:name'] = 'LTI proxy name';
 $string['privacy:metadata:lti_types'] = 'LTI types';
 $string['privacy:metadata:lti_types:name'] = 'LTI type name';
+$string['privacy:metadata:role'] = 'The role in the course for the user accessing the LTI Consumer';
 $string['privacy:metadata:timecreated'] = 'The date at which the record was created';
 $string['privacy:metadata:timemodified'] = 'The date at which the record was modified';
+$string['privacy:metadata:userid'] = 'The ID of the user accessing the LTI Consumer';
+$string['privacy:metadata:useridnumber'] = 'The ID number of the user accessing the LTI Consumer';
+$string['privacy:metadata:username'] = 'The username of the user accessing the LTI Consumer';
 $string['quickgrade'] = 'Allow quick grading';
 $string['quickgrade_help'] = 'If enabled, multiple tools can be graded on one page. Add grades and comments then click the "Save all my feedback" button to save all changes for that page.';
 $string['redirect'] = 'You will be redirected in few seconds. If you are not, press the button.';
index e888927..1506160 100644 (file)
@@ -43,7 +43,10 @@ class mod_lti_privacy_provider_testcase extends \core_privacy\tests\provider_tes
         $collection = new collection('mod_lti');
         $newcollection = provider::get_metadata($collection);
         $itemcollection = $newcollection->get_collection();
-        $this->assertCount(3, $itemcollection);
+        $this->assertCount(4, $itemcollection);
+
+        $ltiproviderexternal = array_shift($itemcollection);
+        $this->assertEquals('lti_provider', $ltiproviderexternal->get_name());
 
         $ltisubmissiontable = array_shift($itemcollection);
         $this->assertEquals('lti_submission', $ltisubmissiontable->get_name());
index 3f1b795..8ea61c9 100644 (file)
@@ -29,6 +29,7 @@ use core_privacy\local\metadata\collection;
 use core_privacy\local\request\context;
 use core_privacy\local\request\contextlist;
 use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
 
 /**
  * Provider for the portfolio API.
@@ -56,6 +57,22 @@ class provider implements
             'name' => 'privacy:metadata:name',
             'value' => 'privacy:metadata:value'
         ], 'privacy:metadata:instancesummary');
+
+        $collection->add_database_table('portfolio_log', [
+            'userid' => 'privacy:metadata:portfolio_log:userid',
+            'time' => 'privacy:metadata:portfolio_log:time',
+            'caller_class' => 'privacy:metadata:portfolio_log:caller_class',
+            'caller_component' => 'privacy:metadata:portfolio_log:caller_component',
+        ], 'privacy:metadata:portfolio_log');
+
+        // Temporary data is not exported/deleted in privacy API. It is cleaned by cron.
+        $collection->add_database_table('portfolio_tempdata', [
+            'data' => 'privacy:metadata:portfolio_tempdata:data',
+            'expirytime' => 'privacy:metadata:portfolio_tempdata:expirytime',
+            'userid' => 'privacy:metadata:portfolio_tempdata:userid',
+            'instance' => 'privacy:metadata:portfolio_tempdata:instance',
+        ], 'privacy:metadata:portfolio_tempdata');
+
         $collection->add_plugintype_link('portfolio', [], 'privacy:metadata');
         return $collection;
     }
@@ -69,9 +86,11 @@ class provider implements
     public static function get_contexts_for_userid(int $userid) : contextlist {
         $sql = "SELECT ctx.id
                   FROM {context} ctx
-                  JOIN {portfolio_instance_user} piu ON ctx.instanceid = piu.userid AND ctx.contextlevel = :usercontext
-                 WHERE piu.userid = :userid";
-        $params = ['userid' => $userid, 'usercontext' => CONTEXT_USER];
+                 WHERE ctx.instanceid = :userid AND ctx.contextlevel = :usercontext
+                  AND (EXISTS (SELECT 1 FROM {portfolio_instance_user} WHERE userid = :userid1) OR
+                       EXISTS (SELECT 1 FROM {portfolio_log} WHERE userid = :userid2))
+                 ";
+        $params = ['userid' => $userid, 'usercontext' => CONTEXT_USER, 'userid1' => $userid, 'userid2' => $userid];
         $contextlist = new contextlist();
         $contextlist->add_from_sql($sql, $params);
         return $contextlist;
@@ -95,16 +114,63 @@ class provider implements
             }
         });
 
+        if (empty($correctusercontext)) {
+            return;
+        }
+
         $usercontext = array_shift($correctusercontext);
 
+        $sql = "SELECT pi.id AS instanceid, pi.name,
+                       piu.id AS preferenceid, piu.name AS preference, piu.value,
+                       pl.id AS logid, pl.time AS logtime, pl.caller_class, pl.caller_file,
+                       pl.caller_component, pl.returnurl, pl.continueurl
+                  FROM {portfolio_instance} pi
+             LEFT JOIN {portfolio_instance_user} piu ON piu.instance = pi.id AND piu.userid = :userid1
+             LEFT JOIN {portfolio_log} pl ON pl.portfolio = pi.id AND pl.userid = :userid2
+                 WHERE piu.id IS NOT NULL OR pl.id IS NOT NULL";
+        $params = ['userid1' => $usercontext->instanceid, 'userid2' => $usercontext->instanceid];
+        $instances = [];
+        $rs = $DB->get_recordset_sql($sql, $params);
+        foreach ($rs as $record) {
+            $instances += [$record->name =>
+                (object)[
+                    'name' => $record->name,
+                    'preferences' => [],
+                    'logs' => [],
+                ]
+            ];
+            if ($record->preferenceid) {
+                $instances[$record->name]->preferences[$record->preferenceid] = (object)[
+                    'name' => $record->preference,
+                    'value' => $record->value,
+                ];
+            }
+            if ($record->logid) {
+                $instances[$record->name]->logs[$record->logid] = (object)[
+                    'time' => transform::datetime($record->logtime),
+                    'caller_class' => $record->caller_class,
+                    'caller_file' => $record->caller_file,
+                    'caller_component' => $record->caller_component,
+                    'returnurl' => $record->returnurl,
+                    'continueurl' => $record->continueurl
+                ];
+            }
+        }
+        $rs->close();
 
-        $sql = "SELECT pi.name, piu.name AS preference, piu.value
-                  FROM {portfolio_instance_user} piu
-                  JOIN {portfolio_instance} pi ON piu.instance = pi.id
-                 WHERE piu.userid = :userid";
-        $params = ['userid' => $usercontext->instanceid];
-        $instances = $DB->get_records_sql($sql, $params);
         if (!empty($instances)) {
+            foreach ($instances as &$instance) {
+                if (!empty($instance->preferences)) {
+                    $instance->preferences = array_values($instance->preferences);
+                } else {
+                    unset($instance->preferences);
+                }
+                if (!empty($instance->logs)) {
+                    $instance->logs = array_values($instance->logs);
+                } else {
+                    unset($instance->logs);
+                }
+            }
             \core_privacy\local\request\writer::with_context($contextlist->current())->export_data(
                     [get_string('privacy:path', 'portfolio')], (object) $instances);
         }
@@ -120,6 +186,8 @@ class provider implements
         // Context could be anything, BEWARE!
         if ($context->contextlevel == CONTEXT_USER) {
             $DB->delete_records('portfolio_instance_user', ['userid' => $context->instanceid]);
+            $DB->delete_records('portfolio_tempdata', ['userid' => $context->instanceid]);
+            $DB->delete_records('portfolio_log', ['userid' => $context->instanceid]);
         }
     }
 
@@ -141,9 +209,15 @@ class provider implements
             }
         });
 
+        if (empty($correctusercontext)) {
+            return;
+        }
+
         $usercontext = array_shift($correctusercontext);
 
         $DB->delete_records('portfolio_instance_user', ['userid' => $usercontext->instanceid]);
+        $DB->delete_records('portfolio_tempdata', ['userid' => $usercontext->instanceid]);
+        $DB->delete_records('portfolio_log', ['userid' => $usercontext->instanceid]);
     }
 
     /**
index 93ffb98..a1e2a66 100644 (file)
@@ -47,6 +47,22 @@ class portfolio_privacy_provider_test extends \core_privacy\tests\provider_testc
             'value' => $value
         ];
         $DB->insert_record('portfolio_instance_user', $userinstance);
+
+        $DB->insert_record('portfolio_log', [
+            'portfolio' => $portfolioinstance->id,
+            'userid' => $user->id,
+            'caller_class' => 'forum_portfolio_caller',
+            'caller_component' => 'mod_forum',
+            'time' => time(),
+        ]);
+
+        $DB->insert_record('portfolio_log', [
+            'portfolio' => $portfolioinstance->id,
+            'userid' => $user->id,
+            'caller_class' => 'workshop_portfolio_caller',
+            'caller_component' => 'mod_workshop',
+            'time' => time(),
+        ]);
     }
 
     /**
@@ -57,9 +73,11 @@ class portfolio_privacy_provider_test extends \core_privacy\tests\provider_testc
         $collection = \core_portfolio\privacy\provider::get_metadata($collection);
         $this->assertNotEmpty($collection);
         $items = $collection->get_collection();
-        $this->assertEquals(2, count($items));
+        $this->assertEquals(4, count($items));
         $this->assertInstanceOf(\core_privacy\local\metadata\types\database_table::class, $items[0]);
-        $this->assertInstanceOf(\core_privacy\local\metadata\types\plugintype_link::class, $items[1]);
+        $this->assertInstanceOf(\core_privacy\local\metadata\types\database_table::class, $items[1]);
+        $this->assertInstanceOf(\core_privacy\local\metadata\types\database_table::class, $items[2]);
+        $this->assertInstanceOf(\core_privacy\local\metadata\types\plugintype_link::class, $items[3]);
     }
 
     /**
@@ -105,6 +123,7 @@ class portfolio_privacy_provider_test extends \core_privacy\tests\provider_testc
         \core_portfolio\privacy\provider::delete_data_for_all_users_in_context($systemcontext);
         $records = $DB->get_records('portfolio_instance_user');
         $this->assertCount(2, $records);
+        $this->assertCount(4, $DB->get_records('portfolio_log'));
         $context = context_user::instance($user1->id);
         \core_portfolio\privacy\provider::delete_data_for_all_users_in_context($context);
         $records = $DB->get_records('portfolio_instance_user');
@@ -112,6 +131,7 @@ class portfolio_privacy_provider_test extends \core_privacy\tests\provider_testc
         $this->assertCount(1, $records);
         $data = array_shift($records);
         $this->assertEquals($user2->id, $data->userid);
+        $this->assertCount(2, $DB->get_records('portfolio_log'));
     }
 
     /**
@@ -128,6 +148,7 @@ class portfolio_privacy_provider_test extends \core_privacy\tests\provider_testc
 
         $records = $DB->get_records('portfolio_instance_user');
         $this->assertCount(2, $records);
+        $this->assertCount(4, $DB->get_records('portfolio_log'));
 
         $context = context_user::instance($user1->id);
         $contextlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_portfolio', [$context->id]);
@@ -137,5 +158,6 @@ class portfolio_privacy_provider_test extends \core_privacy\tests\provider_testc
         $this->assertCount(1, $records);
         $data = array_shift($records);
         $this->assertEquals($user2->id, $data->userid);
+        $this->assertCount(2, $DB->get_records('portfolio_log'));
     }
 }
index 6530068..47e73d2 100644 (file)
  */
 namespace core_privacy;
 use core_privacy\local\metadata\collection;
+use core_privacy\local\metadata\null_provider;
+use core_privacy\local\request\context_aware_provider;
 use core_privacy\local\request\contextlist_collection;
+use core_privacy\local\request\core_user_data_provider;
+use core_privacy\local\request\data_provider;
+use core_privacy\local\request\user_preference_provider;
+use \core_privacy\local\metadata\provider as metadata_provider;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -103,6 +109,21 @@ defined('MOODLE_INTERNAL') || die();
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class manager {
+
+    /**
+     * @var manager_observer Observer.
+     */
+    protected $observer;
+
+    /**
+     * Set the failure handler.
+     *
+     * @param   manager_observer $observer
+     */
+    public function set_observer(manager_observer $observer) {
+        $this->observer = $observer;
+    }
+
     /**
      * Checks whether the given component is compliant with the core_privacy API.
      * To be considered compliant, a component must declare whether (and where) it stores personal data.
@@ -120,7 +141,7 @@ class manager {
      */
     public function component_is_compliant(string $component) : bool {
         // Components which don't store user data need only implement the null_provider.
-        if ($this->component_implements($component, \core_privacy\local\metadata\null_provider::class)) {
+        if ($this->component_implements($component, null_provider::class)) {
             return true;
         }
 
@@ -129,8 +150,8 @@ class manager {
         }
 
         // Components which store user data must implement the local\metadata\provider and the local\request\data_provider.
-        if ($this->component_implements($component, \core_privacy\local\metadata\provider::class) &&
-            $this->component_implements($component, \core_privacy\local\request\data_provider::class)) {
+        if ($this->component_implements($component, metadata_provider::class) &&
+            $this->component_implements($component, data_provider::class)) {
             return true;
         }
 
@@ -144,8 +165,9 @@ class manager {
      * @return string The key to retrieve the language string for the null provider reason.
      */
     public function get_null_provider_reason(string $component) : string {
-        if ($this->component_implements($component, \core_privacy\local\metadata\null_provider::class)) {
-            return $this->get_provider_classname($component)::get_reason();
+        if ($this->component_implements($component, null_provider::class)) {
+            $reason = $this->handled_component_class_callback($component, null_provider::class, 'get_reason', []);
+            return empty($reason) ? 'privacy:reason' : $reason;
         } else {
             throw new \coding_exception('Call to undefined method', 'Please only call this method on a null provider.');
         }
@@ -177,8 +199,10 @@ class manager {
         // Get the metadata, and put into an assoc array indexed by component name.
         $metadata = [];
         foreach ($this->get_component_list() as $component) {
-            if ($this->component_implements($component, \core_privacy\local\metadata\provider::class)) {
-                $metadata[$component] = $this->get_provider_classname($component)::get_metadata(new collection($component));
+            $componentmetadata = $this->handled_component_class_callback($component, metadata_provider::class,
+                'get_metadata', [new collection($component)]);
+            if ($componentmetadata !== null) {
+                $metadata[$component] = $componentmetadata;
             }
         }
         return $metadata;
@@ -187,6 +211,7 @@ class manager {
     /**
      * Gets a collection of resultset objects for all components.
      *
+     *
      * @param int $userid the id of the user we're fetching contexts for.
      * @return contextlist_collection the collection of contextlist items for the respective components.
      */
@@ -208,9 +233,9 @@ class manager {
             $a->progress++;
             $a->datetime = userdate(time());
             $progress->output(get_string('trace:processingcomponent', 'core_privacy', $a), 2);
-            if ($this->component_implements($component, \core_privacy\local\request\core_user_data_provider::class)) {
-                $contextlist = $this->get_provider_classname($component)::get_contexts_for_userid($userid);
-            } else {
+            $contextlist = $this->handled_component_class_callback($component, core_user_data_provider::class,
+                'get_contexts_for_userid', [$userid]);
+            if ($contextlist === null) {
                 $contextlist = new local\request\contextlist();
             }
 
@@ -265,13 +290,14 @@ class manager {
             $progress->output(get_string('trace:processingcomponent', 'core_privacy', $a), 2);
 
             // Core user data providers.
-            if ($this->component_implements($component, \core_privacy\local\request\core_user_data_provider::class)) {
+            if ($this->component_implements($component, core_user_data_provider::class)) {
                 if (count($approvedcontextlist)) {
                     // This plugin has data it knows about. It is responsible for storing basic data about anything it is
                     // told to export.
-                    $this->get_provider_classname($component)::export_user_data($approvedcontextlist);
+                    $this->handled_component_class_callback($component, core_user_data_provider::class,
+                        'export_user_data', [$approvedcontextlist]);
                 }
-            } else if (!$this->component_implements($component, \core_privacy\local\request\context_aware_provider::class)) {
+            } else if (!$this->component_implements($component, context_aware_provider::class)) {
                 // This plugin does not know that it has data - export the shared data it doesn't know about.
                 local\request\helper::export_data_for_null_provider($approvedcontextlist);
             }
@@ -290,15 +316,13 @@ class manager {
             $a->datetime = userdate(time());
             $progress->output(get_string('trace:processingcomponent', 'core_privacy', $a), 2);
             // Core user preference providers.
-            if ($this->component_implements($component, \core_privacy\local\request\user_preference_provider::class)) {
-                $this->get_provider_classname($component)::export_user_preferences($contextlistcollection->get_userid());
-            }
+            $this->handled_component_class_callback($component, user_preference_provider::class,
+                'export_user_preferences', [$contextlistcollection->get_userid()]);
 
             // Contextual information providers. Give each component a chance to include context information based on the
             // existence of a child context in the contextlist_collection.
-            if ($this->component_implements($component, \core_privacy\local\request\context_aware_provider::class)) {
-                $this->get_provider_classname($component)::export_context_data($contextlistcollection);
-            }
+            $this->handled_component_class_callback($component, context_aware_provider::class,
+                'export_context_data', [$contextlistcollection]);
         }
         $progress->output(get_string('trace:done', 'core_privacy'), 1);
 
@@ -343,12 +367,11 @@ class manager {
             $a->datetime = userdate(time());
             $progress->output(get_string('trace:processingcomponent', 'core_privacy', $a), 2);
 
-            if ($this->component_is_core_provider($component)) {
-                if (count($approvedcontextlist)) {
-                    // The component knows about data that it has.
-                    // Have it delete its own data.
-                    $this->get_provider_classname($approvedcontextlist->get_component())::delete_data_for_user($approvedcontextlist);
-                }
+            if (count($approvedcontextlist)) {
+                // The component knows about data that it has.
+                // Have it delete its own data.
+                $this->handled_component_class_callback($approvedcontextlist->get_component(), core_user_data_provider::class,
+                    'delete_data_for_user', [$approvedcontextlist]);
             }
 
             // Delete any shared user data it doesn't know about.
@@ -360,7 +383,7 @@ class manager {
     /**
      * Delete all use data which matches the specified deletion criteria.
      *
-     * @param   context         $context   The specific context to delete data for.
+     * @param \context $context The specific context to delete data for.
      */
     public function delete_data_for_all_users_in_context(\context $context) {
         $progress = static::get_log_tracer();
@@ -380,11 +403,10 @@ class manager {
             $a->datetime = userdate(time());
             $progress->output(get_string('trace:processingcomponent', 'core_privacy', $a), 2);
 
-            if ($this->component_implements($component, \core_privacy\local\request\core_user_data_provider::class)) {
-                // This component knows about specific data that it owns.
-                // Have it delete all of that user data for the context.
-                $this->get_provider_classname($component)::delete_data_for_all_users_in_context($context);
-            }
+            // If this component knows about specific data that it owns,
+            // have it delete all of that user data for the context.
+            $this->handled_component_class_callback($component, core_user_data_provider::class,
+                'delete_data_for_all_users_in_context', [$context]);
 
             // Delete any shared user data it doesn't know about.
             local\request\helper::delete_data_for_all_users_in_context($component, $context);
@@ -392,16 +414,6 @@ class manager {
         $progress->output(get_string('trace:done', 'core_privacy'), 1);
     }
 
-    /**
-     * Check whether the specified component is a core provider.
-     *
-     * @param string $component the frankenstyle component name.
-     * @return bool true if the component is a core provider, false otherwise.
-     */
-    protected function component_is_core_provider($component) {
-        return $this->component_implements($component, \core_privacy\local\request\core_data_provider::class);
-    }
-
     /**
      * Returns a list of frankenstyle names of core components (plugins and subsystems).
      *
@@ -433,7 +445,7 @@ class manager {
      * @return string the fully qualified provider classname.
      */
     public static function get_provider_classname_for_component(string $component) {
-        return "$component\privacy\provider";
+        return "$component\\privacy\\provider";
     }
 
     /**
@@ -500,4 +512,41 @@ class manager {
 
         return new \text_progress_trace();
     }
+
+    /**
+     * Call the named method with the specified params on the supplied component if it implements the relevant interface
+     * on its provider.
+     *
+     * @param   string  $component The component to call
+     * @param   string  $interface The interface to implement
+     * @param   string  $methodname The method to call
+     * @param   array   $params The params to call
+     * @return  mixed
+     */
+    protected function handled_component_class_callback(string $component, string $interface, string $methodname, array $params) {
+        try {
+            return static::component_class_callback($component, $interface, $methodname, $params);
+        } catch (\Throwable $e) {
+            debugging($e->getMessage(), DEBUG_DEVELOPER, $e->getTrace());
+            $this->component_class_callback_failed($e, $component, $interface, $methodname, $params);
+
+            return null;
+        }
+    }
+
+    /**
+     * Notifies the observer of any failure.
+     *
+     * @param \Throwable $e
+     * @param string $component
+     * @param string $interface
+     * @param string $methodname
+     * @param array $params
+     */
+    protected function component_class_callback_failed(\Throwable $e, string $component, string $interface,
+            string $methodname, array $params) {
+        if ($this->observer) {
+            call_user_func_array([$this->observer, 'handle_component_failure'], func_get_args());
+        }
+    }
 }
diff --git a/privacy/classes/manager_observer.php b/privacy/classes/manager_observer.php
new file mode 100644 (file)
index 0000000..b1d7847
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains the interface required to observe failures in the manager.
+ *
+ * @package core_privacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The interface for a Manager observer.
+ *
+ * @package core_privacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface manager_observer {
+
+    /**
+     * Handle failure of a component.
+     *
+     * @param \Throwable $e
+     * @param string $component
+     * @param string $interface
+     * @param string $methodname
+     * @param array $params
+     */
+    public function handle_component_failure($e, $component, $interface, $methodname, array $params);
+}
diff --git a/privacy/tests/fixtures/provider_a.php b/privacy/tests/fixtures/provider_a.php
new file mode 100644 (file)
index 0000000..9f88acf
--- /dev/null
@@ -0,0 +1,91 @@
+<?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/>.
+
+/**
+ * Test provider which works.
+ *
+ * @package core_privacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_component_a\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+
+/**
+ * Mock core_user_data_provider for unit tests.
+ *
+ * @package core_privacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 {
+
+    /**
+     * Get 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_subsystem_link('core_files');
+        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 {
+        $c = new \core_privacy\local\request\contextlist();
+        $c->add_system_context();
+
+        return $c;
+    }
+
+    /**
+     * 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) {
+    }
+
+    /**
+     * 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) {
+    }
+
+    /**
+     * 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) {
+    }
+}
diff --git a/privacy/tests/fixtures/provider_throwing_exception.php b/privacy/tests/fixtures/provider_throwing_exception.php
new file mode 100644 (file)
index 0000000..6c6c5fc
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test provider which has issues.
+ *
+ * @package core_privacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_component_broken\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+
+/**
+ * Mock core_user_data_provider for unit tests.
+ *
+ * @package core_privacy
+ * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 {
+
+    /**
+     * Get 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 {
+        throw new \coding_exception(__FUNCTION__);
+    }
+
+    /**
+     * 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 {
+        throw new \coding_exception(__FUNCTION__);
+    }
+
+    /**
+     * 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) {
+        throw new \coding_exception(__FUNCTION__);
+    }
+
+    /**
+     * 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) {
+        throw new \coding_exception(__FUNCTION__);
+    }
+
+    /**
+     * 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) {
+        throw new \coding_exception(__FUNCTION__);
+    }
+}
index b16e1c7..3f1f9ac 100644 (file)
@@ -28,8 +28,11 @@ require_once($CFG->dirroot . '/privacy/tests/fixtures/mock_null_provider.php');
 require_once($CFG->dirroot . '/privacy/tests/fixtures/mock_provider.php');
 require_once($CFG->dirroot . '/privacy/tests/fixtures/mock_plugin_subplugin_provider.php');
 require_once($CFG->dirroot . '/privacy/tests/fixtures/mock_mod_with_user_data_provider.php');
+require_once($CFG->dirroot . '/privacy/tests/fixtures/provider_a.php');
+require_once($CFG->dirroot . '/privacy/tests/fixtures/provider_throwing_exception.php');
 
 use \core_privacy\local\request\writer;
+use \core_privacy\local\request\approved_contextlist;
 
 /**
  * Privacy manager unit tests.
@@ -175,7 +178,7 @@ class privacy_manager_testcase extends advanced_testcase {
         // Create an approved contextlist.
         $approvedcontextlistcollection = new \core_privacy\local\request\contextlist_collection(10);
         foreach ($contextlistcollection->get_contextlists() as $contextlist) {
-            $approvedcontextlist = new \core_privacy\local\request\approved_contextlist(new stdClass(), $contextlist->get_component(),
+            $approvedcontextlist = new approved_contextlist(new stdClass(), $contextlist->get_component(),
                 $contextlist->get_contextids());
             $approvedcontextlistcollection->add_contextlist($approvedcontextlist);
         }
@@ -210,7 +213,7 @@ class privacy_manager_testcase extends advanced_testcase {
         // Create an approved contextlist.
         $approvedcontextlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
         foreach ($contextlistcollection->get_contextlists() as $contextlist) {
-            $approvedcontextlist = new \core_privacy\local\request\approved_contextlist($user, $contextlist->get_component(),
+            $approvedcontextlist = new approved_contextlist($user, $contextlist->get_component(),
                 $contextlist->get_contextids());
             $approvedcontextlistcollection->add_contextlist($approvedcontextlist);
         }
@@ -300,4 +303,164 @@ class privacy_manager_testcase extends advanced_testcase {
             ],
         ];
     }
+
+    /**
+     * Test that get_contexts_for_userid() with a failing item.
+     */
+    public function test_get_contexts_for_userid_with_failing() {
+        // Get a mock manager, in which the core components list is mocked to include all mock plugins.
+        // testcomponent is a core provider, testcomponent2 isa null provider, testcomponent3 is subplugin provider (non core).
+        $mockman = $this->get_mock_manager_with_core_components(['mod_component_broken', 'mod_component_a']);
+
+        $observer = $this->getMockBuilder(\core_privacy\manager_observer::class)
+            ->setMethods(['handle_component_failure'])
+            ->getMock();
+        $mockman->set_observer($observer);
+
+        $observer->expects($this->once())
+            ->method('handle_component_failure')
+            ->with(
+                $this->isInstanceOf(\coding_exception::class),
+                $this->identicalTo('mod_component_broken'),
+                $this->identicalTo(\core_privacy\local\request\core_user_data_provider::class),
+                $this->identicalTo('get_contexts_for_userid'),
+                $this->anything()
+            );
+
+        // Get the contextlist_collection.
+        $contextlistcollection = $mockman->get_contexts_for_userid(10);
+        $this->assertDebuggingCalled();
+        $this->assertInstanceOf(\core_privacy\local\request\contextlist_collection::class, $contextlistcollection);
+        $this->assertCount(1, $contextlistcollection);
+
+        // The component which completed shoudl have returned a contextlist.
+        $this->assertInstanceOf(\core_privacy\local\request\contextlist::class,
+                                $contextlistcollection->get_contextlist_for_component('mod_component_a'));
+        $this->assertEmpty($contextlistcollection->get_contextlist_for_component('mod_component_broken'));
+    }
+
+    /**
+     * Test that export_user_data() with a failing item.
+     */
+    public function test_export_user_data_with_failing() {
+        $user = \core_user::get_user_by_username('admin');
+        $mockman = $this->get_mock_manager_with_core_components(['mod_component_broken', 'mod_component_a']);
+        $context = \context_system::instance();
+        $contextid = $context->id;
+
+        $observer = $this->getMockBuilder(\core_privacy\manager_observer::class)
+            ->setMethods(['handle_component_failure'])
+            ->getMock();
+        $mockman->set_observer($observer);
+
+        $observer->expects($this->once())
+            ->method('handle_component_failure')
+            ->with(
+                $this->isInstanceOf(\coding_exception::class),
+                $this->identicalTo('mod_component_broken'),
+                $this->identicalTo(\core_privacy\local\request\core_user_data_provider::class),
+                $this->identicalTo('export_user_data'),
+                $this->anything()
+            );
+
+        $collection = new \core_privacy\local\request\contextlist_collection(10);
+        $collection->add_contextlist(new approved_contextlist($user, 'mod_component_broken', [$contextid]));
+        $collection->add_contextlist(new approved_contextlist($user, 'mod_component_a', [$contextid]));
+
+        // Get the contextlist_collection.
+        $mockman->export_user_data($collection);
+        $this->assertDebuggingCalled();
+    }
+
+    /**
+     * Test that delete_data_for_user() with a failing item.
+     */
+    public function test_delete_data_for_user_with_failing() {
+        $user = \core_user::get_user_by_username('admin');
+        $mockman = $this->get_mock_manager_with_core_components(['mod_component_broken', 'mod_component_a']);
+        $context = \context_system::instance();
+        $contextid = $context->id;
+
+        $observer = $this->getMockBuilder(\core_privacy\manager_observer::class)
+            ->setMethods(['handle_component_failure'])
+            ->getMock();
+        $mockman->set_observer($observer);
+
+        $observer->expects($this->once())
+            ->method('handle_component_failure')
+            ->with(
+                $this->isInstanceOf(\coding_exception::class),
+                $this->identicalTo('mod_component_broken'),
+                $this->identicalTo(\core_privacy\local\request\core_user_data_provider::class),
+                $this->identicalTo('delete_data_for_user'),
+                $this->anything()
+            );
+
+        $collection = new \core_privacy\local\request\contextlist_collection(10);
+        $collection->add_contextlist(new approved_contextlist($user, 'mod_component_broken', [$contextid]));
+        $collection->add_contextlist(new approved_contextlist($user, 'mod_component_a', [$contextid]));
+
+        // Get the contextlist_collection.
+        $mockman->delete_data_for_user($collection);
+        $this->assertDebuggingCalled();
+    }
+
+    /**
+     * Test that delete_data_for_all_users_in_context() with a failing item.
+     */
+    public function test_delete_data_for_all_users_in_context_with_failing() {
+        $user = \core_user::get_user_by_username('admin');
+        $mockman = $this->get_mock_manager_with_core_components(['mod_component_broken', 'mod_component_a']);
+        $context = \context_system::instance();
+
+        $observer = $this->getMockBuilder(\core_privacy\manager_observer::class)
+            ->setMethods(['handle_component_failure'])
+            ->getMock();
+        $mockman->set_observer($observer);
+
+        $observer->expects($this->once())
+            ->method('handle_component_failure')
+            ->with(
+                $this->isInstanceOf(\coding_exception::class),
+                $this->identicalTo('mod_component_broken'),
+                $this->identicalTo(\core_privacy\local\request\core_user_data_provider::class),
+                $this->identicalTo('delete_data_for_all_users_in_context'),
+                $this->anything()
+            );
+
+        // Get the contextlist_collection.
+        $mockman->delete_data_for_all_users_in_context($context);
+        $this->assertDebuggingCalled();
+    }
+
+    /**
+     * Test that get_metadata_for_components() with a failing item.
+     */
+    public function test_get_metadata_for_components_with_failing() {
+        $user = \core_user::get_user_by_username('admin');
+        $mockman = $this->get_mock_manager_with_core_components(['mod_component_broken', 'mod_component_a']);
+        $context = \context_system::instance();
+
+        $observer = $this->getMockBuilder(\core_privacy\manager_observer::class)
+            ->setMethods(['handle_component_failure'])
+            ->getMock();
+        $mockman->set_observer($observer);
+
+        $observer->expects($this->once())
+            ->method('handle_component_failure')
+            ->with(
+                $this->isInstanceOf(\coding_exception::class),
+                $this->identicalTo('mod_component_broken'),
+                $this->identicalTo(\core_privacy\local\metadata\provider::class),
+                $this->identicalTo('get_metadata'),
+                $this->anything()
+            );
+
+        // Get the contextlist_collection.
+        $metadata = $mockman->get_metadata_for_components();
+        $this->assertDebuggingCalled();
+
+        $this->assertInternalType('array', $metadata);
+        $this->assertCount(1, $metadata);
+    }
 }
index 496d996..0ca9768 100644 (file)
@@ -46,7 +46,10 @@ class provider_testcase extends advanced_testcase {
      * @return array the array of frankenstyle component names with the relevant class name.
      */
     public function get_component_list() {
-        $components = [];
+        $components = ['core' => [
+            'component' => 'core',
+            'classname' => manager::get_provider_classname_for_component('core')
+        ]];
         // Get all plugins.
         $plugintypes = \core_component::get_plugin_types();
         foreach ($plugintypes as $plugintype => $typedir) {
@@ -204,4 +207,72 @@ class provider_testcase extends advanced_testcase {
         return false;
     }
 
+    /**
+     * Finds user fields in a table
+     *
+     * Returns fields that have foreign key to user table and fields that are named 'userid'.
+     *
+     * @param xmldb_table $table
+     * @return array
+     */
+    protected function get_userid_fields(xmldb_table $table) {
+        $userfields = [];
+
+        // Find all fields that have a foreign key to 'id' field in 'user' table.
+        $keys = $table->getKeys();
+        foreach ($keys as $key) {
+            $reffields = $key->getRefFields();
+            $fields = $key->getFields();
+            if ($key->getRefTable() === 'user' && count($reffields) == 1 && $reffields[0] == 'id' && count($fields) == 1) {
+                $userfields[$fields[0]] = $fields[0];
+            }
+        }
+        // Find fields with the name 'userid' even if they don't have a foreign key.
+        $fields = $table->getFields();
+        foreach ($fields as $field) {
+            if ($field->getName() == 'userid') {
+                $userfields['userid'] = 'userid';
+            }
+        }
+
+        return $userfields;
+    }
+
+    /**
+     * Test that all tables with user fields are covered by metadata providers
+     */
+    public function test_table_coverage() {
+        global $DB;
+        $dbman = $DB->get_manager();
+        $schema = $dbman->get_install_xml_schema();
+        $tables = [];
+        foreach ($schema->getTables() as $table) {
+            if ($table->getName() === 'role_sortorder') {
+                // TODO MDL-62459 this table is not used anywhere. Remove the table and this statement.
+                continue;
+            }
+            if ($fields = $this->get_userid_fields($table)) {
+                $tables[$table->getName()] = '  - ' . $table->getName() . ' (' . join(', ', $fields) . ')';
+            }
+        }
+
+        $componentlist = $this->metadata_provider_provider();
+        foreach ($componentlist as $componentarray) {
+            $component = $componentarray['component'];
+            $classname = $componentarray['classname'];
+            $collection = new collection($component);
+            $metadata = $classname::get_metadata($collection);
+            foreach ($metadata->get_collection() as $item) {
+                if ($item instanceof database_table) {
+                    unset($tables[$item->get_name()]);
+                }
+            }
+        }
+
+        if ($tables) {
+            $this->fail("The following tables with user fields must be covered with metadata providers: \n".
+                join("\n", $tables));
+        }
+
+    }
 }
index 6fb1cf1..2936c05 100644 (file)
@@ -424,7 +424,13 @@ class qtype_calculated_variable_substituter {
         if ($error = qtype_calculated_find_formula_errors($expression)) {
             throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error);
         }
-        return $this->calculate_raw($this->substitute_values_for_eval($expression));
+        $expression = $this->substitute_values_for_eval($expression);
+        if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) {
+            // Some placeholders were not substituted.
+            throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '',
+                '{' . reset($datasets) . '}');
+        }
+        return $this->calculate_raw($expression);
     }
 
     /**
index 0a66ad8..a42d426 100644 (file)
@@ -99,6 +99,13 @@ class qtype_calculated_variable_substituter_test extends advanced_testcase {
         $this->assertEquals('= 3', $vs->replace_expressions_in_text('= {={a} + {b}}'));
     }
 
+    public function test_expression_has_unmapped_placeholder() {
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage(get_string('illegalformulasyntax', 'qtype_calculated', '{c}'));
+        $vs = new qtype_calculated_variable_substituter(array('a' => 1, 'b' => 2), '.');
+        $vs->calculate('{c} - {a} + {b}');
+    }
+
     public function test_replace_expressions_in_text_negative() {
         $vs = new qtype_calculated_variable_substituter(array('a' => -1, 'b' => 2), '.');
         $this->assertEquals('temperatures -1 and 2',
index 9c07a8d..bf24aac 100644 (file)
@@ -293,11 +293,7 @@ form.popupform div {
 
 // Accessibility: text 'seen' by screen readers but not visual users.
 .accesshide {
-    position: absolute;
-    /*rtl:ignore*/
-    left: -10000px;
-    font-weight: normal;
-    font-size: 1em;
+    @include sr-only();
 }
 
 span.hide,
index 1619f5c..b233ff6 100644 (file)
@@ -154,6 +154,18 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
             'requester' => 'privacy:metadata:requester'
         ];
 
+        $mypages = [
+            'userid' => 'privacy:metadata:my_pages:userid',
+            'name' => 'privacy:metadata:my_pages:name',
+            'private' => 'privacy:metadata:my_pages:private',
+        ];
+
+        $userpreferences = [
+            'userid' => 'privacy:metadata:user_preferences:userid',
+            'name' => 'privacy:metadata:user_preferences:name',
+            'value' => 'privacy:metadata:user_preferences:value'
+        ];
+
         $collection->add_database_table('user', $userfields, 'privacy:metadata:usertablesummary');
         $collection->add_database_table('user_password_history', $passwordhistory, 'privacy:metadata:passwordtablesummary');
         $collection->add_database_table('user_password_resets', $userpasswordresets, 'privacy:metadata:passwordresettablesummary');
@@ -161,6 +173,8 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         $collection->add_database_table('user_devices', $userdevices, 'privacy:metadata:devicetablesummary');
         $collection->add_database_table('course_request', $courserequest, 'privacy:metadata:requestsummary');
         $collection->add_database_table('sessions', $usersessions, 'privacy:metadata:sessiontablesummary');
+        $collection->add_database_table('my_pages', $mypages, 'privacy:metadata:my_pages');
+        $collection->add_database_table('user_preferences', $userpreferences, 'privacy:metadata:user_preferences');
         $collection->add_subsystem_link('core_files', [], 'privacy:metadata:filelink');
 
         return $collection;
index a7f514f..39886c6 100644 (file)
@@ -43,7 +43,7 @@
 </p>
 <p>
 <label for="bulk-note">
-<span class="sr-only">{{#str}}note, core_note{{/str}}</span>
+<span class="sr-only">{{#str}}note, core_notes{{/str}}</span>
 </label>
 <textarea id="bulk-note" rows="3" data-max-rows="10" data-auto-rows="true" cols="30" class="form-control"></textarea>
 </p>