Merge branch 'wip-MDL-61976-master' of git://github.com/marinaglancy/moodle
authorDavid Monllao <davidm@moodle.com>
Wed, 9 May 2018 09:43:25 +0000 (11:43 +0200)
committerJun Pataleta <jun@moodle.com>
Thu, 10 May 2018 01:15:59 +0000 (09:15 +0800)
103 files changed:
admin/roles/classes/privacy/provider.php [new file with mode: 0644]
admin/roles/tests/privacy_test.php [new file with mode: 0644]
admin/settings/development.php
admin/settings/security.php
availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js
availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js
availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js
availability/yui/src/form/js/form.js
course/classes/privacy/provider.php [new file with mode: 0644]
course/tests/externallib_test.php
course/tests/privacy_test.php [new file with mode: 0644]
enrol/classes/privacy/provider.php [new file with mode: 0644]
enrol/paypal/classes/privacy/provider.php [new file with mode: 0644]
enrol/paypal/lang/en/enrol_paypal.php
enrol/paypal/tests/privacy_provider_test.php [new file with mode: 0644]
enrol/tests/privacy_test.php [new file with mode: 0644]
group/classes/privacy/provider.php [new file with mode: 0644]
group/tests/privacy_provider_test.php [new file with mode: 0644]
index.php
install/lang/tl/moodle.php
lang/en/course.php [new file with mode: 0644]
lang/en/enrol.php
lang/en/group.php
lang/en/portfolio.php
lang/en/role.php
lang/en/user.php [new file with mode: 0644]
lib/db/upgrade.php
lib/moodlelib.php
lib/portfolio/exporter.php
lib/portfoliolib.php
lib/tests/weblib_format_text_test.php
media/player/swf/tests/player_test.php
mod/data/classes/privacy/provider.php
mod/forum/classes/message/inbound/reply_handler.php
mod/forum/classes/privacy/provider.php [new file with mode: 0644]
mod/forum/classes/privacy/subcontext_info.php [new file with mode: 0644]
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/discuss.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/locallib.php
mod/forum/post.php
mod/forum/rsslib.php
mod/forum/tests/externallib_test.php
mod/forum/tests/helper.php [new file with mode: 0644]
mod/forum/tests/mail_test.php
mod/forum/tests/portfolio_caller_test.php [new file with mode: 0644]
mod/forum/tests/privacy_provider_test.php [new file with mode: 0644]
mod/forum/tests/subscriptions_test.php
mod/forum/version.php
mod/glossary/classes/privacy/provider.php [new file with mode: 0644]
mod/glossary/lang/en/glossary.php
mod/glossary/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/allocation/manual/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/allocation/manual/lang/en/workshopallocation_manual.php
mod/workshop/allocation/manual/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/allocation/random/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/allocation/random/lang/en/workshopallocation_random.php
mod/workshop/allocation/scheduled/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/allocation/scheduled/lang/en/workshopallocation_scheduled.php
mod/workshop/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/classes/privacy/workshopform_legacy_polyfill.php [new file with mode: 0644]
mod/workshop/classes/privacy/workshopform_provider.php [new file with mode: 0644]
mod/workshop/db/install.xml
mod/workshop/db/upgrade.php
mod/workshop/eval/best/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/eval/best/lang/en/workshopeval_best.php
mod/workshop/form/accumulative/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/form/accumulative/lang/en/workshopform_accumulative.php
mod/workshop/form/accumulative/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/form/comments/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/form/comments/lang/en/workshopform_comments.php
mod/workshop/form/comments/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/form/numerrors/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/form/numerrors/lang/en/workshopform_numerrors.php
mod/workshop/form/numerrors/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/form/rubric/classes/privacy/provider.php [new file with mode: 0644]
mod/workshop/form/rubric/lang/en/workshopform_rubric.php
mod/workshop/form/rubric/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/lang/en/workshop.php
mod/workshop/tests/generator/lib.php
mod/workshop/tests/privacy_provider_test.php [new file with mode: 0644]
mod/workshop/version.php
portfolio/classes/privacy/provider.php
portfolio/tests/privacy_provider_test.php
privacy/classes/local/request/context_aware_provider.php [new file with mode: 0644]
privacy/classes/manager.php
report/stats/classes/privacy/provider.php
report/stats/lang/en/report_stats.php
report/stats/tests/privacy_test.php [new file with mode: 0644]
tag/classes/tests/privacy_helper.php [new file with mode: 0644]
theme/boost/scss/moodle/bs4alphacompat.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/undo.scss
theme/boost/templates/columns2.mustache
theme/boost/templates/core/paging_bar.mustache
theme/boost/templates/core_form/element-duration-inline.mustache [changed mode: 0755->0644]
theme/boost/templates/footer.mustache
user/classes/privacy/provider.php [new file with mode: 0644]
user/tests/privacy_test.php [new file with mode: 0644]
version.php

diff --git a/admin/roles/classes/privacy/provider.php b/admin/roles/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..ee8bcea
--- /dev/null
@@ -0,0 +1,382 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for core_role.
+ *
+ * @package    core_role
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_role\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\contextlist;
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\transform;
+use \core_privacy\local\request\writer;
+
+/**
+ * Privacy provider for core_role.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\subsystem\provider,
+    \core_privacy\local\request\subsystem\plugin_provider,
+    \core_privacy\local\request\user_preference_provider {
+
+    /**
+     * Get information about the user data stored by this plugin.
+     *
+     * @param  collection $collection An object for storing metadata.
+     * @return collection The metadata.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $rolecapabilities = [
+            'roleid' => 'privacy:metadata:role_capabilities:roleid',
+            'capability' => 'privacy:metadata:role_capabilities:capability',
+            'permission' => 'privacy:metadata:role_capabilities:permission',
+            'timemodified' => 'privacy:metadata:role_capabilities:timemodified',
+            'modifierid' => 'privacy:metadata:role_capabilities:modifierid'
+        ];
+        $roleassignments = [
+            'roleid' => 'privacy:metadata:role_assignments:roleid',
+            'userid' => 'privacy:metadata:role_assignments:userid',
+            'timemodified' => 'privacy:metadata:role_assignments:timemodified',
+            'modifierid' => 'privacy:metadata:role_assignments:modifierid',
+            'component' => 'privacy:metadata:role_assignments:component',
+            'itemid' => 'privacy:metadata:role_assignments:itemid'
+        ];
+        $collection->add_database_table('role_capabilities', $rolecapabilities,
+            'privacy:metadata:role_capabilities:tableexplanation');
+        $collection->add_database_table('role_assignments', $roleassignments,
+            'privacy:metadata:role_assignments:tableexplanation');
+
+        $collection->add_user_preference('definerole_showadvanced',
+            'privacy:metadata:preference:showadvanced');
+
+        return $collection;
+    }
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $showadvanced = get_user_preferences('definerole_showadvanced', null, $userid);
+        if ($showadvanced !== null) {
+            writer::export_user_preference('core_role',
+                'definerole_showadvanced',
+                transform::yesno($showadvanced),
+                get_string('privacy:metadata:preference:showadvanced', 'core_role')
+            );
+        }
+    }
+    /**
+     * Return all contexts for this userid.
+     *
+     * @param  int $userid The user ID.
+     * @return contextlist The list of context IDs.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        global $DB;
+
+        $contextlist = new contextlist();
+
+        // The role_capabilities table contains user data.
+        $contexts = [
+            CONTEXT_SYSTEM,
+            CONTEXT_USER,
+            CONTEXT_COURSECAT,
+            CONTEXT_COURSE,
+            CONTEXT_MODULE,
+            CONTEXT_BLOCK
+        ];
+        list($insql, $inparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED);
+        $sql = "SELECT ctx.id
+                  FROM {context} ctx
+                  JOIN {role_capabilities} rc
+                    ON rc.contextid = ctx.id
+                   AND ((ctx.contextlevel {$insql} AND rc.modifierid = :modifierid)
+                    OR (ctx.contextlevel = :contextlevel AND ctx.instanceid = :userid))";
+        $params = [
+            'modifierid' => $userid,
+            'contextlevel' => CONTEXT_USER,
+            'userid' => $userid
+         ];
+        $params += $inparams;
+
+        $contextlist->add_from_sql($sql, $params);
+
+        // The role_assignments table contains user data.
+        $contexts = [
+            CONTEXT_SYSTEM,
+            CONTEXT_USER,
+            CONTEXT_COURSECAT,
+            CONTEXT_COURSE,
+            CONTEXT_MODULE,
+            CONTEXT_BLOCK
+        ];
+        list($insql, $inparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED);
+        $params = [
+            'userid' => $userid,
+            'modifierid' => $userid
+         ];
+        $params += $inparams;
+        $sql = "SELECT ctx.id
+                  FROM {role_assignments} ra
+                  JOIN {context} ctx
+                    ON ctx.id = ra.contextid
+                   AND ctx.contextlevel {$insql}
+                 WHERE (ra.userid = :userid
+                    OR ra.modifierid = :modifierid)
+                   AND ra.component != 'tool_cohortroles'";
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param  approved_contextlist $contextlist The list of approved contexts for a user.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist)) {
+             return;
+        }
+
+        $rolesnames = self::get_roles_name();
+        $userid = $contextlist->get_user()->id;
+        $ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
+        list($insql, $inparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        // Role Assignments export data.
+        $contexts = [
+            CONTEXT_SYSTEM,
+            CONTEXT_USER,
+            CONTEXT_COURSECAT,
+            CONTEXT_COURSE,
+            CONTEXT_MODULE,
+            CONTEXT_BLOCK
+        ];
+        list($inctxsql, $ctxparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED);
+        $sql = "SELECT ra.id, ra.contextid, ra.roleid, ra.userid, ra.timemodified, ra.modifierid, $ctxfields
+                  FROM {role_assignments} ra
+                  JOIN {context} ctx
+                    ON ctx.id = ra.contextid
+                   AND ctx.contextlevel {$inctxsql}
+                   AND (ra.userid = :userid OR ra.modifierid = :modifierid)
+                   AND ra.component != 'tool_cohortroles'
+                  JOIN {role} r
+                    ON r.id = ra.roleid
+                 WHERE ctx.id {$insql}";
+        $params = ['userid' => $userid, 'modifierid' => $userid];
+        $params += $inparams;
+        $params += $ctxparams;
+        $assignments = $DB->get_recordset_sql($sql, $params);
+        foreach ($assignments as $assignment) {
+            \context_helper::preload_from_record($assignment);
+            $alldata[$assignment->contextid][$rolesnames[$assignment->roleid]][] = (object)[
+                'timemodified' => transform::datetime($assignment->timemodified),
+                'userid' => transform::user($assignment->userid),
+                'modifierid' => transform::user($assignment->modifierid)
+            ];
+        }
+        $assignments->close();
+        if (!empty($alldata)) {
+            array_walk($alldata, function($roledata, $contextid) {
+                $context = \context::instance_by_id($contextid);
+                array_walk($roledata, function($data, $rolename) use ($context) {
+                    writer::with_context($context)->export_data(
+                            [get_string('privacy:metadata:role_assignments', 'core_role'), $rolename],
+                            (object)$data);
+                });
+            });
+            unset($alldata);
+        }
+
+        // Role Capabilities export data.
+        $strpermissions = self::get_permissions_name();
+        $contexts = [
+            CONTEXT_SYSTEM,
+            CONTEXT_USER,
+            CONTEXT_COURSECAT,
+            CONTEXT_COURSE,
+            CONTEXT_MODULE,
+            CONTEXT_BLOCK
+        ];
+        list($inctxsql, $ctxparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED);
+        $sql = "SELECT rc.id, rc.contextid, rc.capability, rc.permission, rc.timemodified, rc.roleid, $ctxfields
+                  FROM {context} ctx
+                  JOIN {role_capabilities} rc
+                    ON rc.contextid = ctx.id
+                   AND ((ctx.contextlevel {$inctxsql} AND rc.modifierid = :modifierid)
+                    OR (ctx.contextlevel = :contextlevel AND ctx.instanceid = :userid))
+                 WHERE ctx.id {$insql}";
+        $params = [
+            'modifierid' => $userid,
+            'contextlevel' => CONTEXT_USER,
+            'userid' => $userid
+         ];
+        $params += $inparams;
+        $params += $ctxparams;
+        $capabilities = $DB->get_recordset_sql($sql, $params);
+        foreach ($capabilities as $capability) {
+            \context_helper::preload_from_record($capability);
+            $alldata[$capability->contextid][$rolesnames[$capability->roleid]][] = (object)[
+                'timemodified' => transform::datetime($capability->timemodified),
+                'capability' => $capability->capability,
+                'permission' => $strpermissions[$capability->permission]
+            ];
+        }
+        $capabilities->close();
+        if (!empty($alldata)) {
+            array_walk($alldata, function($capdata, $contextid) {
+                $context = \context::instance_by_id($contextid);
+                array_walk($capdata, function($data, $rolename) use ($context) {
+                    writer::with_context($context)->export_data(
+                            [get_string('privacy:metadata:role_capabilities', 'core_role'), $rolename],
+                            (object)$data);
+                });
+            });
+        }
+    }
+    /**
+     * Exports the data relating to tool_cohortroles component on role assignments by
+     * Assign user roles to cohort feature.
+     *
+     * @param  int $userid The user ID.
+     */
+    public static function export_user_role_to_cohort(int $userid) {
+        global $DB;
+
+        $rolesnames = self::get_roles_name();
+        $sql = "SELECT ra.id, ra.contextid, ra.roleid, ra.userid, ra.timemodified, ra.modifierid, r.id as roleid
+                  FROM {role_assignments} ra
+                  JOIN {context} ctx
+                    ON ctx.id = ra.contextid
+                   AND ctx.contextlevel = :contextlevel
+                   AND ra.component = 'tool_cohortroles'
+                  JOIN {role} r
+                    ON r.id = ra.roleid
+                 WHERE ctx.instanceid = :instanceid
+                    OR ra.userid = :userid";
+        $params = ['userid' => $userid, 'instanceid' => $userid, 'contextlevel' => CONTEXT_USER];
+        $assignments = $DB->get_recordset_sql($sql, $params);
+        foreach ($assignments as $assignment) {
+            $alldata[$assignment->contextid][$rolesnames[$assignment->roleid]][] = (object)[
+                'timemodified' => transform::datetime($assignment->timemodified),
+                'userid' => transform::user($assignment->userid),
+                'modifierid' => transform::user($assignment->modifierid)
+            ];
+        }
+        $assignments->close();
+        if (!empty($alldata)) {
+            array_walk($alldata, function($roledata, $contextid) {
+                $context = \context::instance_by_id($contextid);
+                array_walk($roledata, function($data, $rolename) use ($context) {
+                    writer::with_context($context)->export_related_data(
+                            [get_string('privacy:metadata:role_cohortroles', 'core_role'), $rolename], 'cohortroles',
+                            (object)$data);
+                });
+            });
+        }
+    }
+    /**
+     * Delete all user data for this context.
+     *
+     * @param  \context $context The context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        // Don't remove data from role_capabilities.
+        // Because this data affects the whole Moodle, there are override capabilities.
+        // Don't belong to the modifier user.
+
+        // Remove data from role_assignments.
+        if (empty($context)) {
+            return;
+        }
+        $DB->delete_records('role_assignments', ['contextid' => $context->id]);
+    }
+    /**
+     * Delete all user data for this user only.
+     *
+     * @param  approved_contextlist $contextlist The list of approved contexts for a user.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        // Don't remove data from role_capabilities.
+        // Because this data affects the whole Moodle, there are override capabilities.
+        // Don't belong to the modifier user.
+
+        // Remove data from role_assignments.
+        if (empty($contextlist->count())) {
+            return;
+        }
+        $userid = $contextlist->get_user()->id;
+        foreach ($contextlist->get_contexts() as $context) {
+            // Only delete the roles assignments where the user is assigned in all contexts.
+            $DB->delete_records('role_assignments', ['userid' => $userid, 'contextid' => $context->id]);
+        }
+    }
+    /**
+     * Delete user entries in role_assignments related to the feature
+     * Assign user roles to cohort feature.
+     *
+     * @param  int $userid The user ID.
+     */
+    public static function delete_user_role_to_cohort(int $userid) {
+        global $DB;
+
+        // Delete entries where userid is a mentor by tool_cohortroles.
+        $DB->delete_records('role_assignments', ['userid' => $userid, 'component' => 'tool_cohortroles']);
+    }
+    /**
+     * Get all the localised roles name in a simple array.
+     *
+     * @return array Array of name of the roles by roleid.
+     */
+    protected static function get_roles_name() {
+        $roles = role_fix_names(get_all_roles(), \context_system::instance(), ROLENAME_ORIGINAL);
+        $rolesnames = array();
+        foreach ($roles as $role) {
+            $rolesnames[$role->id] = $role->localname;
+        }
+        return $rolesnames;
+    }
+    /**
+     * Get all the permissions name in a simple array.
+     *
+     * @return array Array of permissions name.
+     */
+    protected static function get_permissions_name() {
+        $strpermissions = array(
+            CAP_INHERIT => get_string('inherit', 'role'),
+            CAP_ALLOW => get_string('allow', 'role'),
+            CAP_PREVENT => get_string('prevent', 'role'),
+            CAP_PROHIBIT => get_string('prohibit', 'role')
+        );
+        return $strpermissions;
+    }
+}
\ No newline at end of file
diff --git a/admin/roles/tests/privacy_test.php b/admin/roles/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..bffc919
--- /dev/null
@@ -0,0 +1,477 @@
+<?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 test for core_role
+ *
+ * @package    core_role
+ * @category   test
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+use \core_role\privacy\provider;
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\writer;
+use \core_privacy\tests\provider_testcase;
+use \core_privacy\local\request\transform;
+use \tool_cohortroles\api;
+
+/**
+ * Privacy test for core_role
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_role_privacy_testcase extends provider_testcase {
+    /**
+     * Test to check export_user_preferences.
+     * returns user preferences data.
+     */
+    public function test_export_user_preferences() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $showadvanced = 1;
+        set_user_preference('definerole_showadvanced', $showadvanced);
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $prefs = $writer->get_user_preferences('core_role');
+        $this->assertEquals(transform::yesno($showadvanced), transform::yesno($prefs->definerole_showadvanced->value));
+        $this->assertEquals(get_string('privacy:metadata:preference:showadvanced', 'core_role'),
+            $prefs->definerole_showadvanced->description);
+    }
+    /**
+     * Check all contexts are returned if there is any user data for this user.
+     */
+    public function test_get_contexts_for_userid() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $user = $this->getDataGenerator()->create_user();
+        $this->assertEmpty(provider::get_contexts_for_userid($user->id));
+
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = \context_user::instance($user2->id);
+        $course = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecat = $this->getDataGenerator()->create_category();
+        $cm = $this->getDataGenerator()->create_module('chat', ['course' => $course->id]);
+        $cmcontext = \context_module::instance($cm->cmid);
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id));
+        $cmcontext2 = \context_module::instance($page->cmid);
+        $coursecontext = \context_course::instance($course->id);
+        $coursecontext2 = \context_course::instance($course2->id);
+        $coursecatcontext = \context_coursecat::instance($coursecat->id);
+        $systemcontext = \context_system::instance();
+        $block = $this->getDataGenerator()->create_block('online_users');
+        $blockcontext = \context_block::instance($block->id);
+
+        $student = $DB->get_record('role', array('shortname' => 'student'), '*', MUST_EXIST);
+        $manager = $DB->get_record('role', array('shortname' => 'manager'), '*', MUST_EXIST);
+
+        // Role assignments, where the user is assigned.
+        role_assign($student->id, $user->id, $cmcontext2->id);
+        role_assign($student->id, $user->id, $coursecontext2->id);
+        role_assign($student->id, $user->id, $blockcontext->id);
+        role_assign($manager->id, $user->id, $usercontext2->id);
+        // Role assignments, where the user makes assignments.
+        $this->setUser($user);
+        role_assign($student->id, $user2->id, $coursecontext->id);
+        role_assign($manager->id, $user2->id, $coursecatcontext->id);
+        role_assign($manager->id, $user2->id, $systemcontext->id);
+
+        // Role capabilities.
+        $this->setUser($user);
+        $result = assign_capability('moodle/backup:backupcourse', CAP_ALLOW, $student->id, $cmcontext->id);
+
+        $contextlist = provider::get_contexts_for_userid($user->id)->get_contextids();
+        $this->assertCount(8, $contextlist);
+        $this->assertTrue(in_array($cmcontext->id, $contextlist));
+    }
+
+    /**
+     * Test that user data is exported correctly.
+     */
+    public function test_export_user_data() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = \context_user::instance($user2->id);
+        $course = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecat = $this->getDataGenerator()->create_category();
+        $cm = $this->getDataGenerator()->create_module('chat', ['course' => $course->id]);
+        $cmcontext = \context_module::instance($cm->cmid);
+        $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id));
+        $cmcontext2 = \context_module::instance($page->cmid);
+        $coursecontext = \context_course::instance($course->id);
+        $coursecontext2 = \context_course::instance($course2->id);
+        $coursecatcontext = \context_coursecat::instance($coursecat->id);
+        $systemcontext = \context_system::instance();
+        $block = $this->getDataGenerator()->create_block('online_users');
+        $blockcontext = \context_block::instance($block->id);
+
+        $student = $DB->get_record('role', array('shortname' => 'student'), '*', MUST_EXIST);
+        $manager = $DB->get_record('role', array('shortname' => 'manager'), '*', MUST_EXIST);
+        $rolesnames = self::get_roles_name();
+
+        $subcontextstudent = [
+            get_string('privacy:metadata:role_assignments', 'core_role'),
+            $rolesnames[$student->id]
+        ];
+        $subcontextmanager = [
+            get_string('privacy:metadata:role_assignments', 'core_role'),
+            $rolesnames[$manager->id]
+        ];
+        $subcontextrc = [
+            get_string('privacy:metadata:role_capabilities', 'core_role'),
+            $rolesnames[$student->id]
+        ];
+
+        // Test over role assignments.
+        // Where the user is assigned.
+        role_assign($student->id, $user->id, $cmcontext2->id);
+        role_assign($student->id, $user->id, $coursecontext2->id);
+        role_assign($student->id, $user->id, $blockcontext->id);
+        role_assign($manager->id, $user->id, $usercontext2->id);
+        // Where the user makes assignments.
+        $this->setUser($user);
+        role_assign($manager->id, $user2->id, $coursecatcontext->id);
+        role_assign($manager->id, $user2->id, $systemcontext->id);
+
+        // Test overridable roles in module, course, category, user, system and block.
+        assign_capability('moodle/backup:backupactivity', CAP_ALLOW, $student->id, $cmcontext->id, true);
+        assign_capability('moodle/backup:backupcourse', CAP_ALLOW, $student->id, $coursecontext->id, true);
+        assign_capability('moodle/category:manage', CAP_ALLOW, $student->id, $coursecatcontext->id, true);
+        assign_capability('moodle/backup:backupcourse', CAP_ALLOW, $student->id, $systemcontext->id, true);
+        assign_capability('moodle/block:edit', CAP_ALLOW, $student->id, $blockcontext->id, true);
+        assign_capability('moodle/competency:evidencedelete', CAP_ALLOW, $student->id, $usercontext2->id, true);
+
+        // Retrieve the user's context ids.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $approvedcontextlist = new approved_contextlist($user, 'core_role', $contextlist->get_contextids());
+
+        $strpermissions = array(
+            CAP_INHERIT => get_string('inherit', 'role'),
+            CAP_ALLOW => get_string('allow', 'role'),
+            CAP_PREVENT => get_string('prevent', 'role'),
+            CAP_PROHIBIT => get_string('prohibit', 'role')
+        );
+        // Retrieve role capabilities and role assignments.
+        provider::export_user_data($approvedcontextlist);
+        foreach ($contextlist as $context) {
+            $writer = writer::with_context($context);
+            $this->assertTrue($writer->has_any_data());
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                if ($data = $writer->get_data($subcontextstudent)) {
+                    $this->assertEquals($user->id, reset($data)->userid);
+                }
+                if ($data = $writer->get_data($subcontextrc)) {
+                    $this->assertEquals('moodle/backup:backupactivity', reset($data)->capability);
+                    $this->assertEquals($strpermissions[CAP_ALLOW], reset($data)->permission);
+                }
+            }
+            if ($context->contextlevel == CONTEXT_COURSE) {
+                if ($data = $writer->get_data($subcontextstudent)) {
+                    $this->assertEquals($user->id, reset($data)->userid);
+                }
+                if ($data = $writer->get_data($subcontextrc)) {
+                    $this->assertEquals('moodle/backup:backupcourse', reset($data)->capability);
+                }
+            }
+            if ($context->contextlevel == CONTEXT_COURSECAT) {
+                if ($data = $writer->get_data($subcontextmanager)) {
+                    $this->assertEquals($user->id, reset($data)->modifierid);
+                }
+                if ($data = $writer->get_data($subcontextrc)) {
+                    $this->assertEquals('moodle/category:manage', reset($data)->capability);
+                }
+            }
+            if ($context->contextlevel == CONTEXT_SYSTEM) {
+                if ($data = $writer->get_data($subcontextmanager)) {
+                    $this->assertEquals($user->id, reset($data)->modifierid);
+                }
+                if ($data = $writer->get_data($subcontextrc)) {
+                    $this->assertEquals('moodle/backup:backupcourse', reset($data)->capability);
+                }
+            }
+            if ($context->contextlevel == CONTEXT_BLOCK) {
+                if ($data = $writer->get_data($subcontextstudent)) {
+                    $this->assertEquals($user->id, reset($data)->userid);
+                }
+                if ($data = $writer->get_data($subcontextrc)) {
+                    $this->assertEquals('moodle/block:edit', reset($data)->capability);
+                }
+            }
+            if ($context->contextlevel == CONTEXT_USER) {
+                if ($data = $writer->get_data($subcontextmanager)) {
+                    $this->assertEquals($user->id, reset($data)->userid);
+                }
+                if ($data = $writer->get_data($subcontextrc)) {
+                    $this->assertEquals('moodle/competency:evidencedelete', reset($data)->capability);
+                }
+            }
+        }
+    }
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = \context_user::instance($user2->id);
+        $user3 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = \context_course::instance($course->id);
+        $coursecat = $this->getDataGenerator()->create_category();
+        $coursecatcontext = \context_coursecat::instance($coursecat->id);
+        $systemcontext = \context_system::instance();
+        $cm = $this->getDataGenerator()->create_module('chat', ['course' => $course->id]);
+        $cmcontext = \context_module::instance($cm->cmid);
+        $student = $DB->get_record('role', array('shortname' => 'student'), '*', MUST_EXIST);
+        $manager = $DB->get_record('role', array('shortname' => 'manager'), '*', MUST_EXIST);
+        $block = $this->getDataGenerator()->create_block('online_users');
+        $blockcontext = \context_block::instance($block->id);
+
+        // Role assignments CONTEXT_COURSE.
+        role_assign($student->id, $user->id, $coursecontext->id);
+        role_assign($student->id, $user2->id, $coursecontext->id);
+        role_assign($student->id, $user3->id, $coursecontext->id);
+        $count = $DB->count_records('role_assignments', ['contextid' => $coursecontext->id]);
+        $this->assertEquals(3, $count);
+        // Role assignments CONTEXT_COURSECAT.
+        role_assign($student->id, $user2->id, $coursecatcontext->id);
+        role_assign($student->id, $user3->id, $coursecatcontext->id);
+        $count = $DB->count_records('role_assignments', ['contextid' => $coursecatcontext->id]);
+        $this->assertEquals(2, $count);
+        // Role assignments CONTEXT_SYSTEM.
+        role_assign($student->id, $user->id, $systemcontext->id);
+        $count = $DB->count_records('role_assignments', ['contextid' => $systemcontext->id]);
+        $this->assertEquals(1, $count);
+        // Role assignments CONTEXT_MODULE.
+        role_assign($student->id, $user->id, $cmcontext->id);
+        $count = $DB->count_records('role_assignments', ['contextid' => $cmcontext->id]);
+        $this->assertEquals(1, $count);
+        // Role assigments CONTEXT_BLOCK.
+        role_assign($student->id, $user->id, $blockcontext->id);
+        $count = $DB->count_records('role_assignments', ['contextid' => $blockcontext->id]);
+        $this->assertEquals(1, $count);
+        // Role assigments CONTEXT_USER.
+        role_assign($manager->id, $user->id, $usercontext2->id);
+        $count = $DB->count_records('role_assignments', ['contextid' => $usercontext2->id]);
+        $this->assertEquals(1, $count);
+
+        // Delete data based on CONTEXT_COURSE context.
+        provider::delete_data_for_all_users_in_context($coursecontext);
+        // After deletion, the role_assignments entries for this context should have been deleted.
+        $count = $DB->count_records('role_assignments', ['contextid' => $coursecontext->id]);
+        $this->assertEquals(0, $count);
+        // Check it is not removing data on other contexts.
+        $count = $DB->count_records('role_assignments', ['contextid' => $coursecatcontext->id]);
+        $this->assertEquals(2, $count);
+        $count = $DB->count_records('role_assignments', ['contextid' => $systemcontext->id]);
+        $this->assertEquals(1, $count);
+        $count = $DB->count_records('role_assignments', ['contextid' => $cmcontext->id]);
+        $this->assertEquals(1, $count);
+        // Delete data based on CONTEXT_COURSECAT context.
+        provider::delete_data_for_all_users_in_context($coursecatcontext);
+        // After deletion, the role_assignments entries for this context should have been deleted.
+        $count = $DB->count_records('role_assignments', ['contextid' => $coursecatcontext->id]);
+        $this->assertEquals(0, $count);
+        // Delete data based on CONTEXT_SYSTEM context.
+        provider::delete_data_for_all_users_in_context($systemcontext);
+        // After deletion, the role_assignments entries for this context should have been deleted.
+        $count = $DB->count_records('role_assignments', ['contextid' => $systemcontext->id]);
+        $this->assertEquals(0, $count);
+        // Delete data based on CONTEXT_MODULE context.
+        provider::delete_data_for_all_users_in_context($cmcontext);
+        // After deletion, the role_assignments entries for this context should have been deleted.
+        $count = $DB->count_records('role_assignments', ['contextid' => $cmcontext->id]);
+        $this->assertEquals(0, $count);
+        // Delete data based on CONTEXT_BLOCK context.
+        provider::delete_data_for_all_users_in_context($usercontext2);
+        // After deletion, the role_assignments entries for this context should have been deleted.
+        $count = $DB->count_records('role_assignments', ['contextid' => $usercontext2->id]);
+        $this->assertEquals(0, $count);
+    }
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = \context_user::instance($user2->id);
+        $user3 = $this->getDataGenerator()->create_user();
+        $usercontext3 = \context_user::instance($user3->id);
+        $course = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course3 = $this->getDataGenerator()->create_course();
+        $coursecontext = \context_course::instance($course->id);
+        $coursecontext2 = \context_course::instance($course2->id);
+        $coursecontext3 = \context_course::instance($course3->id);
+        $coursecat = $this->getDataGenerator()->create_category();
+        $coursecatcontext = \context_coursecat::instance($coursecat->id);
+        $systemcontext = \context_system::instance();
+        $cm = $this->getDataGenerator()->create_module('chat', ['course' => $course->id]);
+        $cmcontext = \context_module::instance($cm->cmid);
+        $student = $DB->get_record('role', array('shortname' => 'student'), '*', MUST_EXIST);
+        $manager = $DB->get_record('role', array('shortname' => 'manager'), '*', MUST_EXIST);
+        $block = $this->getDataGenerator()->create_block('online_users');
+        $blockcontext = \context_block::instance($block->id);
+
+        // Role assignments, Where the user is assigned.
+        role_assign($student->id, $user->id, $coursecontext->id);
+        role_assign($student->id, $user->id, $coursecontext2->id);
+        role_assign($student->id, $user->id, $coursecatcontext->id);
+        role_assign($student->id, $user->id, $cmcontext->id);
+        role_assign($student->id, $user->id, $systemcontext->id);
+        role_assign($student->id, $user->id, $blockcontext->id);
+        role_assign($manager->id, $user->id, $usercontext2->id);
+        role_assign($manager->id, $user->id, $usercontext3->id);
+        $count = $DB->count_records('role_assignments', ['userid' => $user->id]);
+        $this->assertEquals(8, $count);
+        // Role assignments, where the user makes assignments.
+        $this->setUser($user);
+        role_assign($student->id, $user2->id, $coursecontext3->id);
+        role_assign($student->id, $user3->id, $coursecontext3->id);
+        $count = $DB->count_records('role_assignments', ['modifierid' => $user->id]);
+        $this->assertEquals(2, $count);
+
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $approvedcontextlist = new approved_contextlist($user, 'core_role', $contextlist->get_contextids());
+        provider::delete_data_for_user($approvedcontextlist);
+        // After deletion, the role_assignments assigned to the user should have been deleted.
+        $count = $DB->count_records('role_assignments', ['userid' => $user->id]);
+        $this->assertEquals(0, $count);
+        // After deletion, the role_assignments assigned by the user should not have been deleted.
+        $count = $DB->count_records('role_assignments', ['modifierid' => $user->id]);
+        $this->assertEquals(2, $count);
+    }
+    /**
+     * Export for a user with a key against a script where no instance is specified.
+     */
+    public function test_export_user_role_to_cohort() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        // Assign user roles to cohort.
+        $user = $this->getDataGenerator()->create_user();
+        $contextuser = \context_user::instance($user->id);
+        $teacher = $DB->get_record('role', array('shortname' => 'teacher'), '*', MUST_EXIST);
+        $cohort = $this->getDataGenerator()->create_cohort();
+        $userassignover = $this->getDataGenerator()->create_user();
+        $contextuserassignover = \context_user::instance($userassignover->id);
+        cohort_add_member($cohort->id, $userassignover->id);
+        $this->setAdminUser();
+        $params = (object) array(
+            'userid' => $user->id,
+            'roleid' => $teacher->id,
+            'cohortid' => $cohort->id
+        );
+        api::create_cohort_role_assignment($params);
+        api::sync_all_cohort_roles();
+        $rolesnames = self::get_roles_name();
+        $subcontextteacher = [
+            get_string('privacy:metadata:role_cohortroles', 'core_role'),
+            $rolesnames[$teacher->id]
+        ];
+        // Test User is assigned role teacher to cohort.
+        provider::export_user_role_to_cohort($user->id);
+        $writer = writer::with_context($contextuserassignover);
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontextteacher, 'cohortroles');
+        $this->assertEquals($user->id, reset($exported)->userid);
+
+        // Test User is member of a cohort which User2 is assigned to role to this cohort.
+        $user2 = $this->getDataGenerator()->create_user();
+        $cohort2 = $this->getDataGenerator()->create_cohort();
+        cohort_add_member($cohort2->id, $user->id);
+        $params = (object) array(
+            'userid' => $user2->id,
+            'roleid' => $teacher->id,
+            'cohortid' => $cohort2->id
+        );
+        api::create_cohort_role_assignment($params);
+        api::sync_all_cohort_roles();
+        provider::export_user_role_to_cohort($user->id);
+        $writer = writer::with_context($contextuser);
+        $this->assertTrue($writer->has_any_data());
+        $exported = $writer->get_related_data($subcontextteacher, 'cohortroles');
+        $this->assertEquals($user2->id, reset($exported)->userid);
+    }
+    /**
+     * Test for provider::delete_user_role_to_cohort().
+     */
+    public function test_delete_user_role_to_cohort() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        // Assign user roles to cohort.
+        $user = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $user4 = $this->getDataGenerator()->create_user();
+        $teacher = $DB->get_record('role', array('shortname' => 'teacher'), '*', MUST_EXIST);
+        $cohort = $this->getDataGenerator()->create_cohort();
+        cohort_add_member($cohort->id, $user2->id);
+        cohort_add_member($cohort->id, $user3->id);
+        cohort_add_member($cohort->id, $user4->id);
+        $this->setAdminUser();
+        $params = (object) array(
+            'userid' => $user->id,
+            'roleid' => $teacher->id,
+            'cohortid' => $cohort->id
+        );
+        api::create_cohort_role_assignment($params);
+        api::sync_all_cohort_roles();
+
+        $count = $DB->count_records('role_assignments', ['userid' => $user->id, 'component' => 'tool_cohortroles']);
+        $this->assertEquals(3, $count);
+
+        provider::delete_user_role_to_cohort($user->id);
+        $count = $DB->count_records('role_assignments', ['userid' => $user->id, 'component' => 'tool_cohortroles']);
+        $this->assertEquals(0, $count);
+    }
+    /**
+     * Supoort function to get all the localised roles name
+     * in a simple array for testing.
+     *
+     * @return array Array of name of the roles by roleid.
+     */
+    protected static function get_roles_name() {
+        $roles = role_fix_names(get_all_roles(), \context_system::instance(), ROLENAME_ORIGINAL);
+        $rolesnames = array();
+        foreach ($roles as $role) {
+            $rolesnames[$role->id] = $role->localname;
+        }
+        return $rolesnames;
+    }
+}
\ No newline at end of file
index 0d42707..f8dab27 100644 (file)
@@ -16,6 +16,9 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
     $temp->add(new admin_setting_configexecutable('pathtosassc', new lang_string('pathtosassc', 'admin'), new lang_string('pathtosassc_help', 'admin'), ''));
 
+    $temp->add(new admin_setting_configcheckbox('forceclean', new lang_string('forceclean', 'core_admin'),
+        new lang_string('forceclean_desc', 'core_admin'), 0));
+
     $ADMIN->add('experimental', $temp);
 
     // "debugging" settingpage
index 3580934..a6ac61f 100644 (file)
@@ -43,9 +43,6 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configtext('userquota', new lang_string('userquota', 'admin'),
                 new lang_string('configuserquota', 'admin', $params), $defaultuserquota, PARAM_INT, 30));
 
-    $temp->add(new admin_setting_configcheckbox('forceclean', new lang_string('forceclean', 'core_admin'),
-        new lang_string('forceclean_desc', 'core_admin'), 1));
-
     $temp->add(new admin_setting_configcheckbox('allowobjectembed', new lang_string('allowobjectembed', 'admin'), new lang_string('configallowobjectembed', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('enabletrusttext', new lang_string('enabletrusttext', 'admin'), new lang_string('configenabletrusttext', 'admin'), 0));
     $temp->add(new admin_setting_configselect('maxeditingtime', new lang_string('maxeditingtime','admin'), new lang_string('configmaxeditingtime','admin'), 1800,
index bfdb2fc..1a7586a 100644 (file)
Binary files a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js and b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js differ
index 7b569c3..3f3c424 100644 (file)
Binary files a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js and b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js differ
index bfdb2fc..1a7586a 100644 (file)
Binary files a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js and b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js differ
index a2cc382..8c93223 100644 (file)
@@ -356,7 +356,7 @@ M.core_availability.List = function(json, root, parentRoot) {
     // Create DIV structure (without kids).
     this.node = Y.Node.create('<div class="availability-list"><h3 class="accesshide"></h3>' +
             '<div class="availability-inner">' +
-            '<div class="availability-header"><span class="p-l-1">' +
+            '<div class="availability-header m-b-1"><span>' +
             M.util.get_string('listheader_sign_before', 'availability') + '</span>' +
             ' <label><span class="accesshide">' + M.util.get_string('label_sign', 'availability') +
             ' </span><select class="availability-neg custom-select m-x-1"' +
@@ -376,7 +376,7 @@ M.core_availability.List = function(json, root, parentRoot) {
             '<div class="clearfix m-t-1"></div>' +
             '<div class="availability-button"></div></div><div class="clearfix"></div></div>');
     if (!root) {
-        this.node.addClass('availability-childlist');
+        this.node.addClass('availability-childlist d-sm-flex align-items-center');
     }
     this.inner = this.node.one('> .availability-inner');
 
@@ -687,24 +687,24 @@ M.core_availability.List.prototype.clickAdd = function() {
         // Add entry for plugin.
         li = Y.Node.create('<li class="clearfix row"></li>');
         id = 'availability_addrestriction_' + type;
-        button = Y.Node.create('<button type="button" class="btn btn-default col-xs-6"' +
-                'id="' + id + '">' + M.util.get_string('title', 'availability_' + type) + '</button>');
+        button = Y.Node.create('<div class="col-6"><button type="button" class="btn btn-default w-100"' +
+                'id="' + id + '">' + M.util.get_string('title', 'availability_' + type) + '</button></div>');
         button.on('click', this.getAddHandler(type, dialogRef), this);
         li.appendChild(button);
-        label = Y.Node.create('<label for="' + id + '" class="col-xs-6">' +
-                M.util.get_string('description', 'availability_' + type) + '</label>');
+        label = Y.Node.create('<div class="col-6"><label for="' + id + '">' +
+                M.util.get_string('description', 'availability_' + type) + '</label></div>');
         li.appendChild(label);
         ul.appendChild(li);
     }
     // Extra entry for lists.
     li = Y.Node.create('<li class="clearfix row"></li>');
     id = 'availability_addrestriction_list_';
-    button = Y.Node.create('<button type="button" class="btn btn-default col-xs-6"' +
-            'id="' + id + '">' + M.util.get_string('condition_group', 'availability') + '</button>');
+    button = Y.Node.create('<div class="col-6"><button type="button" class="btn btn-default w-100"' +
+            'id="' + id + '">' + M.util.get_string('condition_group', 'availability') + '</button></div>');
     button.on('click', this.getAddHandler(null, dialogRef), this);
     li.appendChild(button);
-    label = Y.Node.create('<label for="' + id + '" class="col-xs-6">' +
-            M.util.get_string('condition_group_info', 'availability') + '</label>');
+    label = Y.Node.create('<div class="col-6"><label for="' + id + '">' +
+            M.util.get_string('condition_group_info', 'availability') + '</label></div>');
     li.appendChild(label);
     ul.appendChild(li);
 
@@ -899,7 +899,7 @@ M.core_availability.Item = function(json, root) {
         this.pluginNode.addClass('availability_' + json.type);
     }
 
-    this.node = Y.Node.create('<div class="availability-item d-inline-block"><h3 class="accesshide"></h3></div>');
+    this.node = Y.Node.create('<div class="availability-item d-sm-flex align-items-center"><h3 class="accesshide"></h3></div>');
 
     // Add eye icon if required. This icon is added for root items, but may be
     // hidden depending on the selected list operator.
@@ -922,7 +922,7 @@ M.core_availability.Item = function(json, root) {
 
     // Add the invalid marker (empty).
     this.node.appendChild(document.createTextNode(' '));
-    this.node.appendChild(Y.Node.create('<span class="m-t-1 label label-warning"/>'));
+    this.node.appendChild(Y.Node.create('<span class="label label-warning"/>'));
 };
 
 /**
diff --git a/course/classes/privacy/provider.php b/course/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..41a8136
--- /dev/null
@@ -0,0 +1,220 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @package    core_course
+ * @copyright  2018 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\contextlist;
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\writer;
+use \core_privacy\local\request\transform;
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @copyright  2018 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\context_aware_provider,
+        \core_privacy\local\request\plugin\provider,
+        \core_privacy\local\request\user_preference_provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_subsystem_link('core_completion', [], 'privacy:metadata:completionsummary');
+        $collection->add_user_preference('coursecat_management_perpage', 'privacy:perpage');
+        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 {
+        list($join, $where, $params) = \core_completion\privacy\provider::get_course_completion_join_sql($userid, 'cc', 'c.id');
+        $sql = "SELECT ctx.id
+                FROM {context} ctx
+                JOIN {course} c ON ctx.instanceid = c.id AND ctx.contextlevel = :contextcourse
+                {$join}
+                WHERE {$where}";
+        $params['contextcourse'] = CONTEXT_COURSE;
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        // Get the course.
+        list($select, $params) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+        $params['contextcourse'] = CONTEXT_COURSE;
+
+        $sql = "SELECT c.*
+                FROM {course} c
+                JOIN {context} ctx ON c.id = ctx.instanceid AND ctx.contextlevel = :contextcourse
+                WHERE ctx.id $select";
+
+        $courses = $DB->get_recordset_sql($sql, $params);
+        foreach ($courses as $course) {
+            $coursecompletion = \core_completion\privacy\provider::get_course_completion_info($contextlist->get_user(), $course);
+            writer::with_context(\context_course::instance($course->id))->export_data(
+                    [get_string('privacy:completionpath', 'course')], (object) $coursecompletion);
+        }
+        $courses->close();
+    }
+
+    /**
+     * Give the component a chance to include any contextual information deemed relevant to any child contexts which are
+     * exporting personal data.
+     *
+     * By giving the component access to the full list of contexts being exported across all components, it can determine whether a
+     * descendant context is being exported, and decide whether to add relevant contextual information about itself. Having access
+     * to the full list of contexts being exported is what makes this component a context aware provider.
+     *
+     * E.g.
+     * If, during the core export process, a course module is included in the contextlist_collection but the course containing the
+     * module is not (perhaps there's no longer a user enrolment), then the course should include general contextual information in
+     * the export so we know basic details about which course the module belongs to. This method allows the course to make that
+     * decision, based on the existence of any decendant module contexts in the collection.
+     *
+     * @param \core_privacy\local\request\contextlist_collection $contextlistcollection
+     */
+    public static function export_context_data(\core_privacy\local\request\contextlist_collection $contextlistcollection) {
+        global $DB;
+
+        $coursecontextids = $DB->get_records_menu('context', ['contextlevel' => CONTEXT_COURSE], '', 'id, instanceid');
+        $courseids = [];
+        foreach ($contextlistcollection as $component) {
+            foreach ($component->get_contexts() as $context) {
+                // All course contexts have been accounted for, so skip all checks.
+                if (empty($coursecontextids)) {
+                    break;
+                }
+                // Only course, module, and block contexts are checked.
+                if (in_array($context->contextlevel, [CONTEXT_USER, CONTEXT_SYSTEM, CONTEXT_COURSECAT])) {
+                    continue;
+                }
+                // If the context is a course, then we just add it without the need to check context path.
+                if ($context->contextlevel == CONTEXT_COURSE) {
+                    $courseids[$context->id] = $context->instanceid;
+                    unset($coursecontextids[$context->id]);
+                    continue;
+                }
+                // Otherwise, we need to check all the course context paths, to see if this context is a descendant.
+                foreach ($coursecontextids as $contextid => $instanceid) {
+                    if (stripos($context->path, '/' . $contextid . '/') !== false) {
+                        $courseids[$contextid] = $instanceid;
+                        unset($coursecontextids[$contextid]);
+                    }
+                }
+            }
+        }
+        if (empty($courseids)) {
+            return;
+        }
+
+        // Export general data for these contexts.
+        list($sql, $params) = $DB->get_in_or_equal($courseids);
+        $sql = 'id ' . $sql;
+        $coursedata = $DB->get_records_select('course', $sql, $params);
+
+        foreach ($coursedata as $course) {
+            $context = \context_course::instance($course->id);
+            $data = (object) [
+                'fullname' => $course->fullname,
+                'shortname' => $course->shortname,
+                'idnumber' => $course->idnumber,
+                'summary' => writer::with_context($context)->rewrite_pluginfile_urls([], 'course', 'summary', 0,
+                                                                                     format_string($course->summary)),
+                'format' => get_string('pluginname', 'format_' . $course->format),
+                'startdate' => transform::datetime($course->startdate),
+                'enddate' => transform::datetime($course->enddate)
+            ];
+            writer::with_context($context)
+                    ->export_area_files([], 'course', 'summary', 0)
+                    ->export_area_files([], 'course', 'overviewfiles', 0)
+                    ->export_data([], $data);
+        }
+    }
+
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $perpage = get_user_preferences('coursecat_management_perpage', null, $userid);
+        if (isset($perpage)) {
+            writer::export_user_preference('core_course',
+                'coursecat_management_perpage',
+                $perpage,
+                get_string('privacy:perpage', 'course')
+            );
+        }
+    }
+
+    /**
+     * 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) {
+        // Check what context we've been delivered.
+        if ($context->contextlevel == CONTEXT_COURSE) {
+            // Delete course completion data.
+            \core_completion\privacy\provider::delete_completion(null, $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) {
+        foreach ($contextlist as $context) {
+            if ($context->contextlevel == CONTEXT_COURSE) {
+                // Delete course completion data.
+                \core_completion\privacy\provider::delete_completion($contextlist->get_user(), $context->instanceid);
+            }
+        }
+    }
+}
index be0a707..fdd0380 100644 (file)
@@ -858,9 +858,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
      * Test get_course_contents
      */
     public function test_get_course_contents() {
-        global $CFG;
         $this->resetAfterTest(true);
-        $CFG->forceclean = 0;
 
         list($course, $forumcm, $datacm, $pagecm, $labelcm, $urlcm) = $this->prepare_get_course_contents_test();
 
diff --git a/course/tests/privacy_test.php b/course/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..b2d9033
--- /dev/null
@@ -0,0 +1,178 @@
+<?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 core_course.
+ *
+ * @package    core_course
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/completion/tests/fixtures/completion_creation.php');
+
+/**
+ * Unit tests for course/classes/privacy/policy
+ *
+ * @copyright  2018 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    use completion_creation;
+
+    /**
+     * Test getting the appropriate context for the userid. This should only ever
+     * return the user context for the user id supplied.
+     */
+    public function test_get_contexts_for_userid() {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $this->create_course_completion();
+        $this->complete_course($user);
+        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user->id);
+        $this->assertEquals($this->coursecontext->id, $contextlist->current()->id);
+    }
+
+    /**
+     * Test that user data is exported.
+     */
+    public function test_export_user_data() {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $this->create_course_completion();
+        $this->complete_course($user);
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user, 'core_course',
+                [$this->coursecontext->id]);
+        $writer = \core_privacy\local\request\writer::with_context($this->coursecontext);
+        \core_course\privacy\provider::export_user_data($approvedlist);
+        $completiondata = $writer->get_data([get_string('privacy:completionpath', 'course')]);
+        $this->assertEquals('In progress', $completiondata->status);
+        $this->assertCount(2, $completiondata->criteria);
+    }
+
+    /**
+     * Verify that if a module context is included in the contextlist_collection and its parent course is not, the
+     * export_context_data() call picks this up, and that the contextual course information is included.
+     */
+    public function test_export_context_data_module_context_only() {
+        $this->resetAfterTest();
+
+        // Create a course and a single module.
+        $course1 = $this->getDataGenerator()->create_course(['fullname' => 'Course 1', 'shortname' => 'C1']);
+        $context1 = context_course::instance($course1->id);
+        $modassign = $this->getDataGenerator()->create_module('assign', ['course' => $course1->id, 'name' => 'assign test 1']);
+        $assigncontext = context_module::instance($modassign->cmid);
+
+        // Now, let's assume during user info export, only the coursemodule context is returned in the contextlist_collection.
+        $user = $this->getDataGenerator()->create_user();
+        $collection = new \core_privacy\local\request\contextlist_collection($user->id);
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user, 'mod_assign', [$assigncontext->id]);
+        $collection->add_contextlist($approvedlist);
+
+        // Now, verify that core_course will detect this, and add relevant contextual information.
+        \core_course\privacy\provider::export_context_data($collection);
+        $writer = \core_privacy\local\request\writer::with_context($context1);
+        $this->assertTrue($writer->has_any_data());
+        $writerdata = $writer->get_data();
+        $this->assertObjectHasAttribute('fullname', $writerdata);
+        $this->assertObjectHasAttribute('shortname', $writerdata);
+        $this->assertObjectHasAttribute('idnumber', $writerdata);
+        $this->assertObjectHasAttribute('summary', $writerdata);
+    }
+
+    /**
+     * Verify that if a module context and its parent course context are both included in the contextlist_collection, that course
+     * contextual information is present in the export.
+     */
+    public function test_export_context_data_course_and_module_contexts() {
+        $this->resetAfterTest();
+
+        // Create a course and a single module.
+        $course1 = $this->getDataGenerator()->create_course(['fullname' => 'Course 1', 'shortname' => 'C1']);
+        $context1 = context_course::instance($course1->id);
+        $modassign = $this->getDataGenerator()->create_module('assign', ['course' => $course1->id, 'name' => 'assign test 1']);
+        $assigncontext = context_module::instance($modassign->cmid);
+
+        // Now, assume during user info export, that both module and course contexts are returned in the contextlist_collection.
+        $user = $this->getDataGenerator()->create_user();
+        $collection = new \core_privacy\local\request\contextlist_collection($user->id);
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user, 'mod_assign', [$assigncontext->id]);
+        $approvedlist2 = new \core_privacy\local\request\approved_contextlist($user, 'core_course', [$context1->id]);
+        $collection->add_contextlist($approvedlist);
+        $collection->add_contextlist($approvedlist2);
+
+        // Now, verify that core_course still adds relevant contextual information, even for courses which are explicitly listed in
+        // the contextlist_collection.
+        \core_course\privacy\provider::export_context_data($collection);
+        $writer = \core_privacy\local\request\writer::with_context($context1);
+        $this->assertTrue($writer->has_any_data());
+        $writerdata = $writer->get_data();
+        $this->assertObjectHasAttribute('fullname', $writerdata);
+        $this->assertObjectHasAttribute('shortname', $writerdata);
+        $this->assertObjectHasAttribute('idnumber', $writerdata);
+        $this->assertObjectHasAttribute('summary', $writerdata);
+    }
+
+    /**
+     * Test deleting all user data for one context.
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $this->create_course_completion();
+        $this->complete_course($user1);
+        $this->complete_course($user2);
+        $records = $DB->get_records('course_modules_completion');
+        $this->assertCount(2, $records);
+        $records = $DB->get_records('course_completion_crit_compl');
+        $this->assertCount(2, $records);
+        \core_course\privacy\provider::delete_data_for_all_users_in_context($this->coursecontext);
+        $records = $DB->get_records('course_modules_completion');
+        $this->assertCount(0, $records);
+        $records = $DB->get_records('course_completion_crit_compl');
+        $this->assertCount(0, $records);
+    }
+
+    /**
+     * Test deleting data for only one user.
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $this->create_course_completion();
+        $this->complete_course($user1);
+        $this->complete_course($user2);
+        $records = $DB->get_records('course_modules_completion');
+        $this->assertCount(2, $records);
+        $records = $DB->get_records('course_completion_crit_compl');
+        $this->assertCount(2, $records);
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_course',
+                [$this->coursecontext->id]);
+        \core_course\privacy\provider::delete_data_for_user($approvedlist);
+        $records = $DB->get_records('course_modules_completion');
+        $this->assertCount(1, $records);
+        $records = $DB->get_records('course_completion_crit_compl');
+        $this->assertCount(1, $records);
+    }
+}
diff --git a/enrol/classes/privacy/provider.php b/enrol/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..46a0c63
--- /dev/null
@@ -0,0 +1,233 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Privacy Subsystem implementation for core_enrol.
+ *
+ * @package    core_enrol
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_enrol\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\context;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+/**
+ * Privacy Subsystem for core_enrol implementing metadata and plugin providers.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\subsystem\provider {
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table(
+            'user_enrolments',
+            [
+                'status' => 'privacy:metadata:user_enrolments:status',
+                'enrolid' => 'privacy:metadata:user_enrolments:enrolid',
+                'userid' => 'privacy:metadata:user_enrolments:userid',
+                'timestart' => 'privacy:metadata:user_enrolments:timestart',
+                'timeend' => 'privacy:metadata:user_enrolments:timeend',
+                'modifierid' => 'privacy:metadata:user_enrolments:modifierid',
+                'timecreated' => 'privacy:metadata:user_enrolments:timecreated',
+                'timemodified' => 'privacy:metadata:user_enrolments:timemodified'
+            ],
+            'privacy:metadata:user_enrolments:tableexplanation'
+        );
+
+        return $collection;
+    }
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   int $userid The user to search.
+     * @return  contextlist   $contextlist  The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $sql = "SELECT ctx.id
+                  FROM {user_enrolments} ue
+                  JOIN {enrol} e
+                    ON e.id = ue.enrolid
+                   AND ue.userid = :userid
+                  JOIN {context} ctx
+                    ON ctx.instanceid = e.courseid
+                   AND ctx.contextlevel = :contextlevel";
+        $params = [
+            'contextlevel' => CONTEXT_COURSE,
+            'userid'       => $userid
+        ];
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+        $userid = $contextlist->get_user()->id;
+        list($insql, $inparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+        $params = [
+            'contextlevel' => CONTEXT_COURSE,
+            'userid' => $userid
+         ];
+        $params += $inparams;
+        $sql = "SELECT ue.id,
+                       ue.status,
+                       ue.timestart,
+                       ue.timeend,
+                       ue.timecreated,
+                       ue.timemodified,
+                       e.enrol,
+                       ctx.id as contextid
+                  FROM {user_enrolments} ue
+                  JOIN {enrol} e
+                    ON e.id = ue.enrolid
+                   AND ue.userid = :userid
+                  JOIN {context} ctx
+                    ON ctx.instanceid = e.courseid
+                   AND ctx.contextlevel = :contextlevel
+                 WHERE ctx.id $insql
+                 ORDER BY ctx.id, e.enrol";
+        $data = [];
+        $lastcontextid = null;
+        $lastenrol = null;
+        $path = [get_string('privacy:metadata:user_enrolments', 'core_enrol')];
+        $flush = function($lastcontextid, $lastenrol, $data) use ($path) {
+            $context = \context::instance_by_id($lastcontextid);
+            writer::with_context($context)->export_related_data(
+                $path,
+                $lastenrol,
+                (object)$data
+            );
+        };
+        $userenrolments = $DB->get_recordset_sql($sql, $params);
+        foreach ($userenrolments as $userenrolment) {
+            if (($lastcontextid && $lastcontextid != $userenrolment->contextid) ||
+                    ($lastenrol && $lastenrol != $userenrolment->enrol)) {
+                $flush($lastcontextid, $lastenrol, $data);
+                $data = [];
+            }
+            $data[] = (object) [
+                'status' => $userenrolment->status,
+                'timecreated' => transform::datetime($userenrolment->timecreated),
+                'timemodified' => transform::datetime($userenrolment->timemodified),
+                'timestart' => transform::datetime($userenrolment->timestart),
+                'timeend' => transform::datetime($userenrolment->timeend)
+            ];
+            $lastcontextid = $userenrolment->contextid;
+            $lastenrol = $userenrolment->enrol;
+        }
+        if (!empty($data)) {
+            $flush($lastcontextid, $lastenrol, $data);
+        }
+        $userenrolments->close();
+    }
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param   context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        if (empty($context)) {
+            return;
+        }
+        // Sanity check that context is at the User context level.
+        if ($context->contextlevel == CONTEXT_COURSE) {
+            $sql = "SELECT ue.id
+                      FROM {user_enrolments} ue
+                      JOIN {enrol} e
+                        ON e.id = ue.enrolid
+                      JOIN {context} ctx
+                        ON ctx.instanceid = e.courseid
+                     WHERE ctx.id = :contextid";
+            $params = ['contextid' => $context->id];
+            $enrolsids = $DB->get_fieldset_sql($sql, $params);
+            if (!empty($enrolsids)) {
+                list($insql, $inparams) = $DB->get_in_or_equal($enrolsids, SQL_PARAMS_NAMED);
+                static::delete_user_data($insql, $inparams);
+            }
+        }
+    }
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+        $userid = $contextlist->get_user()->id;
+        list($insql, $inparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+        $params = [
+            'contextlevel' => CONTEXT_COURSE,
+            'userid' => $userid
+         ];
+        $params += $inparams;
+        $sql = "SELECT ue.id
+                  FROM {user_enrolments} ue
+                  JOIN {enrol} e
+                    ON e.id = ue.enrolid
+                   AND ue.userid = :userid
+                  JOIN {context} ctx
+                    ON ctx.instanceid = e.courseid
+                   AND ctx.contextlevel = :contextlevel
+                 WHERE ctx.id $insql";
+        $enrolsids = $DB->get_fieldset_sql($sql, $params);
+        if (!empty($enrolsids)) {
+            list($insql, $inparams) = $DB->get_in_or_equal($enrolsids, SQL_PARAMS_NAMED);
+            static::delete_user_data($insql, $inparams);
+        }
+    }
+
+    /**
+     * Delete data from $tablename with the IDs returned by $sql query.
+     *
+     * @param  string $sql    SQL query for getting the IDs of the uer enrolments entries to delete.
+     * @param  array  $params SQL params for the query.
+     */
+    protected static function delete_user_data(string $sql, array $params) {
+        global $DB;
+
+        $DB->delete_records_select('user_enrolments', "id $sql", $params);
+    }
+
+}
diff --git a/enrol/paypal/classes/privacy/provider.php b/enrol/paypal/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..08ff5bb
--- /dev/null
@@ -0,0 +1,284 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for enrol_paypal.
+ *
+ * @package    enrol_paypal
+ * @category   privacy
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace enrol_paypal\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\helper;
+use core_privacy\local\request\writer;
+
+/**
+ * Privacy Subsystem implementation for enrol_paypal.
+ *
+ * @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\provider,
+        \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param collection $collection The initialised collection to add items to.
+     * @return collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_external_location_link(
+            'paypal.com',
+            [
+                'os0'        => 'privacy:metadata:enrol_paypal:paypal_com:os0',
+                'custom'     => 'privacy:metadata:enrol_paypal:paypal_com:custom',
+                'first_name' => 'privacy:metadata:enrol_paypal:paypal_com:first_name',
+                'last_name'  => 'privacy:metadata:enrol_paypal:paypal_com:last_name',
+                'address'    => 'privacy:metadata:enrol_paypal:paypal_com:address',
+                'city'       => 'privacy:metadata:enrol_paypal:paypal_com:city',
+                'email'      => 'privacy:metadata:enrol_paypal:paypal_com:email',
+                'country'    => 'privacy:metadata:enrol_paypal:paypal_com:country',
+            ],
+            'privacy:metadata:enrol_paypal:paypal_com'
+        );
+
+        // The enrol_paypal has a DB table that contains user data.
+        $collection->add_database_table(
+                'enrol_paypal',
+                [
+                    'business'            => 'privacy:metadata:enrol_paypal:enrol_paypal:business',
+                    'receiver_email'      => 'privacy:metadata:enrol_paypal:enrol_paypal:receiver_email',
+                    'receiver_id'         => 'privacy:metadata:enrol_paypal:enrol_paypal:receiver_id',
+                    'item_name'           => 'privacy:metadata:enrol_paypal:enrol_paypal:item_name',
+                    'courseid'            => 'privacy:metadata:enrol_paypal:enrol_paypal:courseid',
+                    'userid'              => 'privacy:metadata:enrol_paypal:enrol_paypal:userid',
+                    'instanceid'          => 'privacy:metadata:enrol_paypal:enrol_paypal:instanceid',
+                    'memo'                => 'privacy:metadata:enrol_paypal:enrol_paypal:memo',
+                    'tax'                 => 'privacy:metadata:enrol_paypal:enrol_paypal:tax',
+                    'option_selection1_x' => 'privacy:metadata:enrol_paypal:enrol_paypal:option_selection1_x',
+                    'payment_status'      => 'privacy:metadata:enrol_paypal:enrol_paypal:payment_status',
+                    'pending_reason'      => 'privacy:metadata:enrol_paypal:enrol_paypal:pending_reason',
+                    'reason_code'         => 'privacy:metadata:enrol_paypal:enrol_paypal:reason_code',
+                    'txn_id'              => 'privacy:metadata:enrol_paypal:enrol_paypal:txn_id',
+                    'parent_txn_id'       => 'privacy:metadata:enrol_paypal:enrol_paypal:parent_txn_id',
+                    'payment_type'        => 'privacy:metadata:enrol_paypal:enrol_paypal:payment_type',
+                    'timeupdated'         => 'privacy:metadata:enrol_paypal:enrol_paypal:timeupdated'
+                ],
+                'privacy:metadata:enrol_paypal:enrol_paypal'
+        );
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $contextlist = new contextlist();
+
+        // Values of ep.receiver_email and ep.business are already normalised to lowercase characters by PayPal,
+        // therefore there is no need to use LOWER() on them in the following query.
+        $sql = "SELECT ctx.id
+                  FROM {enrol_paypal} ep
+                  JOIN {enrol} e ON ep.instanceid = e.id
+                  JOIN {context} ctx ON e.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
+             LEFT JOIN {user} u1 ON LOWER(u1.email) = ep.receiver_email
+             LEFT JOIN {user} u2 ON LOWER(u2.email) = ep.business
+                 WHERE ep.userid = :userid
+                       OR u1.id = :receiverid
+                       OR u2.id = :businessid";
+        $params = [
+            'contextcourse' => CONTEXT_COURSE,
+            'userid'        => $userid,
+            'receiverid'    => $userid,
+            'businessid'    => $userid,
+        ];
+
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+
+        $user = $contextlist->get_user();
+
+        list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        // Values of ep.receiver_email and ep.business are already normalised to lowercase characters by PayPal,
+        // therefore there is no need to use LOWER() on them in the following query.
+        $sql = "SELECT ep.*
+                  FROM {enrol_paypal} ep
+                  JOIN {enrol} e ON ep.instanceid = e.id
+                  JOIN {context} ctx ON e.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
+             LEFT JOIN {user} u1 ON LOWER(u1.email) = ep.receiver_email
+             LEFT JOIN {user} u2 ON LOWER(u2.email) = ep.business
+                 WHERE ctx.id {$contextsql}
+                       AND (ep.userid = :userid
+                            OR u1.id = :receiverid
+                            OR u2.id = :businessid)
+              ORDER BY e.courseid";
+
+        $params = [
+            'contextcourse' => CONTEXT_COURSE,
+            'userid'        => $user->id,
+            'receiverid'    => $user->id,
+            'businessid'    => $user->id,
+        ];
+        $params += $contextparams;
+
+        // Reference to the course seen in the last iteration of the loop. By comparing this with the current record, and
+        // because we know the results are ordered, we know when we've moved to the PayPal transactions for a new course
+        // and therefore when we can export the complete data for the last course.
+        $lastcourseid = null;
+
+        $strtransactions = get_string('transactions', 'enrol_paypal');
+        $transactions = [];
+        $paypalrecords = $DB->get_recordset_sql($sql, $params);
+        foreach ($paypalrecords as $paypalrecord) {
+            if ($lastcourseid != $paypalrecord->courseid) {
+                if (!empty($transactions)) {
+                    $coursecontext = \context_course::instance($paypalrecord->courseid);
+                    writer::with_context($coursecontext)->export_data(
+                            [$strtransactions],
+                            (object) ['transactions' => $transactions]
+                    );
+                }
+                $transactions = [];
+            }
+
+            $transaction = (object) [
+                'receiver_id'         => $paypalrecord->receiver_id,
+                'item_name'           => $paypalrecord->item_name,
+                'userid'              => $paypalrecord->userid,
+                'memo'                => $paypalrecord->memo,
+                'tax'                 => $paypalrecord->tax,
+                'option_name1'        => $paypalrecord->option_name1,
+                'option_selection1_x' => $paypalrecord->option_selection1_x,
+                'option_name2'        => $paypalrecord->option_name2,
+                'option_selection2_x' => $paypalrecord->option_selection2_x,
+                'payment_status'      => $paypalrecord->payment_status,
+                'pending_reason'      => $paypalrecord->pending_reason,
+                'reason_code'         => $paypalrecord->reason_code,
+                'txn_id'              => $paypalrecord->txn_id,
+                'parent_txn_id'       => $paypalrecord->parent_txn_id,
+                'payment_type'        => $paypalrecord->payment_type,
+                'timeupdated'         => \core_privacy\local\request\transform::datetime($paypalrecord->timeupdated),
+            ];
+            if ($paypalrecord->userid == $user->id) {
+                $transaction->userid = $paypalrecord->userid;
+            }
+            if ($paypalrecord->business == \core_text::strtolower($user->email)) {
+                $transaction->business = $paypalrecord->business;
+            }
+            if ($paypalrecord->receiver_email == \core_text::strtolower($user->email)) {
+                $transaction->receiver_email = $paypalrecord->receiver_email;
+            }
+
+            $transactions[] = $paypalrecord;
+
+            $lastcourseid = $paypalrecord->courseid;
+        }
+        $paypalrecords->close();
+
+        // The data for the last activity won't have been written yet, so make sure to write it now!
+        if (!empty($transactions)) {
+            $coursecontext = \context_course::instance($paypalrecord->courseid);
+            writer::with_context($coursecontext)->export_data(
+                    [$strtransactions],
+                    (object) ['transactions' => $transactions]
+            );
+        }
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param \context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
+        $DB->delete_records('enrol_paypal', array('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) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+
+        $user = $contextlist->get_user();
+
+        $contexts = $contextlist->get_contexts();
+        $courseids = [];
+        foreach ($contexts as $context) {
+            if ($context instanceof \context_course) {
+                $courseids[] = $context->instanceid;
+            }
+        }
+
+        list($insql, $inparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
+
+        $select = "userid = :userid AND courseid $insql";
+        $params = $inparams + ['userid' => $user->id];
+        $DB->delete_records_select('enrol_paypal', $select, $params);
+
+        // We do not want to delete the payment record when the user is just the receiver of payment.
+        // In that case, we just delete the receiver's info from the transaction record.
+
+        $select = "business = :business AND courseid $insql";
+        $params = $inparams + ['business' => \core_text::strtolower($user->email)];
+        $DB->set_field_select('enrol_paypal', 'business', '', $select, $params);
+
+        $select = "receiver_email = :receiver_email AND courseid $insql";
+        $params = $inparams + ['receiver_email' => \core_text::strtolower($user->email)];
+        $DB->set_field_select('enrol_paypal', 'receiver_email', '', $select, $params);
+    }
+}
index 6aa0de2..6123a3c 100644 (file)
@@ -56,8 +56,36 @@ $string['paypal:unenrolself'] = 'Unenrol self from the course';
 $string['paypalaccepted'] = 'PayPal payments accepted';
 $string['pluginname'] = 'PayPal';
 $string['pluginname_desc'] = 'The PayPal module allows you to set up paid courses.  If the cost for any course is zero, then students are not asked to pay for entry.  There is a site-wide cost that you set here as a default for the whole site and then a course setting that you can set for each course individually. The course cost overrides the site cost.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal'] = 'Information about the PayPal transactions for PayPal enrolments.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:business'] = 'Email address or PayPal account ID of the payment recipient (that is, the merchant).';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:courseid'] = 'The ID of the course that is sold.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:instanceid'] = 'The ID of the enrolment instance in the course.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:item_name'] = 'The full name of the course that its enrolment has been sold.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:memo'] = 'A note that was entered by the buyer in PayPal website payments note field.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:option_selection1_x'] = 'Full name of the buyer.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:parent_txn_id'] = 'In the case of a refund, reversal, or canceled reversal, this would be the transaction ID of the original transaction.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:payment_status'] = 'The status of the payment.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:payment_type'] = 'Holds whether the payment was funded with an eCheck (echeck), or was funded with PayPal balance, credit card, or instant transfer (instant).';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:pending_reason'] = 'The reason why payment status is pending (if that is).';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:reason_code'] = 'The reason why payment status is Reversed, Refunded, Canceled_Reversal, or Denied (if the status is one of them).';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:receiver_email'] = 'Primary email address of the payment recipient (that is, the merchant).';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:receiver_id'] = 'Unique PayPal account ID of the payment recipient (i.e., the merchant).';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:tax'] = 'Amount of tax charged on payment.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:timeupdated'] = 'The time of Moodle being notified by PayPal about the payment.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:txn_id'] = 'The merchant\'s original transaction identification number for the payment from the buyer, against which the case was registered.';
+$string['privacy:metadata:enrol_paypal:enrol_paypal:userid'] = 'The ID of the user who bought the course enrolment.';
+$string['privacy:metadata:enrol_paypal:paypal_com'] = 'The PayPal enrolment plugin transmits user data from Moodle to the PayPal website.';
+$string['privacy:metadata:enrol_paypal:paypal_com:address'] = 'Address of the user who is buying the course.';
+$string['privacy:metadata:enrol_paypal:paypal_com:city'] = 'City of the user who is buying the course.';
+$string['privacy:metadata:enrol_paypal:paypal_com:country'] = 'Country of the user who is buying the course.';
+$string['privacy:metadata:enrol_paypal:paypal_com:custom'] = 'A hyphen-separated string that contains ID of the user (the buyer), ID of the course, ID of the enrolment instance.';
+$string['privacy:metadata:enrol_paypal:paypal_com:email'] = 'Email address of the user who is buying the course.';
+$string['privacy:metadata:enrol_paypal:paypal_com:first_name'] = 'First name of the user who is buying the course.';
+$string['privacy:metadata:enrol_paypal:paypal_com:last_name'] = 'Last name of the user who is buying the course.';
+$string['privacy:metadata:enrol_paypal:paypal_com:os0'] = 'Full name of the buyer.';
 $string['processexpirationstask'] = 'PayPal enrolment send expiry notifications task';
 $string['sendpaymentbutton'] = 'Send payment via PayPal';
 $string['status'] = 'Allow PayPal enrolments';
 $string['status_desc'] = 'Allow users to use PayPal to enrol into a course by default.';
+$string['transactions'] = 'PayPal transactions';
 $string['unenrolselfconfirm'] = 'Do you really want to unenrol yourself from course "{$a}"?';
diff --git a/enrol/paypal/tests/privacy_provider_test.php b/enrol/paypal/tests/privacy_provider_test.php
new file mode 100644 (file)
index 0000000..3526683
--- /dev/null
@@ -0,0 +1,583 @@
+<?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 provider tests.
+ *
+ * @package    enrol_paypal
+ * @category   test
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.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 enrol_paypal\privacy\provider;
+use core_privacy\local\request\writer;
+
+/**
+ * Class enrol_paypal_privacy_provider_testcase.
+ *
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class enrol_paypal_privacy_provider_testcase extends \core_privacy\tests\provider_testcase {
+
+    /** @var stdClass A user whose email address matches the business field in some of the PayPal transactions. */
+    protected $businessuser1;
+
+    /** @var stdClass A user whose email address matches the business field in some of the PayPal transactions. */
+    protected $businessuser2;
+
+    /** @var stdClass A user whose email address matches the receiver_email field in some of the PayPal transactions. */
+    protected $receiveruser1;
+
+    /** @var stdClass A user whose email address matches the receiver_email field in some of the PayPal transactions. */
+    protected $receiveruser2;
+
+    /** @var stdClass A user who is not enrolled in any course. */
+    protected $student0;
+
+    /** @var stdClass A student who is only enrolled in course1. */
+    protected $student1;
+
+    /** @var stdClass A student who is only enrolled in course2 with 2 transaction histories in the course. */
+    protected $student2;
+
+    /** @var stdClass A student who is enrolled in both course1 and course2. */
+    protected $student12;
+
+    /** @var stdClass A test course with 2 enrolments for student1 and student12. */
+    protected $course1;
+
+    /** @var stdClass A test course with 2 enrolments for student2 and student12. */
+    protected $course2;
+
+    protected function setUp() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        $generator = $this->getDataGenerator();
+
+        // Create seller accounts.
+        $this->businessuser1 = $generator->create_user(['email' => 'busines1@domain.invalid']);
+        $this->businessuser2 = $generator->create_user(['email' => 'busines2@domain.invalid']);
+        $this->receiveruser1 = $generator->create_user(['email' => 'receiver1@domain.invalid']);
+        $this->receiveruser2 = $generator->create_user(['email' => 'receiver2@domain.invalid']);
+
+        // Create courses.
+        $this->course1 = $generator->create_course();
+        $this->course2 = $generator->create_course();
+
+        // Create enrolment instances.
+        $paypalplugin = enrol_get_plugin('paypal');
+
+        $enrolinstanceid = $paypalplugin->add_instance($this->course1,
+                ['roleid'   => $studentrole->id, 'courseid' => $this->course1->id]);
+        $enrolinstance1  = $DB->get_record('enrol', array('id' => $enrolinstanceid));
+
+        $enrolinstanceid = $paypalplugin->add_instance($this->course2,
+                ['roleid'   => $studentrole->id, 'courseid' => $this->course2->id]);
+        $enrolinstance2 = $DB->get_record('enrol', array('id' => $enrolinstanceid));
+
+        // Create students.
+        $this->student0 = $generator->create_user();    // This user will not be enrolled in any course.
+        $this->student1 = $generator->create_user();
+        $this->student2 = $generator->create_user();
+        $this->student12 = $generator->create_user();
+
+        // Enrol student1 in course1.
+        $paypalplugin->enrol_user($enrolinstance1, $this->student1->id, $studentrole->id);
+        $paypaldata = [
+            'business'       => $this->businessuser1->email,
+            'receiver_email' => $this->receiveruser1->email,
+            'receiver_id'    => 'SELLERSID',
+            'item_name'      => $this->course1->fullname,
+            'courseid'       => $this->course1->id,
+            'userid'         => $this->student1->id,
+            'instanceid'     => $enrolinstance1->id,
+            'payment_status' => 'Completed',
+            'txn_id'         => 'STUDENT1-IN-COURSE1-00',
+            'payment_type'   => 'instant',
+            'timeupdated'    => time(),
+        ];
+        $DB->insert_record('enrol_paypal', $paypaldata);
+
+        // Enrol student2 in course2.
+        $paypalplugin->enrol_user($enrolinstance2, $this->student2->id, $studentrole->id);
+        // This user has 2 transaction histories.
+        // Here is the first one.
+        $paypaldata = [
+            'business'       => $this->businessuser1->email,
+            'receiver_email' => $this->receiveruser2->email,
+            'receiver_id'    => 'SELLERSID',
+            'item_name'      => $this->course2->fullname,
+            'courseid'       => $this->course2->id,
+            'userid'         => $this->student2->id,
+            'instanceid'     => $enrolinstance2->id,
+            'payment_status' => 'Completed',
+            'txn_id'         => 'STUDENT2-IN-COURSE2-00',
+            'payment_type'   => 'instant',
+            'timeupdated'    => time() - 86400, // Yesterday.
+        ];
+        $DB->insert_record('enrol_paypal', $paypaldata);
+        // And now, the second one.
+        $paypaldata = [
+            'business'       => $this->businessuser1->email,
+            'receiver_email' => $this->receiveruser2->email,
+            'receiver_id'    => 'SELLERSID',
+            'item_name'      => $this->course2->fullname,
+            'courseid'       => $this->course2->id,
+            'userid'         => $this->student2->id,
+            'instanceid'     => $enrolinstance2->id,
+            'payment_status' => 'Completed',
+            'txn_id'         => 'STUDENT2-IN-COURSE2-01',
+            'payment_type'   => 'instant',
+            'timeupdated'    => time(),
+        ];
+        $DB->insert_record('enrol_paypal', $paypaldata);
+
+        // Enrol student12 in course1 and course2.
+        // First in course1.
+        $paypalplugin->enrol_user($enrolinstance1, $this->student12->id, $studentrole->id);
+        $paypaldata = [
+            'business'       => $this->businessuser2->email,
+            'receiver_email' => $this->receiveruser1->email,
+            'receiver_id'    => 'SELLERSID',
+            'item_name'      => $this->course1->fullname,
+            'courseid'       => $this->course1->id,
+            'userid'         => $this->student12->id,
+            'instanceid'     => $enrolinstance1->id,
+            'payment_status' => 'Completed',
+            'txn_id'         => 'STUDENT12-IN-COURSE1-00',
+            'payment_type'   => 'instant',
+            'timeupdated'    => time(),
+        ];
+        $DB->insert_record('enrol_paypal', $paypaldata);
+        // Then in course2.
+        $paypalplugin->enrol_user($enrolinstance2, $this->student12->id, $studentrole->id);
+        $paypaldata = [
+            'business'       => $this->businessuser2->email,
+            'receiver_email' => $this->receiveruser2->email,
+            'receiver_id'    => 'SELLERSID',
+            'item_name'      => $this->course2->fullname,
+            'courseid'       => $this->course2->id,
+            'userid'         => $this->student12->id,
+            'instanceid'     => $enrolinstance2->id,
+            'payment_status' => 'Completed',
+            'txn_id'         => 'STUDENT12-IN-COURSE2-00',
+            'payment_type'   => 'instant',
+            'timeupdated'    => time(),
+        ];
+        $DB->insert_record('enrol_paypal', $paypaldata);
+    }
+
+    /**
+     * Test for provider::get_metadata().
+     */
+    public function test_get_metadata() {
+        $collection = new collection('enrol_paypal');
+        $newcollection = provider::get_metadata($collection);
+        $itemcollection = $newcollection->get_collection();
+        $this->assertCount(2, $itemcollection);
+
+        $location = reset($itemcollection);
+        $this->assertEquals('paypal.com', $location->get_name());
+        $this->assertEquals('privacy:metadata:enrol_paypal:paypal_com', $location->get_summary());
+
+        $privacyfields = $location->get_privacy_fields();
+        $this->assertArrayHasKey('os0', $privacyfields);
+        $this->assertArrayHasKey('custom', $privacyfields);
+        $this->assertArrayHasKey('first_name', $privacyfields);
+        $this->assertArrayHasKey('last_name', $privacyfields);
+        $this->assertArrayHasKey('address', $privacyfields);
+        $this->assertArrayHasKey('city', $privacyfields);
+        $this->assertArrayHasKey('email', $privacyfields);
+        $this->assertArrayHasKey('country', $privacyfields);
+
+        $table = next($itemcollection);
+        $this->assertEquals('enrol_paypal', $table->get_name());
+        $this->assertEquals('privacy:metadata:enrol_paypal:enrol_paypal', $table->get_summary());
+
+        $privacyfields = $table->get_privacy_fields();
+        $this->assertArrayHasKey('business', $privacyfields);
+        $this->assertArrayHasKey('receiver_email', $privacyfields);
+        $this->assertArrayHasKey('receiver_id', $privacyfields);
+        $this->assertArrayHasKey('item_name', $privacyfields);
+        $this->assertArrayHasKey('courseid', $privacyfields);
+        $this->assertArrayHasKey('userid', $privacyfields);
+        $this->assertArrayHasKey('instanceid', $privacyfields);
+        $this->assertArrayHasKey('memo', $privacyfields);
+        $this->assertArrayHasKey('tax', $privacyfields);
+        $this->assertArrayHasKey('option_selection1_x', $privacyfields);
+        $this->assertArrayHasKey('payment_status', $privacyfields);
+        $this->assertArrayHasKey('pending_reason', $privacyfields);
+        $this->assertArrayHasKey('reason_code', $privacyfields);
+        $this->assertArrayHasKey('txn_id', $privacyfields);
+        $this->assertArrayHasKey('parent_txn_id', $privacyfields);
+        $this->assertArrayHasKey('payment_type', $privacyfields);
+        $this->assertArrayHasKey('timeupdated', $privacyfields);
+    }
+
+    /**
+     * Test for provider::get_contexts_for_userid().
+     */
+    public function test_get_contexts_for_userid() {
+        $coursecontext1 = context_course::instance($this->course1->id);
+        $coursecontext2 = context_course::instance($this->course2->id);
+
+        // Student1 is only enrolled in 1 course.
+        $contextlist = provider::get_contexts_for_userid($this->student1->id);
+        $this->assertCount(1, $contextlist);
+
+        $contextids = $contextlist->get_contextids();
+        $this->assertEquals([$coursecontext1->id], $contextids);
+
+        // Student12 is enrolled in 2 course.
+        $contextlist = provider::get_contexts_for_userid($this->student12->id);
+        $this->assertCount(2, $contextlist);
+
+        $contextids = $contextlist->get_contextids();
+        $this->assertContains($coursecontext1->id, $contextids);
+        $this->assertContains($coursecontext2->id, $contextids);
+    }
+
+    /**
+     * Test for provider::export_user_data().
+     */
+    public function test_export_user_data() {
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        $this->setUser($this->student1);
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($this->student1->id, $coursecontext1, 'enrol_paypal');
+        $writer = writer::with_context($coursecontext1);
+        $this->assertTrue($writer->has_any_data());
+
+        $data = $writer->get_data([get_string('transactions', 'enrol_paypal')]);
+
+    }
+
+    /**
+     * Test for provider::export_user_data() when user is not enrolled.
+     */
+    public function test_export_user_data_not_enrolled() {
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        $this->setUser($this->student2);
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($this->student2->id, $coursecontext1, 'enrol_paypal');
+        $writer = writer::with_context($coursecontext1);
+        $this->assertFalse($writer->has_any_data());
+    }
+
+    /**
+     * Test for provider::export_user_data() when user has no enrolment.
+     */
+    public function test_export_user_data_no_enrolment() {
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        $this->setUser($this->student0);
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($this->student0->id, $coursecontext1, 'enrol_paypal');
+        $writer = writer::with_context($coursecontext1);
+        $this->assertFalse($writer->has_any_data());
+    }
+
+    public function test_export_user_data_multiple_paypal_history() {
+        $coursecontext2 = context_course::instance($this->course2->id);
+
+        $this->setUser($this->student2);
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($this->student2->id, $coursecontext2, 'enrol_paypal');
+        $writer = writer::with_context($coursecontext2);
+        $this->assertTrue($writer->has_any_data());
+
+        $data = $writer->get_data([get_string('transactions', 'enrol_paypal')]);
+        $this->assertCount(2, $data->transactions);
+        $this->assertEquals(
+                ['STUDENT2-IN-COURSE2-00', 'STUDENT2-IN-COURSE2-01'],
+                array_column($data->transactions, 'txn_id'),
+                '', 0.0, 10, true
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+        $this->setUser($this->student1);
+
+        // Before deletion, we should have 2 PayPal transactions in course1 and 3 PayPal transactions in course2.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+
+        // Delete data based on context.
+        provider::delete_data_for_all_users_in_context($coursecontext1);
+
+        // After deletion, PayPal transactions in course1 should have been deleted.
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context() when there is multiple transaction histories for a user.
+     */
+    public function test_delete_data_for_all_users_in_context_multiple_transactions() {
+        global $DB;
+
+        $coursecontext2 = context_course::instance($this->course2->id);
+        $this->setUser($this->student2);
+
+        // Before deletion, we should have 2 PayPal transactions in course1 and 3 PayPal transactions in course2.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+
+        // Delete data based on context.
+        provider::delete_data_for_all_users_in_context($coursecontext2);
+
+        // After deletion, PayPal transactions in course2 should have been deleted.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_user() when student is enrolled in multiple courses and deleting from one of them.
+     */
+    public function test_delete_data_for_user_from_single_context() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        $this->setUser($this->student12);
+
+        // Before deletion, we should have 2 PayPal transactions (1 of them for student12) in course1
+        // and 3 PayPal transactions (1 of them for student12) in course2.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id, 'userid' => $this->student12->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id, 'userid' => $this->student12->id])
+        );
+
+        // Delete data for user.
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->student12, 'enrol_paypal',
+                [$coursecontext1->id]);
+        provider::delete_data_for_user($contextlist);
+
+        // After deletion, PayPal transactions for student12 in course1 should have been deleted.
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id, 'userid' => $this->student12->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id, 'userid' => $this->student12->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_user() when student is enrolled in multiple courses and deleting from all of them.
+     */
+    public function test_delete_data_for_user_from_multiple_context() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+        $coursecontext2 = context_course::instance($this->course2->id);
+
+        $this->setUser($this->student12);
+
+        // Before deletion, we should have 2 PayPal transactions (1 of them for student12) in course1
+        // and 3 PayPal transactions (1 of them for student12) in course2.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id, 'userid' => $this->student12->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id, 'userid' => $this->student12->id])
+        );
+
+        // Delete data for user.
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->student12, 'enrol_paypal',
+                [$coursecontext1->id, $coursecontext2->id]);
+        provider::delete_data_for_user($contextlist);
+
+        // After deletion, PayPal enrolment data for student12 in both course1 and course2 should have been deleted.
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id, 'userid' => $this->student12->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id, 'userid' => $this->student12->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_user() when user is not enrolled, but is the receiver of the payment.
+     */
+    public function test_delete_data_for_user_for_business_user() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        $this->setUser($this->businessuser1);
+
+        // Before deletion, we should have 5 PayPal enrolments.
+        // 3 of which paid to businessuser1 and 2 of which paid to businessuser2.
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['business' => $this->businessuser1->email])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['business' => $this->businessuser2->email])
+        );
+
+        // Delete data for user in $coursecontext1.
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->businessuser1, 'enrol_paypal',
+                [$coursecontext1->id]);
+        provider::delete_data_for_user($contextlist);
+
+        // After deletion, PayPal enrolment data for businessuser1 in course1 should have been deleted.
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id, 'business' => $this->businessuser1->email])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['business' => $this->businessuser1->email])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id, 'business' => ''])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['business' => $this->businessuser2->email])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_user() when user is not enrolled, but is the receiver of the payment.
+     */
+    public function test_delete_data_for_user_for_receiver_user() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        $this->setUser($this->receiveruser1);
+
+        // Before deletion, we should have 5 PayPal enrolments.
+        // 2 of which paid to receiveruser1 and 3 of which paid to receiveruser2.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['receiver_email' => $this->receiveruser1->email])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['receiver_email' => $this->receiveruser2->email])
+        );
+
+        // Delete data for user.
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->receiveruser1, 'enrol_paypal',
+                [$coursecontext1->id]);
+        provider::delete_data_for_user($contextlist);
+
+        // After deletion, PayPal enrolment data for receiveruser1 in course1 should have been deleted.
+        $this->assertEquals(
+                0,
+                $DB->count_records('enrol_paypal', ['receiver_email' => $this->receiveruser1->email])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['receiver_email' => ''])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['receiver_email' => $this->receiveruser2->email])
+        );
+    }
+}
diff --git a/enrol/tests/privacy_test.php b/enrol/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..8d98fe2
--- /dev/null
@@ -0,0 +1,175 @@
+<?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 test for the core_enrol implementation of the privacy API.
+ *
+ * @package    core_enrol
+ * @category   test
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+use core_enrol\privacy\provider;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\writer;
+use core_privacy\tests\provider_testcase;
+use \core_privacy\local\request\transform;
+/**
+ * Privacy test for the core_enrol.
+ *
+ * @package    core_enrol
+ * @category   test
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_enrol_privacy_testcase extends provider_testcase {
+    /**
+     * Check that a course context is returned if there is any user data for this user.
+     */
+    public function test_get_contexts_for_userid() {
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $course1 = $this->getDataGenerator()->create_course();
+        $this->assertEmpty(provider::get_contexts_for_userid($user1->id));
+        // Enrol user into courses and check contextlist.
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id,  null, 'manual');
+        $contextlist = provider::get_contexts_for_userid($user1->id);
+        // Check that we only get back two context.
+        $this->assertCount(1, $contextlist);
+        // Check that the context is returned is the expected.
+        $coursecontext1 = \context_course::instance($course1->id);
+        $this->assertEquals($coursecontext1->id, $contextlist->get_contextids()[0]);
+    }
+    /**
+     * Test that user data is exported correctly.
+     */
+    public function test_export_user_data() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id,  null, 'self');
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id,  null, 'manual');
+        $subcontexts = [
+            get_string('privacy:metadata:user_enrolments', 'core_enrol')
+        ];
+        $coursecontext1 = \context_course::instance($course1->id);
+        $coursecontext2 = \context_course::instance($course2->id);
+        $this->setUser($user1);
+        $writer = writer::with_context($coursecontext1);
+        $this->assertFalse($writer->has_any_data());
+        $this->export_context_data_for_user($user1->id, $coursecontext1, 'core_enrol');
+        $data = $writer->get_related_data($subcontexts);
+        $this->assertCount(2, (array)$data);
+
+        $sql = "SELECT ue.id,
+                       ue.status,
+                       ue.timestart,
+                       ue.timeend,
+                       ue.timecreated,
+                       ue.timemodified
+                  FROM {user_enrolments} ue
+                  JOIN {enrol} e
+                    ON e.id = ue.enrolid
+                   AND e.courseid = :courseid
+                 WHERE ue.userid = :userid";
+        $enrolmentcouse2 = $DB->get_record_sql($sql, array('userid' => $user1->id, 'courseid' => $course2->id));
+        writer::reset();
+        $writer = writer::with_context($coursecontext2);
+        $this->export_context_data_for_user($user1->id, $coursecontext2, 'core_enrol');
+        $data = $writer->get_related_data($subcontexts, 'manual');
+        $this->assertEquals($enrolmentcouse2->status, reset($data)->status);
+        $this->assertEquals(transform::datetime($enrolmentcouse2->timestart), reset($data)->timestart);
+        $this->assertEquals(transform::datetime($enrolmentcouse2->timeend), reset($data)->timeend);
+        $this->assertEquals(transform::datetime($enrolmentcouse2->timecreated), reset($data)->timecreated);
+        $this->assertEquals(transform::datetime($enrolmentcouse2->timemodified), reset($data)->timemodified);
+    }
+    /**
+     * Test deleting all user data for a specific context.
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id,  null, 'manual');
+        // Get all user enrolments.
+        $userenrolments = $DB->get_records('user_enrolments', array());
+        $this->assertCount(5, $userenrolments);
+        // Get all user enrolments match with course1.
+        $sql = "SELECT ue.id
+                  FROM {user_enrolments} ue
+                  JOIN {enrol} e
+                    ON e.id = ue.enrolid
+                   AND e.courseid = :courseid";
+        $userenrolments = $DB->get_records_sql($sql, array('courseid' => $course1->id));
+        $this->assertCount(3, $userenrolments);
+        // Delete everything for the first course context.
+        $coursecontext1 = \context_course::instance($course1->id);
+        provider::delete_data_for_all_users_in_context($coursecontext1);
+        // Get all user enrolments match with this course contest.
+        $userenrolments = $DB->get_records_sql($sql, array('courseid' => $course1->id));
+        $this->assertCount(0, $userenrolments);
+        // Get all user enrolments.
+        $userenrolments = $DB->get_records('user_enrolments', array());
+        $this->assertCount(2, $userenrolments);
+    }
+    /**
+     * This should work identical to the above test.
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id,  null, 'manual');
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id,  null, 'manual');
+
+        // Get all user enrolments.
+        $userenrolments = $DB->get_records('user_enrolments', array());
+        $this->assertCount(4, $userenrolments);
+        // Get all user enrolments match with user1.
+        $userenrolments = $DB->get_records('user_enrolments', array('userid' => $user1->id));
+        $this->assertCount(2, $userenrolments);
+        // Delete everything for the user1 in the context course 1.
+        $coursecontext1 = \context_course::instance($course1->id);
+        $approvedlist = new approved_contextlist($user1, 'core_enrol', [$coursecontext1->id]);
+        provider::delete_data_for_user($approvedlist);
+        // Get all user enrolments match with user.
+        $userenrolments = $DB->get_records('user_enrolments', ['userid' => $user1->id]);
+        $this->assertCount(1, $userenrolments);
+        // Get all user enrolments accounts.
+        $userenrolments = $DB->get_records('user_enrolments', array());
+        $this->assertCount(3, $userenrolments);
+    }
+}
\ No newline at end of file
diff --git a/group/classes/privacy/provider.php b/group/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..ac71ae9
--- /dev/null
@@ -0,0 +1,248 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for core_group.
+ *
+ * @package    core_group
+ * @category   privacy
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_group\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\transform;
+
+/**
+ * Privacy Subsystem implementation for core_group.
+ *
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        // Groups store user data.
+        \core_privacy\local\metadata\provider,
+
+        // The group subsystem contains user's group memberships.
+        \core_privacy\local\request\subsystem\provider,
+
+        // The group subsystem can provide information to other plugins.
+        \core_privacy\local\request\subsystem\plugin_provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table('groups_members', [
+            'groupid' => 'privacy:metadata:groups:groupid',
+            'userid' => 'privacy:metadata:groups:userid',
+            'timeadded' => 'privacy:metadata:groups:timeadded',
+        ], 'privacy:metadata:groups');
+
+        return $collection;
+    }
+
+    /**
+     * Writes user data to the writer for the user to download.
+     *
+     * @param \context  $context    The context to export data for.
+     * @param string    $component  The component that is calling this function. Empty string means no component.
+     * @param array     $subcontext The sub-context in which to export this data.
+     * @param int       $itemid     Optional itemid associated with component.
+     */
+    public static function export_groups(\context $context, string $component, array $subcontext = [], int $itemid = 0) {
+        global $DB, $USER;
+
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
+        $subcontext[] = get_string('groups', 'core_group');
+
+        $sql = "SELECT gm.id, gm.timeadded, gm.userid, g.name
+                  FROM {groups_members} gm
+                  JOIN {groups} g ON gm.groupid = g.id
+                 WHERE g.courseid = :courseid
+                       AND gm.component = :component
+                       AND gm.userid = :userid";
+        $params = [
+            'courseid'  => $context->instanceid,
+            'component' => $component,
+            'userid'    => $USER->id
+        ];
+
+        if ($itemid) {
+            $sql .= ' AND gm.itemid = :itemid';
+            $params['itemid'] = $itemid;
+        }
+
+        $groups = $DB->get_records_sql($sql, $params);
+
+        $groups = array_map(function($group) {
+            return (object) [
+                'name' => format_string($group->name),
+                'timeadded' => transform::datetime($group->timeadded),
+            ];
+        }, $groups);
+
+        if (!empty($groups)) {
+            \core_privacy\local\request\writer::with_context($context)
+                    ->export_data($subcontext, (object) [
+                        'groups' => $groups,
+                    ]);
+        }
+    }
+
+    /**
+     * Deletes all group memberships for a specified context and component.
+     *
+     * @param \context  $context    Details about which context to delete group memberships for.
+     * @param string    $component  Component to delete. Empty string means no component (manual group memberships).
+     * @param int       $itemid     Optional itemid associated with component.
+     */
+    public static function delete_groups_for_all_users(\context $context, string $component, int $itemid = 0) {
+        global $DB;
+
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
+        if (!$DB->record_exists('groups', ['courseid' => $context->instanceid])) {
+            return;
+        }
+
+        $select = "component = :component AND groupid IN (SELECT g.id FROM {groups} g WHERE courseid = :courseid)";
+        $params = ['component' => $component, 'courseid' => $context->instanceid];
+
+        if ($itemid) {
+            $select .= ' AND itemid = :itemid';
+            $params['itemid'] = $itemid;
+        }
+
+        $DB->delete_records_select('groups_members', $select, $params);
+
+        // Purge the group and grouping cache for users.
+        \cache_helper::purge_by_definition('core', 'user_group_groupings');
+    }
+
+    /**
+     * Deletes all records for a user from a list of approved contexts.
+     *
+     * @param approved_contextlist  $contextlist    Contains the user ID and a list of contexts to be deleted from.
+     * @param string                $component      Component to delete from. Empty string means no component (manual memberships).
+     * @param int                   $itemid         Optional itemid associated with component.
+     */
+    public static function delete_groups_for_user(approved_contextlist $contextlist, string $component, int $itemid = 0) {
+        global $DB;
+
+        $userid = $contextlist->get_user()->id;
+
+        $contextids = $contextlist->get_contextids();
+
+        if (!$contextids) {
+            return;
+        }
+
+        list($contextsql, $contextparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED);
+        $contextparams += ['contextcourse' => CONTEXT_COURSE];
+        $groupselect = "SELECT g.id
+                          FROM {groups} g
+                          JOIN {context} ctx ON g.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
+                         WHERE ctx.id $contextsql";
+
+        if (!$DB->record_exists_sql($groupselect, $contextparams)) {
+            return;
+        }
+
+        $select = "userid = :userid AND component = :component AND groupid IN ({$groupselect})";
+        $params = ['userid' => $userid, 'component' => $component] + $contextparams;
+
+        if ($itemid) {
+            $select .= ' AND itemid = :itemid';
+            $params['itemid'] = $itemid;
+        }
+
+        $DB->delete_records_select('groups_members', $select, $params);
+
+        // Invalidate the group and grouping cache for the user.
+        \cache_helper::invalidate_by_definition('core', 'user_group_groupings', array(), array($userid));
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   int $userid The user to search.
+     * @return  contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $contextlist = new contextlist();
+
+        $sql = "SELECT ctx.id
+                  FROM {groups_members} gm
+                  JOIN {groups} g ON gm.groupid = g.id
+                  JOIN {context} ctx ON g.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
+                 WHERE gm.userid = :userid";
+
+        $params = [
+            'contextcourse' => CONTEXT_COURSE,
+            'userid'        => $userid
+        ];
+
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        $contexts = $contextlist->get_contexts();
+
+        foreach ($contexts as $context) {
+            static::export_groups($context, '');
+        }
+    }
+
+    /**
+     * 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) {
+        static::delete_groups_for_all_users($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) {
+        static::delete_groups_for_user($contextlist, '');
+    }
+}
diff --git a/group/tests/privacy_provider_test.php b/group/tests/privacy_provider_test.php
new file mode 100644 (file)
index 0000000..cb61442
--- /dev/null
@@ -0,0 +1,792 @@
+<?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 provider tests.
+ *
+ * @package    core_group
+ * @category   test
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\metadata\collection;
+use core_group\privacy\provider;
+use core_privacy\local\request\writer;
+
+/**
+ * Class core_group_privacy_provider_testcase.
+ *
+ * @copyright  2018 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_group_privacy_provider_testcase extends provider_testcase {
+
+    /**
+     * Test for provider::get_metadata().
+     */
+    public function test_get_metadata() {
+        $collection = new collection('core_group');
+        $newcollection = provider::get_metadata($collection);
+        $itemcollection = $newcollection->get_collection();
+        $this->assertCount(1, $itemcollection);
+
+        $table = reset($itemcollection);
+
+        $this->assertEquals('groups_members', $table->get_name());
+        $this->assertEquals('privacy:metadata:groups', $table->get_summary());
+
+        $privacyfields = $table->get_privacy_fields();
+        $this->assertArrayHasKey('groupid', $privacyfields);
+        $this->assertArrayHasKey('userid', $privacyfields);
+        $this->assertArrayHasKey('timeadded', $privacyfields);
+    }
+
+    /**
+     * Test for provider::export_groups() to export manual group memberships.
+     */
+    public function test_export_groups() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group3 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group4 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        // Add user1 to group1 and group2.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user1->id));
+
+        // Add user2 to group2 and group3.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3->id, 'userid' => $user2->id));
+
+        $context = context_course::instance($course->id);
+
+        // Retrieve groups for user1.
+        $this->setUser($user1);
+        $writer = writer::with_context($context);
+        provider::export_groups($context, '');
+
+        $data = $writer->get_data([get_string('groups', 'core_group')]);
+        $exportedgroups = $data->groups;
+
+        // User1 belongs to group1 and group2.
+        $this->assertEquals(
+                [$group1->name, $group2->name],
+                array_column($exportedgroups, 'name'),
+                '', 0.0, 10, true);
+    }
+
+    /**
+     * Test for provider::export_groups() to export group memberships of a component.
+     */
+    public function test_export_groups_for_component() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group3 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group4 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group5 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, null, 'self');
+
+        // Add user1 to group1 (via enrol_self) and group2 and group3.
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $group1->id, 'userid' => $user1->id, 'component' => 'enrol_self'));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3->id, 'userid' => $user1->id));
+
+        // Add user2 to group3 (via enrol_self) and group4.
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $group3->id, 'userid' => $user2->id, 'component' => 'enrol_self'));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group4->id, 'userid' => $user2->id));
+
+        $context = context_course::instance($course->id);
+
+        // Retrieve groups for user1.
+        $this->setUser($user1);
+        $writer = writer::with_context($context);
+        provider::export_groups($context, 'enrol_self');
+
+        $data = $writer->get_data([get_string('groups', 'core_group')]);
+        $exportedgroups = $data->groups;
+
+        // User1 only belongs to group1 via enrol_self.
+        $this->assertCount(1, $exportedgroups);
+        $exportedgroup = reset($exportedgroups);
+        $this->assertEquals($group1->name, $exportedgroup->name);
+    }
+
+    /**
+     * Test for provider::delete_groups_for_all_users() to delete manual group memberships.
+     */
+    public function test_delete_groups_for_all_users() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id);
+
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2b->id, 'userid' => $user2->id));
+
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course2->id])
+        );
+
+        $coursecontext1 = context_course::instance($course1->id);
+        provider::delete_groups_for_all_users($coursecontext1, '');
+
+        $this->assertEquals(
+            0,
+            $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+            2,
+            $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course2->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_groups_for_all_users() to delete group memberships of a component.
+     */
+    public function test_delete_groups_for_all_users_for_component() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id, null, 'self');
+
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $group1a->id, 'userid' => $user1->id, 'component' => 'enrol_self'));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $group2a->id, 'userid' => $user1->id, 'component' => 'enrol_self'));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2b->id, 'userid' => $user2->id));
+
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course2->id])
+        );
+
+        $coursecontext1 = context_course::instance($course1->id);
+        provider::delete_groups_for_all_users($coursecontext1, 'enrol_self');
+
+        $this->assertEquals(
+            1,
+            $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+            2,
+            $DB->count_records_sql("SELECT COUNT(gm.id)
+                                      FROM {groups_members} gm
+                                      JOIN {groups} g ON gm.groupid = g.id
+                                     WHERE g.courseid = ?", [$course2->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_groups_for_all_users() to check deleting from cache.
+     */
+    public function test_delete_groups_for_all_users_deletes_cache() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $this->getDataGenerator()->create_group_member(array('userid' => $user1->id, 'groupid' => $group1->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user1->id, 'groupid' => $group2->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user2->id, 'groupid' => $group1->id));
+
+        $this->assertEquals([[$group1->id, $group2->id]], groups_get_user_groups($course->id, $user1->id), '', 0.0, 10, true);
+        $this->assertEquals([[$group1->id]], groups_get_user_groups($course->id, $user2->id));
+
+        $coursecontext = context_course::instance($course->id);
+        provider::delete_groups_for_all_users($coursecontext, '');
+
+        $this->assertEquals([[]], groups_get_user_groups($course->id, $user1->id));
+        $this->assertEquals([[]], groups_get_user_groups($course->id, $user2->id));
+    }
+
+    /**
+     * Test for provider::delete_groups_for_user() to delete manual group memberships.
+     */
+    public function test_delete_groups_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course3 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group3a = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+        $group3b = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course3->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course3->id);
+
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3b->id, 'userid' => $user2->id));
+
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE gm.userid = ?", [$user1->id])
+        );
+
+        $this->setUser($user1);
+        $coursecontext1 = context_course::instance($course1->id);
+        $coursecontext2 = context_course::instance($course2->id);
+        $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist($user1, 'core_group',
+                [$coursecontext1->id, $coursecontext2->id]);
+        provider::delete_groups_for_user($approvedcontextlist, '');
+
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course3->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE gm.userid = ?", [$user1->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_groups_for_user() to delete group memberships of a component.
+     */
+    public function test_delete_groups_for_user_for_component() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course3 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group3a = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+        $group3b = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user1->id, $course3->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id, null, 'self');
+        $this->getDataGenerator()->enrol_user($user2->id, $course3->id, null, 'self');
+
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $group1a->id, 'userid' => $user1->id, 'component' => 'enrol_self'));
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $group1b->id, 'userid' => $user2->id, 'component' => 'enrol_self'));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3b->id, 'userid' => $user2->id));
+
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE gm.userid = ?", [$user1->id])
+        );
+
+        $this->setUser($user1);
+        $coursecontext1 = context_course::instance($course1->id);
+        $coursecontext2 = context_course::instance($course2->id);
+        $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist($user1, 'core_group',
+                [$coursecontext1->id, $coursecontext2->id]);
+        provider::delete_groups_for_user($approvedcontextlist, 'enrol_self');
+
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course3->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE gm.userid = ?", [$user1->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_groups_for_user() to check deleting from cache.
+     */
+    public function test_delete_groups_for_user_deletes_cache() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id);
+
+        $this->getDataGenerator()->create_group_member(array('userid' => $user->id, 'groupid' => $group1->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user->id, 'groupid' => $group2->id));
+
+        $this->assertEquals([[$group1->id, $group2->id]], groups_get_user_groups($course->id, $user->id), '', 0.0, 10, true);
+
+        $this->setUser($user);
+        $coursecontext = context_course::instance($course->id);
+        $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist($user, 'core_group', [$coursecontext->id]);
+        provider::delete_groups_for_user($approvedcontextlist, '');
+
+        $this->assertEquals([[]], groups_get_user_groups($course->id, $user->id));
+    }
+
+    /**
+     * Test for provider::get_contexts_for_userid().
+     */
+    public function test_get_contexts_for_userid() {
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course3 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group3a = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+        $group3b = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course3->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course3->id);
+
+        $this->getDataGenerator()->create_group_member(array('userid' => $user1->id, 'groupid' => $group1a->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user1->id, 'groupid' => $group2a->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user2->id, 'groupid' => $group1b->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user2->id, 'groupid' => $group2b->id));
+        $this->getDataGenerator()->create_group_member(array('userid' => $user2->id, 'groupid' => $group3b->id));
+
+        $coursecontext1 = context_course::instance($course1->id);
+        $coursecontext2 = context_course::instance($course2->id);
+
+        // User1 is member of some groups in course1 and course2.
+        $contextlist = provider::get_contexts_for_userid($user1->id);
+        $this->assertCount(2, $contextlist);
+        $this->assertEquals(
+                [$coursecontext1->id, $coursecontext2->id],
+                $contextlist->get_contextids(),
+                '', 0.0, 10, true);
+    }
+
+    /**
+     * Test for provider::export_user_data().
+     */
+    public function test_export_user_data() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group3 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $group4 = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        // Add user1 to group1 and group2.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user1->id));
+
+        // Add user2 to group2 and group3.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3->id, 'userid' => $user2->id));
+
+        $context = context_course::instance($course->id);
+
+        $this->setUser($user1);
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($user1->id, $context, 'core_group');
+
+        $writer = writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+
+        $data = $writer->get_data([get_string('groups', 'core_group')]);
+        $exportedgroups = $data->groups;
+
+        // User1 belongs to group1 and group2.
+        $this->assertEquals(
+                [$group1->name, $group2->name],
+                array_column($exportedgroups, 'name'),
+                '', 0.0, 10, true);
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id);
+
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2b->id, 'userid' => $user2->id));
+
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+
+        $coursecontext1 = context_course::instance($course1->id);
+        provider::delete_data_for_all_users_in_context($coursecontext1);
+
+        $this->assertEquals(
+                0,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course3 = $this->getDataGenerator()->create_course();
+
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id));
+        $group2a = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group2b = $this->getDataGenerator()->create_group(array('courseid' => $course2->id));
+        $group3a = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+        $group3b = $this->getDataGenerator()->create_group(array('courseid' => $course3->id));
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course3->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course3->id);
+
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group2b->id, 'userid' => $user2->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3a->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group3b->id, 'userid' => $user2->id));
+
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE gm.userid = ?", [$user1->id])
+        );
+
+        $this->setUser($user1);
+        $coursecontext1 = context_course::instance($course1->id);
+        $coursecontext2 = context_course::instance($course2->id);
+        $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist($user1, 'core_group',
+                [$coursecontext1->id, $coursecontext2->id]);
+        provider::delete_data_for_user($approvedcontextlist);
+
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course1->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course2->id])
+        );
+        $this->assertEquals(
+                2,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE g.courseid = ?", [$course3->id])
+        );
+        $this->assertEquals(
+                1,
+                $DB->count_records_sql("SELECT COUNT(gm.id)
+                                          FROM {groups_members} gm
+                                          JOIN {groups} g ON gm.groupid = g.id
+                                         WHERE gm.userid = ?", [$user1->id])
+        );
+    }
+}
index e95b0bb..502093c 100644 (file)
--- a/index.php
+++ b/index.php
@@ -38,7 +38,6 @@ if (!empty($CFG->defaulthomepage) && ($CFG->defaulthomepage == HOMEPAGE_MY) && o
     $urlparams['redirect'] = 0;
 }
 $PAGE->set_url('/', $urlparams);
-$PAGE->set_course($SITE);
 $PAGE->set_pagelayout('frontpage');
 $PAGE->set_other_editing_capability('moodle/course:update');
 $PAGE->set_other_editing_capability('moodle/course:manageactivities');
@@ -47,11 +46,7 @@ $PAGE->set_other_editing_capability('moodle/course:activityvisibility');
 // Prevent caching of this page to stop confusion when changing page after making AJAX changes.
 $PAGE->set_cacheable(false);
 
-if ($CFG->forcelogin) {
-    require_login();
-} else {
-    user_accesstime_log();
-}
+require_course_login($SITE);
 
 $hasmaintenanceaccess = has_capability('moodle/site:maintenanceaccess', context_system::instance());
 
index 4dcef01..e8b3084 100644 (file)
@@ -34,3 +34,4 @@ $string['language'] = 'Wik√†';
 $string['moodlelogo'] = 'Logo ng Moodle';
 $string['next'] = 'Susunod';
 $string['previous'] = 'Nakaraan';
+$string['reload'] = 'I-reload';
diff --git a/lang/en/course.php b/lang/en/course.php
new file mode 100644 (file)
index 0000000..bd1a992
--- /dev/null
@@ -0,0 +1,27 @@
+<?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/>.
+
+/**
+ * Strings for component 'course', language 'en', branch 'MOODLE_20_STABLE'
+ *
+ * @package   core_course
+ * @copyright 2018 Adrian Greeve <adriangreeve.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['privacy:perpage'] = 'The number of courses to show per page.';
+$string['privacy:completionpath'] = 'Course completion';
+$string['privacy:metadata:completionsummary'] = 'The course contains completion information about the user.';
index 1d29ca6..7315719 100644 (file)
@@ -149,3 +149,13 @@ $string['extremovedsuspend'] = 'Disable course enrolment';
 $string['extremovedsuspendnoroles'] = 'Disable course enrolment and remove roles';
 $string['extremovedkeep'] = 'Keep user enrolled';
 $string['extremovedunenrol'] = 'Unenrol user from course';
+$string['privacy:metadata:user_enrolments'] = 'Enrolments';
+$string['privacy:metadata:user_enrolments:enrolid'] = 'The instance of the enrol plugin.';
+$string['privacy:metadata:user_enrolments:modifierid'] = 'The ID of the user who last modified the user enrolment.';
+$string['privacy:metadata:user_enrolments:status'] = 'The status of the user enrolment in a course.';
+$string['privacy:metadata:user_enrolments:tableexplanation'] = 'This is where Enrol management stores enrolled users.';
+$string['privacy:metadata:user_enrolments:timecreated'] = 'The date/time of when the user enrolment was created.';
+$string['privacy:metadata:user_enrolments:timeend'] = 'The date/time of when the user enrolment ends.';
+$string['privacy:metadata:user_enrolments:timestart'] = 'The date/time of when the user enrolment starts.';
+$string['privacy:metadata:user_enrolments:timemodified'] = 'The date/time of when the user enrolment was modified.';
+$string['privacy:metadata:user_enrolments:userid'] = 'The ID of the user.';
index ead53e5..b349065 100644 (file)
@@ -173,6 +173,10 @@ $string['overview'] = 'Overview';
 $string['potentialmembers'] = 'Potential members: {$a}';
 $string['potentialmembs'] = 'Potential members';
 $string['printerfriendly'] = 'Printer-friendly display';
+$string['privacy:metadata:groups'] = 'A record of group membership.';
+$string['privacy:metadata:groups:groupid'] = 'The ID of the group.';
+$string['privacy:metadata:groups:timeadded'] = 'The timestamp indicating when the user was added to the group.';
+$string['privacy:metadata:groups:userid'] = 'The ID of the user which is associated to the group.';
 $string['random'] = 'Randomly';
 $string['removegroupfromselectedgrouping'] = 'Remove group from grouping';
 $string['removefromgroup'] = 'Remove user from group {$a}';
index 7e18f50..792cb6e 100644 (file)
@@ -167,6 +167,12 @@ $string['pluginismisconfigured'] = 'Portfolio plugin is misconfigured, skipping.
 $string['portfolio'] = 'Portfolio';
 $string['portfolios'] = 'Portfolios';
 $string['privacy:metadata'] = 'The portfolio subsystem acts as a channel, passing requests from plugins to the various portfolio plugins.';
+$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:value'] = 'Value for the preference';
+$string['privacy:metadata:userid'] = 'The user Identifier.';
+$string['privacy:path'] = 'Portfolio instances';
 $string['queuesummary'] = 'Currently queued transfers';
 $string['returntowhereyouwere'] = 'Return to where you were';
 $string['save'] = 'Save';
index 957a504..a5fae08 100644 (file)
@@ -470,3 +470,20 @@ $string['whydoesuserhavecap'] = 'Why does {$a->fullname} have capability {$a->ca
 $string['whydoesusernothavecap'] = 'Why does {$a->fullname} not have capability {$a->capability} in context {$a->context}?';
 $string['xroleassignments'] = '{$a}\'s role assignments';
 $string['xuserswiththerole'] = 'Users with the role "{$a->role}"';
+$string['privacy:metadata:preference:showadvanced'] = 'Handle the toggle advanced mode button.';
+$string['privacy:metadata:role_assignments'] = 'Role assignments';
+$string['privacy:metadata:role_assignments:component'] = 'Plugin responsible for role assignment, empty when manually assigned.';
+$string['privacy:metadata:role_assignments:itemid'] = 'The Id of enrolment/auth instance responsible for this role assignment.';
+$string['privacy:metadata:role_assignments:modifierid'] = 'The Id of the user who created or modified the role assignment.';
+$string['privacy:metadata:role_assignments:roleid'] = 'The Id of the role.';
+$string['privacy:metadata:role_assignments:tableexplanation'] = 'This table stores the assigned roles in each context.';
+$string['privacy:metadata:role_assignments:timemodified'] = 'The date when the role assignment was created or modified.';
+$string['privacy:metadata:role_assignments:userid'] = 'The Id of the user.';
+$string['privacy:metadata:role_capabilities'] = 'Role capabilities';
+$string['privacy:metadata:role_capabilities:capability'] = 'The name of the capability.';
+$string['privacy:metadata:role_capabilities:modifierid'] = 'The Id of the user who created or modified the capability.';
+$string['privacy:metadata:role_capabilities:permission'] = 'The permission for a capability: inherit, allow, prevent or prohibit.';
+$string['privacy:metadata:role_capabilities:roleid'] = 'The Id of the role.';
+$string['privacy:metadata:role_capabilities:tableexplanation'] = 'This table stores the capabilities and the override capabilities for a particular role in a particular context.';
+$string['privacy:metadata:role_capabilities:timemodified'] = 'The date when the capability was created or modified.';
+$string['privacy:metadata:role_cohortroles'] = 'Roles to cohort';
diff --git a/lang/en/user.php b/lang/en/user.php
new file mode 100644 (file)
index 0000000..8a4d933
--- /dev/null
@@ -0,0 +1,119 @@
+<?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/>.
+
+/**
+ * Strings for component 'user', language 'en', branch 'MOODLE_20_STABLE'
+ *
+ * @package   core_user
+ * @copyright 2018 Adrian Greeve <adriangreeve.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['privacy:courserequestpath'] = 'Requested courses';
+$string['privacy:descriptionpath'] = 'Profile description';
+$string['privacy:devicespath'] = 'User devices';
+$string['privacy:draftfilespath'] = 'Draft files';
+$string['privacy:lastaccesspath'] = 'Last access to courses';
+$string['privacy:metadata:address'] = 'The address of the user.';
+$string['privacy:metadata:aim'] = 'The AIM identifier of the user.';
+$string['privacy:metadata:alternatename'] = 'An alternative name for the user.';
+$string['privacy:metadata:appid'] = 'The app id, usually something like com.moodle.moodlemobile';
+$string['privacy:metadata:auth'] = 'The authentication plugin used for this user record.';
+$string['privacy:metadata:autosubscribe'] = 'A preference as to if the user should be auto-subscribed to forums the user posts in.';
+$string['privacy:metadata:calendartype'] = 'A user preference for the type of calendar to use.';
+$string['privacy:metadata:category'] = 'The category identifier.';
+$string['privacy:metadata:city'] = 'The city of the user.';
+$string['privacy:metadata:confirmed'] = 'If this is an active user or not.';
+$string['privacy:metadata:country'] = 'The country that the user is in.';
+$string['privacy:metadata:courseid'] = 'An identifier for a course.';
+$string['privacy:metadata:currentlogin'] = 'The current login for this user.';
+$string['privacy:metadata:data'] = 'Data relating to the custom user field from the user.';
+$string['privacy:metadata:deleted'] = 'A flag to show if the user has been deleted or not.';
+$string['privacy:metadata:department'] = 'The department that this user can be found in.';
+$string['privacy:metadata:description'] = 'General details about this user.';
+$string['privacy:metadata:devicename'] = 'The device name, occam or iPhone etc..';
+$string['privacy:metadata:devicetablesummary'] = 'This table stores user\'s mobile devices information in order to send PUSH notifications';
+$string['privacy:metadata:email'] = 'An email address for contact.';
+$string['privacy:metadata:emailstop'] = 'A preference to stop email being sent to the user.';
+$string['privacy:metadata:fieldid'] = 'The ID relating to the custom user field.';
+$string['privacy:metadata:filelink'] = 'There are multiple different files for the user stored in the files table.';
+$string['privacy:metadata:firstaccess'] = 'The time that this user first accessed the site.';
+$string['privacy:metadata:firstip'] = 'The first IP address recorded';
+$string['privacy:metadata:firstname'] = 'The first name of the user.';
+$string['privacy:metadata:firstnamephonetic'] = 'The phonetic details about the user\'s first name.';
+$string['privacy:metadata:fullname'] = 'The fullname for this course.';
+$string['privacy:metadata:hash'] = 'A hash of a previous password.';
+$string['privacy:metadata:icq'] = 'The ICQ number of the user.';
+$string['privacy:metadata:id'] = 'The identifier for the user.';
+$string['privacy:metadata:idnumber'] = 'An identification number given by the institution.';
+$string['privacy:metadata:imagealt'] = 'Alternative text for the user\'s image.';
+$string['privacy:metadata:infotablesummary'] = 'Stores custom user information.';
+$string['privacy:metadata:institution'] = 'The institution that this user is a member of.';
+$string['privacy:metadata:lang'] = 'A user preference for the language shown.';
+$string['privacy:metadata:lastaccess'] = 'The time that the user last accessed the site.';
+$string['privacy:metadata:lastaccesstablesummary'] = 'Information about the last time a user accessed a course.';
+$string['privacy:metadata:lastip'] = 'The last IP address for the user.';
+$string['privacy:metadata:lastlogin'] = 'The last login of this user.';
+$string['privacy:metadata:lastname'] = 'The surname of the user.';
+$string['privacy:metadata:lastnamephonetic'] = 'The phonetic details about the user\'s surname.';
+$string['privacy:metadata:maildigest'] = 'A setting for the mail digest for this user.';
+$string['privacy:metadata:maildisplay'] = 'A preference for the user about displaying their email address to other users.';
+$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: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.';
+$string['privacy:metadata:phone'] = 'A phone number for the user.';
+$string['privacy:metadata:picture'] = 'The picture details associated with this user.';
+$string['privacy:metadata:platform'] = 'The device platform, Android or iOS etc';
+$string['privacy:metadata:policyagreed'] = 'A flag to determine if the user has agreed to the site policy.';
+$string['privacy:metadata:pushid'] = 'The device PUSH token/key/identifier/registration id';
+$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:username'] = 'The username for this user.';
+$string['privacy:metadata:secret'] = 'Secret.. not sure.';
+$string['privacy:metadata:sessdata'] = 'Session content';
+$string['privacy:metadata:sessiontablesummary'] = 'Database based session storage';
+$string['privacy:metadata:shortname'] = 'A short name for the course.';
+$string['privacy:metadata:sid'] = 'The session ID';
+$string['privacy:metadata:skype'] = 'The skype identifier of the user.';
+$string['privacy:metadata:state'] = '0 means a normal session';
+$string['privacy:metadata:summary'] = 'A description of the course.';
+$string['privacy:metadata:theme'] = 'A user preference for the theme to display.';
+$string['privacy:metadata:timeaccess'] = 'The time for access to the course.';
+$string['privacy:metadata:timecreated'] = 'The time this record was created.';
+$string['privacy:metadata:timemodified'] = 'The time this records was modified.';
+$string['privacy:metadata:timererequested'] = 'The time the user re-requested the password reset.';
+$string['privacy:metadata:timerequested'] = 'The time that the user first requested this password reset';
+$string['privacy:metadata:timezone'] = 'The timezone that the user resides in.';
+$string['privacy:metadata:token'] = 'secret set and emailed to user';
+$string['privacy:metadata:trackforums'] = 'A preference for forums and tracking them.';
+$string['privacy:metadata:trustbitmask'] = 'The trust bit mask';
+$string['privacy:metadata:yahoo'] = 'The yahoo identifier of the user.';
+$string['privacy:metadata:url'] = 'A URL related to this user.';
+$string['privacy:metadata:userid'] = 'The user ID linked to this table.';
+$string['privacy:metadata:usertablesummary'] = 'This table stores the main personal data about the user.';
+$string['privacy:metadata:uuid'] = 'The device vendor UUID';
+$string['privacy:metadata:version'] = 'The device version, 6.1.2, 4.2.2 etc..';
+$string['privacy:passwordhistorypath'] = 'Password history';
+$string['privacy:passwordresetpath'] = 'Password resets';
+$string['privacy:profileimagespath'] = 'Profile images';
+$string['privacy:privatefilespath'] = 'Private files';
+$string['privacy:sessionpath'] = 'Session data';
index fceef83..edafff2 100644 (file)
@@ -2216,15 +2216,5 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018040500.01);
     }
 
-    if ($oldversion < 2018050200.01) {
-
-        // For upgraded sites we set $CFG->forceclean to 0 to preserve the current behavior.
-        // For new sites the default for this setting will be 1.
-        set_config('forceclean', 0);
-
-        // Main savepoint reached.
-        upgrade_main_savepoint(true, 2018050200.01);
-    }
-
     return true;
 }
index e1b368a..cb3ab99 100644 (file)
@@ -3005,6 +3005,10 @@ function require_course_login($courseorid, $autologinguest = true, $cm = null, $
         // Always login for hidden activities.
         require_login($courseorid, $autologinguest, $cm, $setwantsurltome, $preventredirect);
 
+    } else if (isloggedin() && !isguestuser()) {
+        // User is already logged in. Make sure the login is complete (user is fully setup, policies agreed).
+        require_login($courseorid, $autologinguest, $cm, $setwantsurltome, $preventredirect);
+
     } else if ($issite) {
         // Login for SITE not required.
         // We still need to instatiate PAGE vars properly so that things that rely on it like navigation function correctly.
index 29e5e69..4aded62 100644 (file)
@@ -117,9 +117,9 @@ class portfolio_exporter {
      * @param portfolio_caller_base $caller portfolio caller (passed by reference)
      * @param string $callercomponent the name of the callercomponent
      */
-    public function __construct(&$instance, &$caller, $callercomponent) {
-        $this->instance =& $instance;
-        $this->caller =& $caller;
+    public function __construct($instance, portfolio_caller_base $caller, $callercomponent) {
+        $this->instance = $instance;
+        $this->caller = $caller;
         if ($instance) {
             $this->instancefile = 'portfolio/' . $instance->get('plugin') . '/lib.php';
             $this->instance->set('exporter', $this);
index e484432..68fcf86 100644 (file)
@@ -1358,8 +1358,11 @@ function portfolio_include_callback_file($component, $class = null) {
         throw new portfolio_button_exception('nocallbackfile', 'portfolio', '', $component);
     }
 
-    if (!is_null($class) && !class_exists($class)) {
-        throw new portfolio_button_exception('nocallbackclass', 'portfolio', '', $class);
+    if (!is_null($class)) {
+        // If class is specified, check it exists and extends portfolio_caller_base.
+        if (!class_exists($class) || !is_subclass_of($class, 'portfolio_caller_base')) {
+            throw new portfolio_button_exception('nocallbackclass', 'portfolio', '', $class);
+        }
     }
 }
 
index 5a8410f..9d6d3b1 100644 (file)
@@ -109,9 +109,6 @@ class core_weblib_format_text_testcase extends advanced_testcase {
      * @param string $expected The expected filter value
      */
     public function test_format_text_blanktarget($link, $expected) {
-        global $CFG;
-        $this->resetAfterTest();
-        $CFG->forceclean = 0;
         $actual = format_text($link, FORMAT_MOODLE, array('blanktarget' => true, 'filter' => false, 'noclean' => true));
         $this->assertEquals($expected, $actual);
     }
index 49c6761..f7b053f 100644 (file)
@@ -104,7 +104,6 @@ class media_swf_testcase extends advanced_testcase {
      */
     public function test_embed_link() {
         global $CFG;
-        $CFG->forceclean = 0;
         $url = new moodle_url('http://example.org/some_filename.swf');
         $text = html_writer::link($url, 'Watch this one');
         $content = format_text($text, FORMAT_HTML, ['trusted' => true]);
@@ -114,13 +113,9 @@ class media_swf_testcase extends advanced_testcase {
         $this->assertRegExp('~width="' . $CFG->media_default_width . '" height="' .
             $CFG->media_default_height . '"~', $content);
 
-        // Not working without trust or with $CFG->forceclean!
+        // Not working without trust!
         $content = format_text($text, FORMAT_HTML);
         $this->assertNotRegExp('~mediaplugin_swf~', $content);
-
-        $CFG->forceclean = 1;
-        $content = format_text($text, FORMAT_HTML, ['trusted' => true]);
-        $this->assertNotRegExp('~mediaplugin_swf~', $content);
     }
 
     /**
@@ -130,8 +125,6 @@ class media_swf_testcase extends advanced_testcase {
      */
     public function test_embed_media() {
         global $CFG;
-        $CFG->forceclean = 0;
-
         $url = new moodle_url('http://example.org/some_filename.swf');
         $trackurl = new moodle_url('http://example.org/some_filename.vtt');
         $text = '<video controls="true"><source src="'.$url.'"/>' .
index 666d8be..e07665b 100644 (file)
@@ -48,9 +48,6 @@ class provider implements
         // This plugin is a core_user_data_provider.
         \core_privacy\local\request\plugin\provider {
 
-    /** @var array stores list of records marked for deletion */
-    protected static $deletedrecords = [];
-
     /**
      * Return the fields which contain personal data.
      *
@@ -324,6 +321,7 @@ class provider implements
         if (!$context instanceof \context_module) {
             return;
         }
+        $recordstobedeleted = [];
 
         $sql = "SELECT " . self::sql_fields() . "
                 FROM {course_modules} cm
@@ -337,10 +335,11 @@ class provider implements
         $rs = $DB->get_recordset_sql($sql, ['cmid' => $context->instanceid, 'modname' => 'data']);
         foreach ($rs as $row) {
             self::mark_data_content_for_deletion($context, $row);
+            $recordstobedeleted[$row->recordid] = $row->recordid;
         }
         $rs->close();
 
-        self::delete_data_records($context);
+        self::delete_data_records($context, $recordstobedeleted);
     }
 
     /**
@@ -356,6 +355,7 @@ class provider implements
         }
 
         $user = $contextlist->get_user();
+        $recordstobedeleted = [];
 
         foreach ($contextlist->get_contexts() as $context) {
             $sql = "SELECT " . self::sql_fields() . "
@@ -372,13 +372,14 @@ class provider implements
                 'modname' => 'data', 'userid' => $user->id]);
             foreach ($rs as $row) {
                 self::mark_data_content_for_deletion($context, $row);
+                $recordstobedeleted[$row->recordid] = $row->recordid;
             }
             $rs->close();
-            self::delete_data_records($context);
+            self::delete_data_records($context, $recordstobedeleted);
         }
 
         // Additionally remove comments this user made on other entries.
-        \core_comment\privacy\provider::delete_comments_for_user($contextlist, 'mod_data', 'entry');
+        \core_comment\privacy\provider::delete_comments_for_user($contextlist, 'mod_data', 'database_entry');
 
         // We do not delete ratings made by this user on other records because it may change grades.
     }
@@ -405,8 +406,6 @@ class provider implements
                     [$context, $recordobj, $fieldobj, $contentobj]);
             }
         }
-
-        self::$deletedrecords[$recordobj->id] = $recordobj->id;
     }
 
     /**
@@ -417,14 +416,15 @@ class provider implements
      * Deletes records from data_content and data_records tables, associated files, tags, comments and ratings.
      *
      * @param \context $context
+     * @param array $recordstobedeleted list of ids of the data records that need to be deleted
      */
-    protected static function delete_data_records($context) {
+    protected static function delete_data_records($context, $recordstobedeleted) {
         global $DB;
-        if (empty(self::$deletedrecords)) {
+        if (empty($recordstobedeleted)) {
             return;
         }
 
-        list($sql, $params) = $DB->get_in_or_equal(self::$deletedrecords, SQL_PARAMS_NAMED);
+        list($sql, $params) = $DB->get_in_or_equal($recordstobedeleted, SQL_PARAMS_NAMED);
 
         // Delete files.
         get_file_storage()->delete_area_files_select($context->id, 'mod_data', 'data_records',
@@ -439,7 +439,5 @@ class provider implements
         \core_comment\privacy\provider::delete_comments_for_all_users_select($context, 'mod_data', 'database_entry', $sql, $params);
         // Delete ratings.
         \core_rating\privacy\provider::delete_ratings_select($context, 'mod_data', 'entry', $sql, $params);
-
-        self::$deletedrecords = [];
     }
 }
index cd369e5..039f42b 100644 (file)
@@ -162,6 +162,7 @@ class reply_handler extends \core\message\inbound\handler {
         $addpost->subject      = $subject;
         $addpost->parent       = $post->id;
         $addpost->itemid       = file_get_unused_draft_itemid();
+        $addpost->deleted      = 0;
 
         list ($message, $format) = self::remove_quoted_text($messagedata);
         $addpost->message = $message;
diff --git a/mod/forum/classes/privacy/provider.php b/mod/forum/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..f0a7964
--- /dev/null
@@ -0,0 +1,880 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for mod_forum.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\privacy;
+
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\deletion_criteria;
+use \core_privacy\local\request\writer;
+use \core_privacy\local\request\helper as request_helper;
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\transform;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Implementation of the privacy subsystem plugin provider for the forum activity module.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    // This plugin has data.
+    \core_privacy\local\metadata\provider,
+
+    // This plugin currently implements the original plugin\provider interface.
+    \core_privacy\local\request\plugin\provider,
+
+    // This plugin has some sitewide user preferences to export.
+    \core_privacy\local\request\user_preference_provider
+{
+
+    use subcontext_info;
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection     $items 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 {
+        // The 'forum' table does not store any specific user data.
+        $items->add_database_table('forum_digests', [
+            'forum' => 'privacy:metadata:forum_digests:forum',
+            'userid' => 'privacy:metadata:forum_digests:userid',
+            'maildigest' => 'privacy:metadata:forum_digests:maildigest',
+        ], 'privacy:metadata:forum_digests');
+
+        // The 'forum_discussions' table stores the metadata about each forum discussion.
+        $items->add_database_table('forum_discussions', [
+            'name' => 'privacy:metadata:forum_discussions:name',
+            'userid' => 'privacy:metadata:forum_discussions:userid',
+            'assessed' => 'privacy:metadata:forum_discussions:assessed',
+            'timemodified' => 'privacy:metadata:forum_discussions:timemodified',
+            'usermodified' => 'privacy:metadata:forum_discussions:usermodified',
+        ], 'privacy:metadata:forum_discussions');
+
+        // The 'forum_discussion_subs' table stores information about which discussions a user is subscribed to.
+        $items->add_database_table('forum_discussion_subs', [
+            'discussionid' => 'privacy:metadata:forum_discussion_subs:discussionid',
+            'preference' => 'privacy:metadata:forum_discussion_subs:preference',
+            'userid' => 'privacy:metadata:forum_discussion_subs:userid',
+        ], 'privacy:metadata:forum_discussion_subs');
+
+        // The 'forum_posts' table stores the metadata about each forum discussion.
+        $items->add_database_table('forum_posts', [
+            'discussion' => 'privacy:metadata:forum_posts:discussion',
+            'parent' => 'privacy:metadata:forum_posts:parent',
+            'created' => 'privacy:metadata:forum_posts:created',
+            'modified' => 'privacy:metadata:forum_posts:modified',
+            'subject' => 'privacy:metadata:forum_posts:subject',
+            'message' => 'privacy:metadata:forum_posts:message',
+            'userid' => 'privacy:metadata:forum_posts:userid',
+        ], 'privacy:metadata:forum_posts');
+
+        // The 'forum_queue' table contains user data, but it is only a temporary cache of other data.
+        // We should not need to export it as it does not allow profiling of a user.
+
+        // The 'forum_read' table stores data about which forum posts have been read by each user.
+        $items->add_database_table('forum_read', [
+            'userid' => 'privacy:metadata:forum_read:userid',
+            'discussionid' => 'privacy:metadata:forum_read:discussionid',
+            'postid' => 'privacy:metadata:forum_read:postid',
+            'firstread' => 'privacy:metadata:forum_read:firstread',
+            'lastread' => 'privacy:metadata:forum_read:lastread',
+        ], 'privacy:metadata:forum_read');
+
+        // The 'forum_subscriptions' table stores information about which forums a user is subscribed to.
+        $items->add_database_table('forum_subscriptions', [
+            'userid' => 'privacy:metadata:forum_subscriptions:userid',
+            'forum' => 'privacy:metadata:forum_subscriptions:forum',
+        ], 'privacy:metadata:forum_subscriptions');
+
+        // The 'forum_subscriptions' table stores information about which forums a user is subscribed to.
+        $items->add_database_table('forum_track_prefs', [
+            'userid' => 'privacy:metadata:forum_track_prefs:userid',
+            'forumid' => 'privacy:metadata:forum_track_prefs:forumid',
+        ], 'privacy:metadata:forum_track_prefs');
+
+        // 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');
+
+        // There are several user preferences.
+        $items->add_user_preference('maildigest', 'privacy:metadata:preference:maildigest');
+        $items->add_user_preference('autosubscribe', 'privacy:metadata:preference:autosubscribe');
+        $items->add_user_preference('trackforums', 'privacy:metadata:preference:trackforums');
+        $items->add_user_preference('markasreadonnotification', 'privacy:metadata:preference:markasreadonnotification');
+
+        return $items;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * In the case of forum, that is any forum where the user has made any post, rated any content, or has any preferences.
+     *
+     * @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 {
+        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid);
+        // Fetch all forum discussions, and forum posts.
+        $sql = "SELECT c.id
+                  FROM {context} c
+            INNER JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
+            INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname
+            INNER JOIN {forum} f ON f.id = cm.instance
+             LEFT JOIN {forum_discussions} d ON d.forum = f.id
+             LEFT JOIN {forum_posts} p ON p.discussion = d.id
+             LEFT JOIN {forum_digests} dig ON dig.forum = f.id
+             LEFT JOIN {forum_subscriptions} sub ON sub.forum = f.id
+             LEFT JOIN {forum_track_prefs} pref ON pref.forumid = f.id
+             LEFT JOIN {forum_read} hasread ON hasread.forumid = f.id
+             LEFT JOIN {forum_discussion_subs} dsub ON dsub.forum = f.id
+             {$ratingsql->join}
+                 WHERE (
+                    p.userid        = :postuserid OR
+                    d.userid        = :discussionuserid OR
+                    dig.userid      = :digestuserid OR
+                    sub.userid      = :subuserid OR
+                    pref.userid     = :prefuserid OR
+                    hasread.userid  = :hasreaduserid OR
+                    dsub.userid     = :dsubuserid OR
+                    {$ratingsql->userwhere}
+                )
+        ";
+        $params = [
+            'modname'           => 'forum',
+            'contextlevel'      => CONTEXT_MODULE,
+            'postuserid'        => $userid,
+            'discussionuserid'  => $userid,
+            'digestuserid'      => $userid,
+            'subuserid'         => $userid,
+            'prefuserid'        => $userid,
+            'hasreaduserid'     => $userid,
+            'dsubuserid'        => $userid,
+        ];
+        $params += $ratingsql->params;
+
+        $contextlist = new \core_privacy\local\request\contextlist();
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Store all user preferences for the plugin.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $user = \core_user::get_user($userid);
+
+        switch ($user->maildigest) {
+            case 1:
+                $digestdescription = get_string('emaildigestcomplete');
+                break;
+            case 2:
+                $digestdescription = get_string('emaildigestsubjects');
+                break;
+            case 0:
+            default:
+                $digestdescription = get_string('emaildigestoff');
+                break;
+        }
+        writer::export_user_preference('mod_forum', 'maildigest', $user->maildigest, $digestdescription);
+
+        switch ($user->autosubscribe) {
+            case 0:
+                $subscribedescription = get_string('autosubscribeno');
+                break;
+            case 1:
+            default:
+                $subscribedescription = get_string('autosubscribeyes');
+                break;
+        }
+        writer::export_user_preference('mod_forum', 'autosubscribe', $user->autosubscribe, $subscribedescription);
+
+        switch ($user->trackforums) {
+            case 0:
+                $trackforumdescription = get_string('trackforumsno');
+                break;
+            case 1:
+            default:
+                $trackforumdescription = get_string('trackforumsyes');
+                break;
+        }
+        writer::export_user_preference('mod_forum', 'trackforums', $user->trackforums, $trackforumdescription);
+
+        $markasreadonnotification = get_user_preferences('markasreadonnotification', null, $user->id);
+        if (null !== $markasreadonnotification) {
+            switch ($markasreadonnotification) {
+                case 0:
+                    $markasreadonnotificationdescription = get_string('markasreadonnotificationno', 'mod_forum');
+                    break;
+                case 1:
+                default:
+                    $markasreadonnotificationdescription = get_string('markasreadonnotificationyes', 'mod_forum');
+                    break;
+            }
+            writer::export_user_preference('mod_forum', 'markasreadonnotification', $markasreadonnotification,
+                    $markasreadonnotificationdescription);
+        }
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist    $contextlist    The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist)) {
+            return;
+        }
+
+        $user = $contextlist->get_user();
+        $userid = $user->id;
+
+        list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        $sql = "SELECT
+                    c.id AS contextid,
+                    f.*,
+                    cm.id AS cmid,
+                    dig.maildigest,
+                    sub.userid AS subscribed,
+                    pref.userid AS tracked
+                  FROM {context} c
+            INNER JOIN {course_modules} cm ON cm.id = c.instanceid
+            INNER JOIN {forum} f ON f.id = cm.instance
+             LEFT JOIN {forum_digests} dig ON dig.forum = f.id AND dig.userid = :digestuserid
+             LEFT JOIN {forum_subscriptions} sub ON sub.forum = f.id AND sub.userid = :subuserid
+             LEFT JOIN {forum_track_prefs} pref ON pref.forumid = f.id AND pref.userid = :prefuserid
+                 WHERE (
+                    c.id {$contextsql}
+                )
+        ";
+
+        $params = [
+            'digestuserid'  => $userid,
+            'subuserid'     => $userid,
+            'prefuserid'    => $userid,
+        ];
+        $params += $contextparams;
+
+        // Keep a mapping of forumid to contextid.
+        $mappings = [];
+
+        $forums = $DB->get_recordset_sql($sql, $params);
+        foreach ($forums as $forum) {
+            $mappings[$forum->id] = $forum->contextid;
+
+            $context = \context::instance_by_id($mappings[$forum->id]);
+
+            // Store the main forum data.
+            $data = request_helper::get_context_data($context, $user);
+            writer::with_context($context)
+                ->export_data([], $data);
+            request_helper::export_context_files($context, $user);
+
+            // Store relevant metadata about this forum instance.
+            static::export_digest_data($userid, $forum);
+            static::export_subscription_data($userid, $forum);
+            static::export_tracking_data($userid, $forum);
+        }
+        $forums->close();
+
+        if (!empty($mappings)) {
+            // Store all discussion data for this forum.
+            static::export_discussion_data($userid, $mappings);
+
+            // Store all post data for this forum.
+            static::export_all_posts($userid, $mappings);
+        }
+    }
+
+    /**
+     * Store all information about all discussions that we have detected this user to have access to.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   array       $mappings A list of mappings from forumid => contextid.
+     * @return  array       Which forums had data written for them.
+     */
+    protected static function export_discussion_data(int $userid, array $mappings) {
+        global $DB;
+
+        // Find all of the discussions, and discussion subscriptions for this forum.
+        list($foruminsql, $forumparams) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $sql = "SELECT
+                    d.*,
+                    g.name as groupname,
+                    dsub.preference
+                  FROM {forum} f
+            INNER JOIN {forum_discussions} d ON d.forum = f.id
+             LEFT JOIN {groups} g ON g.id = d.groupid
+             LEFT JOIN {forum_discussion_subs} dsub ON dsub.discussion = d.id
+             LEFT JOIN {forum_posts} p ON p.discussion = d.id
+                 WHERE f.id ${foruminsql}
+                   AND (
+                        d.userid    = :discussionuserid OR
+                        p.userid    = :postuserid OR
+                        dsub.userid = :dsubuserid
+                   )
+        ";
+
+        $params = [
+            'postuserid'        => $userid,
+            'discussionuserid'  => $userid,
+            'dsubuserid'        => $userid,
+        ];
+        $params += $forumparams;
+
+        // Keep track of the forums which have data.
+        $forumswithdata = [];
+
+        $discussions = $DB->get_recordset_sql($sql, $params);
+        foreach ($discussions as $discussion) {
+            // No need to take timestart into account as the user has some involvement already.
+            // Ignore discussion timeend as it should not block access to user data.
+            $forumswithdata[$discussion->forum] = true;
+            $context = \context::instance_by_id($mappings[$discussion->forum]);
+
+            // Store related metadata for this discussion.
+            static::export_discussion_subscription_data($userid, $context, $discussion);
+
+            $discussiondata = (object) [
+                'name' => format_string($discussion->name, true),
+                'pinned' => transform::yesno((bool) $discussion->pinned),
+                'timemodified' => transform::datetime($discussion->timemodified),
+                'usermodified' => transform::datetime($discussion->usermodified),
+                'creator_was_you' => transform::yesno($discussion->userid == $userid),
+            ];
+
+            // Store the discussion content.
+            writer::with_context($context)
+                ->export_data(static::get_discussion_area($discussion), $discussiondata);
+
+            // Forum discussions do not have any files associately directly with them.
+        }
+
+        $discussions->close();
+
+        return $forumswithdata;
+    }
+
+    /**
+     * Store all information about all posts that we have detected this user to have access to.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   array       $mappings A list of mappings from forumid => contextid.
+     * @return  array       Which forums had data written for them.
+     */
+    protected static function export_all_posts(int $userid, array $mappings) {
+        global $DB;
+
+        // Find all of the posts, and post subscriptions for this forum.
+        list($foruminsql, $forumparams) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid);
+        $sql = "SELECT
+                    p.discussion AS id,
+                    f.id AS forumid,
+                    d.name,
+                    d.groupid
+                  FROM {forum} f
+            INNER JOIN {forum_discussions} d ON d.forum = f.id
+            INNER JOIN {forum_posts} p ON p.discussion = d.id
+             LEFT JOIN {forum_read} fr ON fr.postid = p.id
+            {$ratingsql->join}
+                 WHERE f.id ${foruminsql} AND
+                (
+                    p.userid = :postuserid OR
+                    fr.userid = :readuserid OR
+                    {$ratingsql->userwhere}
+                )
+              GROUP BY f.id, p.discussion, d.name, d.groupid
+        ";
+
+        $params = [
+            'postuserid'    => $userid,
+            'readuserid'    => $userid,
+        ];
+        $params += $forumparams;
+        $params += $ratingsql->params;
+
+        $discussions = $DB->get_records_sql($sql, $params);
+        foreach ($discussions as $discussion) {
+            $context = \context::instance_by_id($mappings[$discussion->forumid]);
+            static::export_all_posts_in_discussion($userid, $context, $discussion);
+        }
+    }
+
+    /**
+     * Store all information about all posts that we have detected this user to have access to.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \context    $context The instance of the forum context.
+     * @param   \stdClass   $discussion The discussion whose data is being exported.
+     */
+    protected static function export_all_posts_in_discussion(int $userid, \context $context, \stdClass $discussion) {
+        global $DB, $USER;
+
+        $discussionid = $discussion->id;
+
+        // Find all of the posts, and post subscriptions for this forum.
+        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid);
+        $sql = "SELECT
+                    p.*,
+                    d.forum AS forumid,
+                    fr.firstread,
+                    fr.lastread,
+                    fr.id AS readflag,
+                    rat.id AS hasratings
+                    FROM {forum_discussions} d
+              INNER JOIN {forum_posts} p ON p.discussion = d.id
+               LEFT JOIN {forum_read} fr ON fr.postid = p.id AND fr.userid = :readuserid
+            {$ratingsql->join} AND {$ratingsql->userwhere}
+                   WHERE d.id = :discussionid
+        ";
+
+        $params = [
+            'discussionid'  => $discussionid,
+            'readuserid'    => $userid,
+        ];
+        $params += $ratingsql->params;
+
+        // Keep track of the forums which have data.
+        $structure = (object) [
+            'children' => [],
+        ];
+
+        $posts = $DB->get_records_sql($sql, $params);
+        foreach ($posts as $post) {
+            $post->hasdata = (isset($post->hasdata)) ? $post->hasdata : false;
+            $post->hasdata = $post->hasdata || !empty($post->hasratings);
+            $post->hasdata = $post->hasdata || $post->readflag;
+            $post->hasdata = $post->hasdata || ($post->userid == $USER->id);
+
+            if (0 == $post->parent) {
+                $structure->children[$post->id] = $post;
+            } else {
+                if (empty($posts[$post->parent]->children)) {
+                    $posts[$post->parent]->children = [];
+                }
+                $posts[$post->parent]->children[$post->id] = $post;
+            }
+
+            // Set all parents.
+            if ($post->hasdata) {
+                $curpost = $post;
+                while ($curpost->parent != 0) {
+                    $curpost = $posts[$curpost->parent];
+                    $curpost->hasdata = true;
+                }
+            }
+        }
+
+        $discussionarea = static::get_discussion_area($discussion);
+        $discussionarea[] = get_string('posts', 'mod_forum');
+        static::export_posts_in_structure($userid, $context, $discussionarea, $structure);
+    }
+
+    /**
+     * Export all posts in the provided structure.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \context    $context The instance of the forum context.
+     * @param   array       $parentarea The subcontext of the parent.
+     * @param   \stdClass   $structure The post structure and all of its children
+     */
+    protected static function export_posts_in_structure(int $userid, \context $context, $parentarea, \stdClass $structure) {
+        foreach ($structure->children as $post) {
+            if (!$post->hasdata) {
+                // This tree has no content belonging to the user. Skip it and all children.
+                continue;
+            }
+
+            $postarea = array_merge($parentarea, static::get_post_area($post));
+
+            // Store the post content.
+            static::export_post_data($userid, $context, $postarea, $post);
+
+            if (isset($post->children)) {
+                // Now export children of this post.
+                static::export_posts_in_structure($userid, $context, $postarea, $post);
+            }
+        }
+    }
+
+    /**
+     * Export all data in the post.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \context    $context The instance of the forum context.
+     * @param   array       $postarea The subcontext of the parent.
+     * @param   \stdClass   $post The post structure and all of its children
+     */
+    protected static function export_post_data(int $userid, \context $context, $postarea, $post) {
+        // Store related metadata.
+        static::export_read_data($userid, $context, $postarea, $post);
+
+        $postdata = (object) [
+            'subject' => format_string($post->subject, true),
+            'created' => transform::datetime($post->created),
+            'modified' => transform::datetime($post->modified),
+            'author_was_you' => transform::yesno($post->userid == $userid),
+        ];
+
+        $postdata->message = writer::with_context($context)
+            ->rewrite_pluginfile_urls($postarea, 'mod_forum', 'post', $post->id, $post->message);
+
+        $postdata->message = format_text($postdata->message, $post->messageformat, (object) [
+            'para'    => false,
+            'trusted' => $post->messagetrust,
+            'context' => $context,
+        ]);
+
+        writer::with_context($context)
+            // Store the post.
+            ->export_data($postarea, $postdata)
+
+            // Store the associated files.
+            ->export_area_files($postarea, 'mod_forum', 'post', $post->id);
+
+        if ($post->userid == $userid) {
+            // Store all ratings against this post as the post belongs to the user. All ratings on it are ratings of their content.
+            \core_rating\privacy\provider::export_area_ratings($userid, $context, $postarea, 'mod_forum', 'post', $post->id, false);
+
+            // Store all tags against this post as the tag belongs to the user.
+            \core_tag\privacy\provider::export_item_tags($userid, $context, $postarea, 'mod_forum', 'forum_posts', $post->id);
+
+            // Export all user data stored for this post from the plagiarism API.
+            $coursecontext = $context->get_course_context();
+            \core_plagiarism\privacy\provider::export_plagiarism_user_data($userid, $context, $postarea, [
+                    'cmid' => $context->instanceid,
+                    'course' => $coursecontext->instanceid,
+                    'forum' => $post->forumid,
+                    'discussionid' => $post->discussion,
+                    'postid' => $post->id,
+                ]);
+        }
+
+        // Check for any ratings that the user has made on this post.
+        \core_rating\privacy\provider::export_area_ratings($userid,
+                $context,
+                $postarea,
+                'mod_forum',
+                'post',
+                $post->id,
+                $userid,
+                true
+            );
+    }
+
+    /**
+     * Store data about daily digest preferences
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \stdClass   $forum The forum whose data is being exported.
+     * @return  bool        Whether any data was stored.
+     */
+    protected static function export_digest_data(int $userid, \stdClass $forum) {
+        if (null !== $forum->maildigest) {
+            // The user has a specific maildigest preference for this forum.
+            $a = (object) [
+                'forum' => format_string($forum->name, true),
+            ];
+
+            switch ($forum->maildigest) {
+                case 0:
+                    $a->type = get_string('emaildigestoffshort', 'mod_forum');
+                    break;
+                case 1:
+                    $a->type = get_string('emaildigestcompleteshort', 'mod_forum');
+                    break;
+                case 2:
+                    $a->type = get_string('emaildigestsubjectsshort', 'mod_forum');
+                    break;
+            }
+
+            writer::with_context(\context_module::instance($forum->cmid))
+                ->export_metadata([], 'digestpreference', $forum->maildigest,
+                    get_string('privacy:digesttypepreference', 'mod_forum', $a));
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Store data about whether the user subscribes to forum.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \stdClass   $forum The forum whose data is being exported.
+     * @return  bool        Whether any data was stored.
+     */
+    protected static function export_subscription_data(int $userid, \stdClass $forum) {
+        if (null !== $forum->subscribed) {
+            // The user is subscribed to this forum.
+            writer::with_context(\context_module::instance($forum->cmid))
+                ->export_metadata([], 'subscriptionpreference', 1, get_string('privacy:subscribedtoforum', 'mod_forum'));
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Store data about whether the user subscribes to this particular discussion.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \context_module $context The instance of the forum context.
+     * @param   \stdClass   $discussion The discussion whose data is being exported.
+     * @return  bool        Whether any data was stored.
+     */
+    protected static function export_discussion_subscription_data(int $userid, \context_module $context, \stdClass $discussion) {
+        $area = static::get_discussion_area($discussion);
+        if (null !== $discussion->preference) {
+            // The user has a specific subscription preference for this discussion.
+            $a = (object) [];
+
+            switch ($discussion->preference) {
+                case \mod_forum\subscriptions::FORUM_DISCUSSION_UNSUBSCRIBED:
+                    $a->preference = get_string('unsubscribed', 'mod_forum');
+                    break;
+                default:
+                    $a->preference = get_string('subscribed', 'mod_forum');
+                    break;
+            }
+
+            writer::with_context($context)
+                ->export_metadata(
+                    $area,
+                    'subscriptionpreference',
+                    $discussion->preference,
+                    get_string('privacy:discussionsubscriptionpreference', 'mod_forum', $a)
+                );
+
+            return true;
+        }
+
+        return true;
+    }
+
+    /**
+     * Store forum read-tracking data about a particular forum.
+     *
+     * This is whether a forum has read-tracking enabled or not.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \stdClass   $forum The forum whose data is being exported.
+     * @return  bool        Whether any data was stored.
+     */
+    protected static function export_tracking_data(int $userid, \stdClass $forum) {
+        if (null !== $forum->tracked) {
+            // The user has a main preference to track all forums, but has opted out of this one.
+            writer::with_context(\context_module::instance($forum->cmid))
+                ->export_metadata([], 'trackreadpreference', 0, get_string('privacy:readtrackingdisabled', 'mod_forum'));
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Store read-tracking information about a particular forum post.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     * @param   \context_module $context The instance of the forum context.
+     * @param   array       $postarea The subcontext for this post.
+     * @param   \stdClass   $post The post whose data is being exported.
+     * @return  bool        Whether any data was stored.
+     */
+    protected static function export_read_data(int $userid, \context_module $context, array $postarea, \stdClass $post) {
+        if (null !== $post->firstread) {
+            $a = (object) [
+                'firstread' => $post->firstread,
+                'lastread'  => $post->lastread,
+            ];
+
+            writer::with_context($context)
+                ->export_metadata(
+                    $postarea,
+                    'postread',
+                    (object) [
+                        'firstread' => $post->firstread,
+                        'lastread' => $post->lastread,
+                    ],
+                    get_string('privacy:postwasread', 'mod_forum', $a)
+                );
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param   context                 $context   The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        // Check that this is a context_module.
+        if (!$context instanceof \context_module) {
+            return;
+        }
+
+        // Get the course module.
+        $cm = $DB->get_record('course_modules', ['id' => $context->instanceid]);
+        $forum = $DB->get_record('forum', ['id' => $cm->instance]);
+
+        $DB->delete_records('forum_track_prefs', ['forumid' => $forum->id]);
+        $DB->delete_records('forum_subscriptions', ['forum' => $forum->id]);
+        $DB->delete_records('forum_read', ['forumid' => $forum->id]);
+
+        // Delete all discussion items.
+        $DB->delete_records_select(
+            'forum_queue',
+            "discussionid IN (SELECT id FROM {forum_discussions} WHERE forum = :forum)",
+            [
+                'forum' => $forum->id,
+            ]
+        );
+
+        $DB->delete_records_select(
+            'forum_posts',
+            "discussion IN (SELECT id FROM {forum_discussions} WHERE forum = :forum)",
+            [
+                'forum' => $forum->id,
+            ]
+        );
+
+        $DB->delete_records('forum_discussion_subs', ['forum' => $forum->id]);
+        $DB->delete_records('forum_discussions', ['forum' => $forum->id]);
+
+        // Delete all files from the posts.
+        $fs = get_file_storage();
+        $fs->delete_area_files($context->id, 'mod_forum', 'post');
+
+        // Delete all ratings in the context.
+        \core_rating\privacy\provider::delete_ratings($context, 'mod_forum', 'post');
+
+        // Delete all Tags.
+        \core_tag\privacy\provider::delete_item_tags($context, 'mod_forum', 'forum_posts');
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist    $contextlist    The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+        $user = $contextlist->get_user();
+        $userid = $user->id;
+        foreach ($contextlist as $context) {
+            // Get the course module.
+            $cm = $DB->get_record('course_modules', ['id' => $context->instanceid]);
+            $forum = $DB->get_record('forum', ['id' => $cm->instance]);
+
+            $DB->delete_records('forum_track_prefs', [
+                'forumid' => $forum->id,
+                'userid' => $userid,
+            ]);
+            $DB->delete_records('forum_subscriptions', [
+                'forum' => $forum->id,
+                'userid' => $userid,
+            ]);
+            $DB->delete_records('forum_read', [
+                'forumid' => $forum->id,
+                'userid' => $userid,
+            ]);
+
+            // Delete all discussion items.
+            $DB->delete_records_select(
+                'forum_queue',
+                "userid = :userid AND discussionid IN (SELECT id FROM {forum_discussions} WHERE forum = :forum)",
+                [
+                    'userid' => $userid,
+                    'forum' => $forum->id,
+                ]
+            );
+
+            $DB->delete_records('forum_discussion_subs', [
+                'forum' => $forum->id,
+                'userid' => $userid,
+            ]);
+
+            $uniquediscussions = $DB->get_recordset('forum_discussions', [
+                    'forum' => $forum->id,
+                    'userid' => $userid,
+                ]);
+
+            foreach ($uniquediscussions as $discussion) {
+                // Do not delete discussion or forum posts.
+                // Instead update them to reflect that the content has been deleted.
+                $postsql = "userid = :userid AND discussion IN (SELECT id FROM {forum_discussions} WHERE forum = :forum)";
+                $postidsql = "SELECT fp.id FROM {forum_posts} fp WHERE {$postsql}";
+                $postparams = [
+                    'forum' => $forum->id,
+                    'userid' => $userid,
+                ];
+
+                // Update the subject.
+                $DB->set_field_select('forum_posts', 'subject', '', $postsql, $postparams);
+
+                // Update the subject and its format.
+                $DB->set_field_select('forum_posts', 'message', '', $postsql, $postparams);
+                $DB->set_field_select('forum_posts', 'messageformat', FORMAT_PLAIN, $postsql, $postparams);
+
+                // Mark the post as deleted.
+                $DB->set_field_select('forum_posts', 'deleted', 1, $postsql, $postparams);
+
+                // Note: Do _not_ delete ratings of other users. Only delete ratings on the users own posts.
+                // Ratings are aggregate fields and deleting the rating of this post will have an effect on the rating
+                // of any post.
+                \core_rating\privacy\provider::delete_ratings_select($context, 'mod_forum', 'post',
+                        "IN ($postidsql)", $postparams);
+
+                // Delete all Tags.
+                \core_tag\privacy\provider::delete_item_tags_select($context, 'mod_forum', 'forum_posts',
+                        "IN ($postidsql)", $postparams);
+            }
+
+            $uniquediscussions->close();
+
+            // Delete all files from the posts.
+            $fs = get_file_storage();
+            $fs->delete_area_files($context->id, 'mod_forum', 'post');
+        }
+    }
+}
diff --git a/mod/forum/classes/privacy/subcontext_info.php b/mod/forum/classes/privacy/subcontext_info.php
new file mode 100644 (file)
index 0000000..0a02743
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for mod_forum.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\privacy;
+
+use \core_privacy\request\approved_contextlist;
+use \core_privacy\request\writer;
+use \core_privacy\metadata\item_collection;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Subcontext helper trait.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait subcontext_info {
+    /**
+     * Get the discussion part of the subcontext.
+     *
+     * @param   \stdClass   $discussion The discussion
+     * @return  array
+     */
+    protected static function get_discussion_area(\stdClass $discussion) : Array {
+        $pathparts = [];
+        if (!empty($discussion->groupname)) {
+            $pathparts[] = get_string('groups');
+            $pathparts[] = $discussion->groupname;
+        }
+
+        $parts = [
+            $discussion->id,
+            $discussion->name,
+        ];
+
+        $discussionname = implode('-', $parts);
+
+        $pathparts[] = get_string('discussions', 'mod_forum');
+        $pathparts[] = $discussionname;
+
+        return $pathparts;
+    }
+
+    /**
+     * Get the post part of the subcontext.
+     *
+     * @param   \stdClass   $post The post.
+     * @return  array
+     */
+    protected static function get_post_area(\stdClass $post) : Array {
+        $parts = [
+            $post->created,
+            $post->subject,
+            $post->id,
+        ];
+        $area[] = implode('-', $parts);
+
+        return $area;
+    }
+
+    /**
+     * Get the parent subcontext for the supplied forum, discussion, and post combination.
+     *
+     * @param   \stdClass   $post The post.
+     * @return  array
+     */
+    protected static function get_post_area_for_parent(\stdClass $post) {
+        global $DB;
+
+        $subcontext = [];
+        if ($parent = $DB->get_record('forum_posts', ['id' => $post->parent], 'id, created, subject')) {
+            $subcontext = array_merge($subcontext, static::get_post_area($parent));
+        }
+        $subcontext = array_merge($subcontext, static::get_post_area($post));
+
+        return $subcontext;
+    }
+
+    /**
+     * Get the subcontext for the supplied forum, discussion, and post combination.
+     *
+     * @param   \stdClass   $forum The forum.
+     * @param   \stdClass   $discussion The discussion
+     * @param   \stdClass   $post The post.
+     * @return  array
+     */
+    protected static function get_subcontext($forum, $discussion = null, $post = null) {
+        $subcontext = [];
+        if (null !== $discussion) {
+            $subcontext += self::get_discussion_area($discussion);
+
+            if (null !== $post) {
+                $subcontext[] = get_string('posts', 'mod_forum');
+                $subcontext = array_merge($subcontext, static::get_post_area_for_parent($post));
+            }
+        }
+
+        return $subcontext;
+
+    }
+}
index cbd1906..9c0c99b 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/forum/db" VERSION="20171012" COMMENT="XMLDB file for Moodle mod/forum"
+<XMLDB PATH="mod/forum/db" VERSION="20180329" COMMENT="XMLDB file for Moodle mod/forum"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -80,6 +80,7 @@
         <FIELD NAME="attachment" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="totalscore" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="mailnow" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="deleted" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index f523a38..d8961fd 100644 (file)
@@ -101,5 +101,20 @@ function xmldb_forum_upgrade($oldversion) {
     // Automatically generated Moodle v3.4.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2018032900) {
+
+        // Define field deleted to be added to forum_posts.
+        $table = new xmldb_table('forum_posts');
+        $field = new xmldb_field('deleted', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'mailnow');
+
+        // Conditionally launch add field deleted.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Forum savepoint reached.
+        upgrade_mod_savepoint(true, 2018032900, 'forum');
+    }
+
     return true;
 }
index 2366e45..f7207a5 100644 (file)
@@ -219,7 +219,7 @@ if (! $post = forum_get_post_full($parent)) {
     print_error("notexists", 'forum', "$CFG->wwwroot/mod/forum/view.php?f=$forum->id");
 }
 
-if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) {
+if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm, false)) {
     print_error('noviewdiscussionspermission', 'forum', "$CFG->wwwroot/mod/forum/view.php?id=$forum->id");
 }
 
index 9bdb2d4..a92d3bd 100644 (file)
@@ -249,8 +249,7 @@ class mod_forum_external extends external_api {
         $allposts = forum_get_all_discussion_posts($discussion->id, $sort, $forumtracked);
 
         foreach ($allposts as $post) {
-
-            if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) {
+            if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm, false)) {
                 $warning = array();
                 $warning['item'] = 'post';
                 $warning['itemid'] = $post->id;
@@ -275,6 +274,23 @@ class mod_forum_external extends external_api {
                 $post->children = array();
             }
 
+            if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) {
+                // The post is available, but has been marked as deleted.
+                // It will still be available but filled with a placeholder.
+                $post->userid = null;
+                $post->userfullname = null;
+                $post->userpictureurl = null;
+
+                $post->subject = get_string('privacy:request:delete:post:subject', 'mod_forum');
+                $post->message = get_string('privacy:request:delete:post:message', 'mod_forum');
+
+                $post->deleted = true;
+                $posts[] = $post;
+
+                continue;
+            }
+            $post->deleted = false;
+
             if (forum_is_author_hidden($post, $forum)) {
                 $post->userid = null;
                 $post->userfullname = null;
@@ -346,7 +362,8 @@ class mod_forum_external extends external_api {
                                 'canreply' => new external_value(PARAM_BOOL, 'The user can reply to posts?'),
                                 'postread' => new external_value(PARAM_BOOL, 'The post was read'),
                                 'userfullname' => new external_value(PARAM_TEXT, 'Post author full name'),
-                                'userpictureurl' => new external_value(PARAM_URL, 'Post author picture.', VALUE_OPTIONAL)
+                                'userpictureurl' => new external_value(PARAM_URL, 'Post author picture.', VALUE_OPTIONAL),
+                                'deleted' => new external_value(PARAM_BOOL, 'This post has been removed.'),
                             ), 'post'
                         )
                     ),
@@ -850,7 +867,8 @@ class mod_forum_external extends external_api {
         $post->messageformat = FORMAT_HTML;   // Force formatting for now.
         $post->messagetrust = trusttext_trusted($context);
         $post->itemid = $options['inlineattachmentsid'];
-        $post->attachments   = $options['attachmentsid'];
+        $post->attachments = $options['attachmentsid'];
+        $post->deleted = 0;
         $fakemform = $post->attachments;
         if ($postid = forum_add_new_post($post, $fakemform)) {
 
index abb8410..3b5884e 100644 (file)
@@ -429,6 +429,56 @@ $string['postsmadebyuserincourse'] = 'Posts made by {$a->fullname} in {$a->cours
 $string['posttoforum'] = 'Post to forum';
 $string['postupdated'] = 'Your post was updated';
 $string['potentialsubscribers'] = 'Potential subscribers';
+$string['privacy:digesttypenone'] = 'We do not hold any data relating to a preferred forum digest type for this forum.';
+$string['privacy:digesttypepreference'] = 'You have chosen to receive the following forum digest type: "{$a->type}".';
+$string['privacy:discussionsubscriptionpreference'] = 'You have chosen the following discussion subscription preference for this forum: "{$a->preference}"';
+$string['privacy:metadata:core_tag'] = 'The forum makes use of the tag subsystem to support tagging of posts.';
+$string['privacy:metadata:core_rating'] = 'The forum makes use of the rating subsystem to support the rating of posts.';
+$string['privacy:metadata:forum_digests'] = 'Information about the digest preferences for each forum.';
+$string['privacy:metadata:forum_digests:forum'] = 'The forum subscribed to.';
+$string['privacy:metadata:forum_digests:maildigest'] = 'The digest preference.';
+$string['privacy:metadata:forum_digests:userid'] = 'The ID of the user with the digest preference.';
+$string['privacy:metadata:forum_discussion_subs'] = 'Information about the subscriptions to individual forum discussions.';
+$string['privacy:metadata:forum_discussion_subs:discussionid'] = 'The ID of the discussion that was subscribed to.';
+$string['privacy:metadata:forum_discussion_subs:preference'] = 'The start time of the subscription.';
+$string['privacy:metadata:forum_discussion_subs:userid'] = 'The ID of the user with the discussion subscription.';
+$string['privacy:metadata:forum_discussions'] = 'Information about the individual forum discussions that a user has created.';
+$string['privacy:metadata:forum_discussions:assessed'] = 'TODOD - what does this field store';
+$string['privacy:metadata:forum_discussions:name'] = 'The name of the discussion, as chosen by the author.';
+$string['privacy:metadata:forum_discussions:timemodified'] = 'The time that the discussion was last modified.';
+$string['privacy:metadata:forum_discussions:userid'] = 'The ID of the user who created the discussion';
+$string['privacy:metadata:forum_discussions:usermodified'] = 'The ID of the user who last modified the discussion in some way.';
+$string['privacy:metadata:forum_posts'] = 'Information about the digest preferences for each forum.';
+$string['privacy:metadata:forum_posts:created'] = 'The time that the post was created.';
+$string['privacy:metadata:forum_posts:discussion'] = 'The discussion that the post is in.';
+$string['privacy:metadata:forum_posts:message'] = 'The message of the forum post.';
+$string['privacy:metadata:forum_posts:modified'] = 'The time that the post was last modified.';
+$string['privacy:metadata:forum_posts:parent'] = 'The parent post that was replied to.';
+$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_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.';
+$string['privacy:metadata:forum_read:lastread'] = 'The most recent time that the post was read.';
+$string['privacy:metadata:forum_read:postid'] = 'The post that was read.';
+$string['privacy:metadata:forum_read:userid'] = 'The ID of the user that this record relates to.';
+$string['privacy:metadata:forum_subscriptions'] = 'Information about which forums the user has subscribed to.';
+$string['privacy:metadata:forum_subscriptions:forum'] = 'The forum that was subscribed to.';
+$string['privacy:metadata:forum_subscriptions:userid'] = 'The ID of the user that this forum subscription relates to.';
+$string['privacy:metadata:forum_track_prefs'] = 'Information about which forums the user has chosen to track post reads for.';
+$string['privacy:metadata:forum_track_prefs:forumid'] = 'The forum that has read tracking enabled.';
+$string['privacy:metadata:forum_track_prefs:userid'] = 'The ID of the user that this forum tracking preference relates to.';
+$string['privacy:metadata:preference:autosubscribe'] = 'Whether to subscribe to discussions when replying to posts within them.';
+$string['privacy:metadata:preference:maildigest'] = 'The site-wide mail digest preference.';
+$string['privacy:metadata:preference:markasreadonnotification'] = 'Whether to mark forum posts as read when receiving them as messages.';
+$string['privacy:metadata:preference:trackforums'] = 'Whether to enable read tracking.';
+$string['privacy:postwasread'] = 'This post was first read on {$a->firstread} and most recently read on {$a->lastread}';
+$string['privacy:readtrackingdisabled'] = 'You have chosen to not track which posts that you have read within this forum.';
+$string['privacy:request:delete:discussion:name'] = 'Delete at the request of the author';
+$string['privacy:request:delete:post:message'] = 'The content of this post has been deleted at the request of its author.';
+$string['privacy:request:delete:post:subject'] = 'Delete at the request of the author';
+$string['privacy:subscribedtoforum'] = 'You are subscribed to this forum.';
 $string['processingdigest'] = 'Processing email digest for user {$a}';
 $string['processingpost'] = 'Processing post {$a}';
 $string['prune'] = 'Split';
@@ -555,3 +605,5 @@ $string['warnformorepost'] = 'Warning! There is more than one discussion in this
 $string['yournewquestion'] = 'Your new question';
 $string['yournewtopic'] = 'Your new discussion topic';
 $string['yourreply'] = 'Your reply';
+$string['forumsubjectdeleted'] = 'This forum post has been removed';
+$string['forumbodydeleted'] = 'The content of this forum post has been removed and can no longer be accessed.';
index 43f5ca4..8b90059 100644 (file)
@@ -1442,6 +1442,7 @@ function forum_print_overview($courses,&$htmlarray) {
                 .'FROM {forum_discussions} d '
                 .'JOIN {forum_posts} p ON p.discussion = d.id '
                 ."WHERE ($coursessql) "
+                .'AND p.deleted <> 1 '
                 .'AND p.userid != ? '
                 .'AND (d.timestart <= ? AND (d.timeend = 0 OR d.timeend > ?)) '
                 .'GROUP BY d.id, d.forum, d.course, d.groupid '
@@ -1469,7 +1470,7 @@ function forum_print_overview($courses,&$htmlarray) {
         $sql = 'SELECT d.forum,d.course,COUNT(p.id) AS count '.
             ' FROM {forum_posts} p '.
             ' JOIN {forum_discussions} d ON p.discussion = d.id '.
-            ' LEFT JOIN {forum_read} r ON r.postid = p.id AND r.userid = ? WHERE (';
+            ' LEFT JOIN {forum_read} r ON r.postid = p.id AND r.userid = ? WHERE p.deleted <> 1 AND (';
         $params = array($USER->id);
 
         foreach ($trackingforums as $track) {
@@ -1573,7 +1574,7 @@ function forum_print_recent_activity($course, $viewfullnames, $timestart) {
                                               JOIN {forum_discussions} d ON d.id = p.discussion
                                               JOIN {forum} f             ON f.id = d.forum
                                               JOIN {user} u              ON u.id = p.userid
-                                        WHERE p.created > ? AND f.course = ?
+                                        WHERE p.created > ? AND f.course = ? AND p.deleted <> 1
                                      ORDER BY p.id ASC", array($timestart, $course->id))) { // order by initial posting date
          return false;
     }
@@ -3182,7 +3183,8 @@ function forum_print_post($post, $discussion, $forum, &$cm, $course, $ownpost=fa
         $postisread = forum_tp_is_post_read($USER->id, $post);
     }
 
-    if (!forum_user_can_see_post($forum, $discussion, $post, NULL, $cm)) {
+    if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm, false)) {
+        // Do _not_ check the deleted flag - we need to display a different UI.
         $output = '';
         if (!$dummyifcantsee) {
             if ($return) {
@@ -3221,6 +3223,62 @@ function forum_print_post($post, $discussion, $forum, &$cm, $course, $ownpost=fa
         return;
     }
 
+    if (!empty($post->deleted)) {
+        // Note: Posts marked as deleted are still returned by the above forum_user_can_post because it is required for
+        // nesting of posts.
+        $output = '';
+        if (!$dummyifcantsee) {
+            if ($return) {
+                return $output;
+            }
+            echo $output;
+            return;
+        }
+        $output .= html_writer::tag('a', '', [
+                'id' => "p{$post->id}",
+            ]);
+        $output .= html_writer::start_tag('div', [
+                'class' => 'forumpost clearfix',
+                'role' => 'region',
+                'aria-label' => get_string('forumbodydeleted', 'forum'),
+            ]);
+
+        $output .= html_writer::start_tag('div', array('class' => 'row header'));
+        $output .= html_writer::tag('div', '', array('class' => 'left picture'));
+
+        $classes = ['topic'];
+        if (!empty($post->parent)) {
+            $classes[] = 'starter';
+        }
+        $output .= html_writer::start_tag('div', ['class' => implode(' ', $classes)]);
+
+        // Subject.
+        $output .= html_writer::tag('div', get_string('forumsubjectdeleted', 'forum'), [
+                'class' => 'subject',
+                'role' => 'header',
+            ]);
+
+        // Author.
+        $output .= html_writer::tag('div', '', [
+                'class' => 'author',
+                'role' => 'header',
+            ]);
+
+        $output .= html_writer::end_tag('div');
+        $output .= html_writer::end_tag('div'); // End row.
+        $output .= html_writer::start_tag('div', ['class' => 'row']);
+        $output .= html_writer::tag('div', '&nbsp;', ['class' => 'left side']); // Groups.
+        $output .= html_writer::tag('div', get_string('forumbodydeleted', 'forum'), ['class' => 'content']); // Content.
+        $output .= html_writer::end_tag('div'); // End row.
+        $output .= html_writer::end_tag('div'); // End forumpost.
+
+        if ($return) {
+            return $output;
+        }
+        echo $output;
+        return;
+    }
+
     if (empty($str)) {
         $str = new stdClass;
         $str->edit         = get_string('edit', 'forum');
@@ -3487,7 +3545,7 @@ function forum_print_post($post, $discussion, $forum, &$cm, $course, $ownpost=fa
     }
 
     // Output ratings
-    if (!empty($post->rating)) {
+    if (!empty($post->deleted) && !empty($post->rating)) {
         $output .= html_writer::tag('div', $OUTPUT->render($post->rating), array('class'=>'forum-post-rating'));
     }
 
@@ -3714,6 +3772,7 @@ function mod_forum_rating_can_see_item_ratings($params) {
     if (!forum_user_can_see_post($forum, $discussion, $post, $USER, $cm)) {
         return false;
     }
+
     return true;
 }
 
@@ -4735,6 +4794,7 @@ function forum_delete_post($post, $children, $course, $cm, $forum, $skipcompleti
                 'forumtype' => $forum->type,
             )
         );
+        $post->deleted = 1;
         if ($post->userid !== $USER->id) {
             $params['relateduserid'] = $post->userid;
         }
@@ -5251,21 +5311,19 @@ function forum_user_can_see_discussion($forum, $discussion, $context, $user=NULL
 }
 
 /**
- * @global object
- * @global object
- * @param object $forum
- * @param object $discussion
- * @param object $post
- * @param object $user
- * @param object $cm
- * @return bool
+ * Check whether a user can see the specified post.
+ *
+ * @param   \stdClass $forum The forum to chcek
+ * @param   \stdClass $discussion The discussion the post is in
+ * @param   \stdClass $post The post in question
+ * @param   \stdClass $user The user to test - if not specified, the current user is checked.
+ * @param   \stdClass $cm The Course Module that the forum is in (required).
+ * @param   bool      $checkdeleted Whether to check the deleted flag on the post.
+ * @return  bool
  */
-function forum_user_can_see_post($forum, $discussion, $post, $user=NULL, $cm=NULL) {
+function forum_user_can_see_post($forum, $discussion, $post, $user = null, $cm = null, $checkdeleted = true) {
     global $CFG, $USER, $DB;
 
-    // Context used throughout function.
-    $modcontext = context_module::instance($cm->id);
-
     // retrieve objects (yuk)
     if (is_numeric($forum)) {
         debugging('missing full forum', DEBUG_DEVELOPER);
@@ -5291,6 +5349,10 @@ function forum_user_can_see_post($forum, $discussion, $post, $user=NULL, $cm=NUL
         $post->id = $post->parent;
     }
 
+    if ($checkdeleted && !empty($post->deleted)) {
+        return false;
+    }
+
     if (!$cm) {
         debugging('missing cm', DEBUG_DEVELOPER);
         if (!$cm = get_coursemodule_from_instance('forum', $forum->id, $forum->course)) {
@@ -5298,6 +5360,9 @@ function forum_user_can_see_post($forum, $discussion, $post, $user=NULL, $cm=NUL
         }
     }
 
+    // Context used throughout function.
+    $modcontext = context_module::instance($cm->id);
+
     if (empty($user) || empty($user->id)) {
         $user = $USER;
     }
@@ -5824,13 +5889,23 @@ function forum_print_posts_threaded($course, &$cm, $forum, $discussion, $parent,
                 forum_print_post($post, $discussion, $forum, $cm, $course, $ownpost, $reply, $link,
                                      '', '', $postread, true, $forumtracked);
             } else {
-                if (!forum_user_can_see_post($forum, $discussion, $post, NULL, $cm)) {
-                    echo "</div>\n";
-                    continue;
+                if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm, true)) {
+                    if (forum_user_can_see_post($forum, $discussion, $post, null, $cm, false)) {
+                        // This post has been deleted but still exists and may have children.
+                        $subject = get_string('privacy:request:delete:post:subject', 'mod_forum');
+                        $byline = '';
+                    } else {
+                        // The user can't see this post at all.
+                        echo "</div>\n";
+                        continue;
+                    }
+                } else {
+                    $by = new stdClass();
+                    $by->name = fullname($post, $canviewfullnames);
+                    $by->date = userdate($post->modified);
+                    $byline = ' ' . get_string("bynameondate", "forum", $by);
+                    $subject = format_string($post->subject, true);
                 }
-                $by = new stdClass();
-                $by->name = fullname($post, $canviewfullnames);
-                $by->date = userdate($post->modified);
 
                 if ($forumtracked) {
                     if (!empty($post->postread)) {
@@ -5841,9 +5916,14 @@ function forum_print_posts_threaded($course, &$cm, $forum, $discussion, $parent,
                 } else {
                     $style = '<span class="forumthread">';
                 }
-                echo $style."<a name=\"$post->id\"></a>".
-                     "<a href=\"discuss.php?d=$post->discussion&amp;parent=$post->id\">".format_string($post->subject,true)."</a> ";
-                print_string("bynameondate", "forum", $by);
+
+                echo $style;
+                echo "<a name='{$post->id}'></a>";
+                echo html_writer::link(new moodle_url('/mod/forum/discuss.php', [
+                        'd' => $post->discussion,
+                        'parent' => $post->id,
+                    ]), $subject);
+                echo $byline;
                 echo "</span>";
             }
 
index b446d27..6f57004 100644 (file)
@@ -95,6 +95,12 @@ class forum_portfolio_caller extends portfolio_module_caller_base {
         $fs = get_file_storage();
         if ($this->post) {
             if ($this->attachment) {
+                // Make sure the requested file belongs to this post.
+                $file = $fs->get_file_by_id($this->attachment);
+                if ($file->get_contextid() != $this->modcontext->id
+                    || $file->get_itemid() != $this->post->id) {
+                    throw new portfolio_caller_exception('filenotfound');
+                }
                 $this->set_file_and_format_data($this->attachment);
             } else {
                 $attach = $fs->get_area_files($this->modcontext->id, 'mod_forum', 'attachment', $this->post->id, 'timemodified', false);
index 88acf30..df0a726 100644 (file)
@@ -791,6 +791,7 @@ if ($mform_post->is_cancelled()) {
         $addpost = $fromform;
         $addpost->forum=$forum->id;
         if ($fromform->id = forum_add_new_post($addpost, $mform_post)) {
+            $fromform->deleted = 0;
             $subscribemessage = forum_post_subscription($fromform, $forum, $discussion);
 
             if (!empty($fromform->mailnow)) {
index 4f41397..f360627 100644 (file)
@@ -189,7 +189,7 @@ function forum_rss_feed_discussions_sql($forum, $cm, $newsince=0) {
               FROM {forum_discussions} d
                    JOIN {forum_posts} p ON p.discussion = d.id
                    JOIN {user} u ON p.userid = u.id
-             WHERE d.forum = {$forum->id} AND p.parent = 0
+             WHERE d.forum = {$forum->id} AND p.parent = 0 AND p.deleted <> 0
                    $timelimit $groupselect $newsince
           ORDER BY $forumsort";
     return array($sql, $params);
@@ -243,7 +243,7 @@ function forum_rss_feed_posts_sql($forum, $cm, $newsince=0) {
                {forum_posts} p,
                {user} u
             WHERE d.forum = {$forum->id} AND
-                p.discussion = d.id AND
+                p.discussion = d.id AND p.deleted <> 0 AND
                 u.id = p.userid $newsince
                 $groupselect
             ORDER BY p.created desc";
@@ -339,10 +339,17 @@ function forum_rss_feed_contents($forum, $sql, $params, $context) {
                 $message = get_string('forumbodyhidden', 'forum');
                 $item->author = get_string('forumauthorhidden', 'forum');
             } else if (!$isdiscussion && !forum_user_can_see_post($forum, $discussion, $post, $USER, $cm)) {
-                // This is a post which the user has no permission to view
-                $item->title = get_string('forumsubjecthidden', 'forum');
-                $message = get_string('forumbodyhidden', 'forum');
-                $item->author = get_string('forumauthorhidden', 'forum');
+                if (forum_user_can_see_post($forum, $discussion, $post, $USER, $cm, false)) {
+                    // This is a post which the user has no permission to view.
+                    $item->title = get_string('forumsubjecthidden', 'forum');
+                    $message = get_string('forumbodyhidden', 'forum');
+                    $item->author = get_string('forumauthorhidden', 'forum');
+                } else {
+                    // This is a post which has been deleted.
+                    $item->title = get_string('privacy:request:delete:post:subject', 'mod_forum');
+                    $message = get_string('privacy:request:delete:post:subject', 'mod_forum');
+                    $item->author = get_string('forumauthorhidden', 'forum');
+                }
             } else {
                 // The user must have permission to view
                 if ($isdiscussion && !empty($rec->discussionname)) {
index cfe301f..c095db7 100644 (file)
@@ -308,7 +308,8 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'canreply' => true,
             'postread' => false,
             'userfullname' => fullname($user3),
-            'userpictureurl' => ''
+            'userpictureurl' => '',
+            'deleted' => false,
         );
 
         $expectedposts['posts'][] = array(
@@ -343,7 +344,8 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'canreply' => true,
             'postread' => false,
             'userfullname' => fullname($user2),
-            'userpictureurl' => ''
+            'userpictureurl' => '',
+            'deleted' => false,
         );
 
         // Test a discussion with two additional posts (total 3 posts).
@@ -416,6 +418,86 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         }
     }
 
+    /**
+     * Test get forum posts
+     */
+    public function test_mod_forum_get_forum_discussion_posts_deleted() {
+        global $CFG, $PAGE;
+
+        $this->resetAfterTest(true);
+        $generator = self::getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Create a course and enrol some users in it.
+        $course1 = self::getDataGenerator()->create_course();
+
+        // Create users.
+        $user1 = self::getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $user2 = self::getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+
+        // Set the first created user to the test user.
+        self::setUser($user1);
+
+        // Create test data.
+        $forum1 = self::getDataGenerator()->create_module('forum', (object) [
+                'course' => $course1->id,
+            ]);
+        $forum1context = context_module::instance($forum1->cmid);
+
+        // Add discussions to the forum.
+        $discussion = $generator->create_discussion((object) [
+                'course' => $course1->id,
+                'userid' => $user1->id,
+                'forum' => $forum1->id,
+            ]);
+
+        $discussion2 = $generator->create_discussion((object) [
+                'course' => $course1->id,
+                'userid' => $user2->id,
+                'forum' => $forum1->id,
+            ]);
+
+        // Add replies to the discussion.
+        $discussionreply1 = $generator->create_post((object) [
+                'discussion' => $discussion->id,
+                'parent' => $discussion->firstpost,
+                'userid' => $user2->id,
+            ]);
+        $discussionreply2 = $generator->create_post((object) [
+                'discussion' => $discussion->id,
+                'parent' => $discussionreply1->id,
+                'userid' => $user2->id,
+                'subject' => '',
+                'message' => '',
+                'messageformat' => FORMAT_PLAIN,
+                'deleted' => 1,
+            ]);
+        $discussionreply3 = $generator->create_post((object) [
+                'discussion' => $discussion->id,
+                'parent' => $discussion->firstpost,
+                'userid' => $user2->id,
+            ]);
+
+        // Test where some posts have been marked as deleted.
+        $posts = mod_forum_external::get_forum_discussion_posts($discussion->id, 'modified', 'DESC');
+        $posts = external_api::clean_returnvalue(mod_forum_external::get_forum_discussion_posts_returns(), $posts);
+        $deletedsubject = get_string('privacy:request:delete:post:subject', 'mod_forum');
+        $deletedmessage = get_string('privacy:request:delete:post:message', 'mod_forum');
+
+        foreach ($posts['posts'] as $post) {
+            if ($post['id'] == $discussionreply2->id) {
+                $this->assertTrue($post['deleted']);
+                $this->assertEquals($deletedsubject, $post['subject']);
+                $this->assertEquals($deletedmessage, $post['message']);
+            } else {
+                $this->assertFalse($post['deleted']);
+                $this->assertNotEquals($deletedsubject, $post['subject']);
+                $this->assertNotEquals($deletedmessage, $post['message']);
+            }
+        }
+    }
+
     /**
      * Test get forum posts (qanda forum)
      */
diff --git a/mod/forum/tests/helper.php b/mod/forum/tests/helper.php
new file mode 100644 (file)
index 0000000..163c454
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Helper functions used by several tests.
+ *
+ * @package    mod_forum
+ * @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();
+
+global $CFG;
+
+/**
+ * Helper functions used by several tests.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait helper {
+
+    /**
+     * Helper to create the required number of users in the specified
+     * course.
+     * Users are enrolled as students.
+     *
+     * @param stdClass $course The course object
+     * @param integer $count The number of users to create
+     * @return array The users created
+     */
+    protected function helper_create_users($course, $count) {
+        $users = array();
+
+        for ($i = 0; $i < $count; $i++) {
+            $user = $this->getDataGenerator()->create_user();
+            $this->getDataGenerator()->enrol_user($user->id, $course->id);
+            $users[] = $user;
+        }
+
+        return $users;
+    }
+
+    /**
+     * Create a new discussion and post within the specified forum, as the
+     * specified author.
+     *
+     * @param stdClass $forum The forum to post in
+     * @param stdClass $author The author to post as
+     * @return array An array containing the discussion object, and the post object
+     */
+    protected function helper_post_to_forum($forum, $author) {
+        global $DB;
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Create a discussion in the forum, and then add a post to that discussion.
+        $record = new stdClass();
+        $record->course = $forum->course;
+        $record->userid = $author->id;
+        $record->forum = $forum->id;
+        $discussion = $generator->create_discussion($record);
+
+        // Retrieve the post which was created by create_discussion.
+        $post = $DB->get_record('forum_posts', array('discussion' => $discussion->id));
+
+        return array($discussion, $post);
+    }
+
+    /**
+     * Update the post time for the specified post by $factor.
+     *
+     * @param stdClass $post The post to update
+     * @param int $factor The amount to update by
+     */
+    protected function helper_update_post_time($post, $factor) {
+        global $DB;
+
+        // Update the post to have a created in the past.
+        $DB->set_field('forum_posts', 'created', $post->created + $factor, array('id' => $post->id));
+    }
+
+    /**
+     * Update the subscription time for the specified user/discussion by $factor.
+     *
+     * @param stdClass $user The user to update
+     * @param stdClass $discussion The discussion to update for this user
+     * @param int $factor The amount to update by
+     */
+    protected function helper_update_subscription_time($user, $discussion, $factor) {
+        global $DB;
+
+        $sub = $DB->get_record('forum_discussion_subs', array('userid' => $user->id, 'discussion' => $discussion->id));
+
+        // Update the subscription to have a preference in the past.
+        $DB->set_field('forum_discussion_subs', 'preference', $sub->preference + $factor, array('id' => $sub->id));
+    }
+
+    /**
+     * Create a new post within an existing discussion, as the specified author.
+     *
+     * @param stdClass $forum The forum to post in
+     * @param stdClass $discussion The discussion to post in
+     * @param stdClass $author The author to post as
+     * @return stdClass The forum post
+     */
+    protected function helper_post_to_discussion($forum, $discussion, $author) {
+        global $DB;
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Add a post to the discussion.
+        $record = new stdClass();
+        $record->course = $forum->course;
+        $strre = get_string('re', 'forum');
+        $record->subject = $strre . ' ' . $discussion->subject;
+        $record->userid = $author->id;
+        $record->forum = $forum->id;
+        $record->discussion = $discussion->id;
+        $record->mailnow = 1;
+
+        $post = $generator->create_post($record);
+
+        return $post;
+    }
+
+    /**
+     * Create a new post within an existing discussion, as the specified author.
+     *
+     * @param stdClass $parent The post being replied to
+     * @param stdClass $author The author to post as
+     * @return stdClass The forum post
+     */
+    protected function helper_reply_to_post($parent, $author) {
+        global $DB;
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Add a post to the discussion.
+        $strre = get_string('re', 'forum');
+        $record = (object) [
+            'discussion' => $parent->discussion,
+            'parent' => $parent->id,
+            'userid' => $author->id,
+            'mailnow' => 1,
+            'subject' => $strre . ' ' . $parent->subject,
+        ];
+
+        $post = $generator->create_post($record);
+
+        return $post;
+    }
+}
index c1508f7..e15ceaa 100644 (file)
@@ -29,6 +29,7 @@ global $CFG;
 
 class mod_forum_mail_testcase extends advanced_testcase {
 
+
     protected $helper;
 
     public function setUp() {
diff --git a/mod/forum/tests/portfolio_caller_test.php b/mod/forum/tests/portfolio_caller_test.php
new file mode 100644 (file)
index 0000000..572068e
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The portfolio forum tests.
+ *
+ * @package    mod_forum
+ * @copyright  2018 onwards Totara Learning Solutions LTD {@link http://www.totaralms.com/}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Brendan Cox <brendan.cox@totaralearning.com>
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class mod_forum_portfolio_caller_testcase
+ *
+ * Tests behaviour of the forum_portfolio_caller class.
+ */
+class mod_forum_portfolio_caller_testcase extends advanced_testcase {
+
+    /**
+     * Ensure that a file will be loaded in an instance of the caller when supplied valid and
+     * accessible post and attachment file ids.
+     */
+    public function test_file_in_user_post_is_loaded() {
+        global $CFG;
+        require_once($CFG->dirroot . '/mod/forum/locallib.php');
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id));
+        $context = context_module::instance($forum->cmid);
+
+        /* @var mod_forum_generator $forumgenerator */
+        $forumgenerator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+        $discussion = $forumgenerator->create_discussion(
+            array(
+                'course' => $course->id,
+                'forum' => $forum->id,
+                'userid' => $user->id,
+                'attachment' => 1
+            )
+        );
+
+        $fs = get_file_storage();
+        $dummy = (object) array(
+            'contextid' => $context->id,
+            'component' => 'mod_forum',
+            'filearea' => 'attachment',
+            'itemid' => $discussion->firstpost,
+            'filepath' => '/',
+            'filename' => 'myassignmnent.pdf'
+        );
+        $firstpostfile = $fs->create_file_from_string($dummy, 'Content of ' . $dummy->filename);
+
+        $caller = new forum_portfolio_caller(array(
+            'postid' => $discussion->firstpost,
+            'attachment' => $firstpostfile->get_id()
+        ));
+
+        $caller->load_data();
+        $this->assertEquals($caller->get_sha1_file(), $firstpostfile->get_contenthash());
+    }
+
+    /**
+     * Ensure that files will not be loaded if the supplied attachment id is for a file that is not attached to
+     * the supplied post id.
+     */
+    public function test_file_not_in_user_post_not_loaded() {
+        global $CFG;
+        require_once($CFG->dirroot . '/mod/forum/locallib.php');
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id));
+        $context = context_module::instance($forum->cmid);
+
+        /* @var mod_forum_generator $forumgenerator */
+        $forumgenerator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+        $discussion = $forumgenerator->create_discussion(
+            array(
+                'course' => $course->id,
+                'forum' => $forum->id,
+                'userid' => $user->id,
+                'attachment' => 1
+            )
+        );
+
+        $fs = get_file_storage();
+        $dummyone = (object) array(
+            'contextid' => $context->id,
+            'component' => 'mod_forum',
+            'filearea' => 'attachment',
+            'itemid' => $discussion->firstpost,
+            'filepath' => '/',
+            'filename' => 'myassignmnent.pdf'
+        );
+        $firstpostfile = $fs->create_file_from_string($dummyone, 'Content of ' . $dummyone->filename);
+
+        // Create a second post and add a file there.
+        $secondpost = $forumgenerator->create_post(
+            array(
+                'discussion' => $discussion->id,
+                'userid' => $user->id,
+                'attachment' => 1
+            )
+        );
+        $dummytwo = (object) array(
+            'contextid' => $context->id,
+            'component' => 'mod_forum',
+            'filearea' => 'attachment',
+            'itemid' => $secondpost->id,
+            'filepath' => '/',
+            'filename' => 'myotherthing.pdf'
+        );
+        $secondpostfile = $fs->create_file_from_string($dummytwo, 'Content of ' . $dummytwo->filename);
+
+        $caller = new forum_portfolio_caller(array(
+            'postid' => $discussion->firstpost,
+            'attachment' => $secondpostfile->get_id()
+        ));
+
+        $this->expectExceptionMessage('Sorry, the requested file could not be found');
+        $caller->load_data();
+    }
+}
diff --git a/mod/forum/tests/privacy_provider_test.php b/mod/forum/tests/privacy_provider_test.php
new file mode 100644 (file)
index 0000000..577a94e
--- /dev/null
@@ -0,0 +1,1224 @@
+<?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 forum implementation of the Privacy Provider API.
+ *
+ * @package    mod_forum
+ * @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();
+
+global $CFG;
+
+require_once(__DIR__ . '/helper.php');
+require_once($CFG->dirroot . '/rating/lib.php');
+
+use \mod_forum\privacy\provider;
+
+/**
+ * Tests for the forum implementation of the Privacy Provider API.
+ *
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_forum_privacy_provider_testcase extends \core_privacy\tests\provider_testcase {
+
+    // Include the privacy subcontext_info trait.
+    // This includes the subcontext builders.
+    use \mod_forum\privacy\subcontext_info;
+
+    // Include the mod_forum test helpers.
+    // This includes functions to create forums, users, discussions, and posts.
+    use helper;
+
+    // Include the privacy helper trait for the ratings API.
+    use \core_rating\phpunit\privacy_helper;
+
+    // Include the privacy helper trait for the tag API.
+    use \core_tag\tests\privacy_helper;
+
+    /**
+     * Test setUp.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Helper to assert that the forum data is correct.
+     *
+     * @param   object  $expected The expected data in the forum.
+     * @param   object  $actual The actual data in the forum.
+     */
+    protected function assert_forum_data($expected, $actual) {
+        // Exact matches.
+        $this->assertEquals(format_string($expected->name, true), $actual->name);
+    }
+
+    /**
+     * Helper to assert that the discussion data is correct.
+     *
+     * @param   object  $expected The expected data in the discussion.
+     * @param   object  $actual The actual data in the discussion.
+     */
+    protected function assert_discussion_data($expected, $actual) {
+        // Exact matches.
+        $this->assertEquals(format_string($expected->name, true), $actual->name);
+        $this->assertEquals(
+            \core_privacy\local\request\transform::yesno($expected->pinned),
+            $actual->pinned
+        );
+
+        $this->assertEquals(
+            \core_privacy\local\request\transform::datetime($expected->timemodified),
+            $actual->timemodified
+        );
+
+        $this->assertEquals(
+            \core_privacy\local\request\transform::datetime($expected->usermodified),
+            $actual->usermodified
+        );
+    }
+
+    /**
+     * Helper to assert that the post data is correct.
+     *
+     * @param   object  $expected The expected data in the post.
+     * @param   object  $actual The actual data in the post.
+     * @param   \core_privacy\local\request\writer  $writer The writer used
+     */
+    protected function assert_post_data($expected, $actual, $writer) {
+        // Exact matches.
+        $this->assertEquals(format_string($expected->subject, true), $actual->subject);
+
+        // The message should have been passed through the rewriter.
+        // Note: The testable rewrite_pluginfile_urls function in the ignores all items except the text.
+        $this->assertEquals(
+            $writer->rewrite_pluginfile_urls([], '', '', '', $expected->message),
+            $actual->message
+        );
+
+        $this->assertEquals(
+            \core_privacy\local\request\transform::datetime($expected->created),
+            $actual->created
+        );
+
+        $this->assertEquals(
+            \core_privacy\local\request\transform::datetime($expected->modified),
+            $actual->modified
+        );
+    }
+
+    /**
+     * Test that a user who is enrolled in a course, but who has never
+     * posted and has no other metadata stored will not have any link to
+     * that context.
+     */
+    public function test_user_has_never_posted() {
+        // Create a course, with a forum, our user under test, another user, and a discussion + post from the other user.
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list($user, $otheruser) = $this->helper_create_users($course, 2);
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $otheruser);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Test that no contexts were retrieved.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $contexts = $contextlist->get_contextids();
+        $this->assertCount(0, $contexts);
+
+        // Attempting to export data for this context should return nothing either.
+        $this->export_context_data_for_user($user->id, $context, 'mod_forum');
+
+        $writer = \core_privacy\local\request\writer::with_context($context);
+
+        // The provider should always export data for any context explicitly asked of it, but there should be no
+        // metadata, files, or discussions.
+        $this->assertEmpty($writer->get_data([get_string('discussions', 'mod_forum')]));
+        $this->assertEmpty($writer->get_all_metadata([]));
+        $this->assertEmpty($writer->get_files([]));
+    }
+
+    /**
+     * Test that a user who is enrolled in a course, and who has never
+     * posted and has subscribed to the forum will have relevant
+     * information returned.
+     */
+    public function test_user_has_never_posted_subscribed_to_forum() {
+        // Create a course, with a forum, our user under test, another user, and a discussion + post from the other user.
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list($user, $otheruser) = $this->helper_create_users($course, 2);
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $otheruser);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Subscribe the user to the forum.
+        \mod_forum\subscriptions::subscribe_user($user->id, $forum);
+
+        // Retrieve all contexts - only this context should be returned.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($context, $contextlist->current());
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($user->id, $context, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+
+        $subcontext = $this->get_subcontext($forum);
+        // There should be one item of metadata.
+        $this->assertCount(1, $writer->get_all_metadata($subcontext));
+
+        // It should be the subscriptionpreference whose value is 1.
+        $this->assertEquals(1, $writer->get_metadata($subcontext, 'subscriptionpreference'));
+
+        // There should be data about the forum itself.
+        $this->assertNotEmpty($writer->get_data($subcontext));
+    }
+
+    /**
+     * Test that a user who is enrolled in a course, and who has never
+     * posted and has subscribed to the discussion will have relevant
+     * information returned.
+     */
+    public function test_user_has_never_posted_subscribed_to_discussion() {
+        // Create a course, with a forum, our user under test, another user, and a discussion + post from the other user.
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list($user, $otheruser) = $this->helper_create_users($course, 2);
+        // Post twice - only the second discussion should be included.
+        $this->helper_post_to_forum($forum, $otheruser);
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $otheruser);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Subscribe the user to the discussion.
+        \mod_forum\subscriptions::subscribe_user_to_discussion($user->id, $discussion);
+
+        // Retrieve all contexts - only this context should be returned.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($context, $contextlist->current());
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($user->id, $context, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+
+        // There should be nothing in the forum. The user is not subscribed there.
+        $forumsubcontext = $this->get_subcontext($forum);
+        $this->assertCount(0, $writer->get_all_metadata($forumsubcontext));
+        $this->assert_forum_data($forum, $writer->get_data($forumsubcontext));
+
+        // There should be metadata in the discussion.
+        $discsubcontext = $this->get_subcontext($forum, $discussion);
+        $this->assertCount(1, $writer->get_all_metadata($discsubcontext));
+
+        // It should be the subscriptionpreference whose value is an Integer.
+        // (It's a timestamp, but it doesn't matter).
+        $metadata = $writer->get_metadata($discsubcontext, 'subscriptionpreference');
+        $this->assertGreaterThan(1, $metadata);
+
+        // For context we output the discussion content.
+        $data = $writer->get_data($discsubcontext);
+        $this->assertInstanceOf('stdClass', $data);
+        $this->assert_discussion_data($discussion, $data);
+
+        // Post content is not exported unless the user participated.
+        $postsubcontext = $this->get_subcontext($forum, $discussion, $post);
+        $this->assertCount(0, $writer->get_data($postsubcontext));
+    }
+
+    /**
+     * Test that a user who has posted their own discussion will have all
+     * content returned.
+     */
+    public function test_user_has_posted_own_discussion() {
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list($user, $otheruser) = $this->helper_create_users($course, 2);
+
+        // Post twice - only the second discussion should be included.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $user);
+        list($otherdiscussion, $otherpost) = $this->helper_post_to_forum($forum, $otheruser);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Retrieve all contexts - only this context should be returned.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($context, $contextlist->current());
+
+        // Export all of the data for the context.
+        $this->setUser($user);
+        $this->export_context_data_for_user($user->id, $context, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+
+        // The other discussion should not have been returned as we did not post in it.
+        $this->assertEmpty($writer->get_data($this->get_subcontext($forum, $otherdiscussion)));
+
+        $this->assert_discussion_data($discussion, $writer->get_data($this->get_subcontext($forum, $discussion)));
+        $this->assert_post_data($post, $writer->get_data($this->get_subcontext($forum, $discussion, $post)), $writer);
+    }
+
+    /**
+     * Test that a user who has posted a reply to another users discussion
+     * will have all content returned.
+     */
+    public function test_user_has_posted_reply() {
+        global $DB;
+
+        // Create several courses and forums. We only insert data into the final one.
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list($user, $otheruser) = $this->helper_create_users($course, 2);
+        // Post twice - only the second discussion should be included.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $otheruser);
+        list($otherdiscussion, $otherpost) = $this->helper_post_to_forum($forum, $otheruser);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Post a reply to the other person's post.
+        $reply = $this->helper_reply_to_post($post, $user);
+
+        // Testing as user $user.
+        $this->setUser($user);
+
+        // Retrieve all contexts - only this context should be returned.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($context, $contextlist->current());
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($user->id, $context, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+
+        // Refresh the discussions.
+        $discussion = $DB->get_record('forum_discussions', ['id' => $discussion->id]);
+        $otherdiscussion = $DB->get_record('forum_discussions', ['id' => $otherdiscussion->id]);
+
+        // The other discussion should not have been returned as we did not post in it.
+        $this->assertEmpty($writer->get_data($this->get_subcontext($forum, $otherdiscussion)));
+
+        // Our discussion should have been returned as we did post in it.
+        $data = $writer->get_data($this->get_subcontext($forum, $discussion));
+        $this->assertNotEmpty($data);
+        $this->assert_discussion_data($discussion, $data);
+
+        // The reply will be included.
+        $this->assert_post_data($reply, $writer->get_data($this->get_subcontext($forum, $discussion, $reply)), $writer);
+    }
+
+    /**
+     * Test that the rating of another users content will have only the
+     * rater's information returned.
+     */
+    public function test_user_has_rated_others() {
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'scale' => 100,
+        ]);
+        list($user, $otheruser) = $this->helper_create_users($course, 2);
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $otheruser);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Rate the other users content.
+        $rm = new rating_manager();
+        $ratingoptions = new stdClass;
+        $ratingoptions->context = $context;
+        $ratingoptions->component = 'mod_forum';
+        $ratingoptions->ratingarea = 'post';
+        $ratingoptions->itemid  = $post->id;
+        $ratingoptions->scaleid = $forum->scale;
+        $ratingoptions->userid  = $user->id;
+
+        $rating = new \rating($ratingoptions);
+        $rating->update_rating(75);
+
+        // Run as the user under test.
+        $this->setUser($user);
+
+        // Retrieve all contexts - only this context should be returned.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($context, $contextlist->current());
+
+        // Export all of the data for the context.
+        $this->export_context_data_for_user($user->id, $context, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+
+        // The discussion should not have been returned as we did not post in it.
+        $this->assertEmpty($writer->get_data($this->get_subcontext($forum, $discussion)));
+
+        $this->assert_all_own_ratings_on_context(
+            $user->id,
+            $context,
+            $this->get_subcontext($forum, $discussion, $post),
+            'mod_forum',
+            'post',
+            $post->id
+        );
+
+        // The original post will not be included.
+        $this->assert_post_data($post, $writer->get_data($this->get_subcontext($forum, $discussion, $post)), $writer);
+    }
+
+    /**
+     * Test that ratings of a users own content will all be returned.
+     */
+    public function test_user_has_been_rated() {
+        $course = $this->getDataGenerator()->create_course();
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'scale' => 100,
+        ]);
+        list($user, $otheruser, $anotheruser) = $this->helper_create_users($course, 3);
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $user);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Other users rate my content.
+        $rm = new rating_manager();
+        $ratingoptions = new stdClass