MDL-61309 mod_forum: Implement the Privacy API
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 8 Feb 2018 02:22:02 +0000 (10:22 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 9 May 2018 06:04:12 +0000 (14:04 +0800)
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/lang/en/forum.php
mod/forum/tests/helper.php [new file with mode: 0644]
mod/forum/tests/mail_test.php
mod/forum/tests/privacy_provider_test.php [new file with mode: 0644]
mod/forum/tests/subscriptions_test.php

diff --git a/mod/forum/classes/privacy/provider.php b/mod/forum/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..f8aa3bc
--- /dev/null
@@ -0,0 +1,887 @@
+<?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),
+            ];
+
+            // 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' => transform::user($post->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.
+        $rm = new \rating_manager();
+        $rm->delete_ratings((object) [
+            'contextid' => $context->id,
+        ]);
+
+        // Delete all Tags.
+        \core_tag_tag::delete_instances('mod_forum', 'post', $context->id);
+    }
+
+    /**
+     * 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;
+        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 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)";
+                $postparams = [
+                    'forum' => $forum->id,
+                    'userid' => $userid,
+                ];
+
+                // Update the subject.
+                $DB->set_field_select('forum_posts', 'subject', '', $postsql, $postparams);
+                    'subject',
+                    get_string('privacy:request:delete:post:subject', 'mod_forum'),
+                    $postsql,
+                    $postparams);
+
+                // Update the subject and its format.
+                $DB->set_field_select('forum_posts', 'message', '', $postsql, $postparams);
+                    'forum_posts',
+                    'message',
+                    get_string('privacy:request:delete:post:message', 'mod_forum'),
+                    $postsql,
+                    $postparams);
+                $DB->set_field_select('forum_posts', 'messageformat', FORMAT_PLAIN, $postsql, $postparams);
+
+                $discussion->name = get_string('privacy:request:delete:discussion:name', 'mod_forum', null, $lang);
+                $DB->update_record('forum_discussions', $discussion);
+
+                // Note: Do _not_ delete ratings.
+                // Ratings are aggregate fields and deleting the rating of this post will have an effect on the rating
+                // of any post.
+
+                // Delete all Tags.
+                \core_tag_tag::delete_instances('mod_forum', 'post', $context->id);
+            }
+
+            $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 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.';
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/privacy_provider_test.php b/mod/forum/tests/privacy_provider_test.php
new file mode 100644 (file)
index 0000000..85d24ab
--- /dev/null
@@ -0,0 +1,1031 @@
+<?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');
+
+/**
+ * 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;
+        $ratingoptions->context = $context;
+        $ratingoptions->component = 'mod_forum';
+        $ratingoptions->ratingarea = 'post';
+        $ratingoptions->itemid  = $post->id;
+        $ratingoptions->scaleid = $forum->scale;
+
+        $ratingoptions->userid  = $otheruser->id;
+        $rating = new \rating($ratingoptions);
+        $rating->update_rating(75);
+
+        $ratingoptions->userid  = $anotheruser->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());
+
+        $this->assert_all_ratings_on_context(
+            $context,
+            $this->get_subcontext($forum, $discussion, $post),
+            'mod_forum',
+            'post',
+            $post->id
+        );
+    }
+
+    /**
+     * Test that per-user daily digest settings are included correctly.
+     */
+    public function test_user_forum_digest() {
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum0 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm0 = get_coursemodule_from_instance('forum', $forum0->id);
+        $context0 = \context_module::instance($cm0->id);
+
+        $forum1 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm1 = get_coursemodule_from_instance('forum', $forum1->id);
+        $context1 = \context_module::instance($cm1->id);
+
+        $forum2 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm2 = get_coursemodule_from_instance('forum', $forum2->id);
+        $context2 = \context_module::instance($cm2->id);
+
+        $forum3 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm3 = get_coursemodule_from_instance('forum', $forum3->id);
+        $context3 = \context_module::instance($cm3->id);
+
+        list($user) = $this->helper_create_users($course, 1);
+
+        // Set a digest value for each forum.
+        forum_set_user_maildigest($forum0, 0, $user);
+        forum_set_user_maildigest($forum1, 1, $user);
+        forum_set_user_maildigest($forum2, 2, $user);
+
+        // Run as the user under test.
+        $this->setUser($user);
+
+        // Retrieve all contexts - three contexts should be returned - the fourth should not be included.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(3, $contextlist);
+
+        $contextids = [
+                $context0->id,
+                $context1->id,
+                $context2->id,
+            ];
+        sort($contextids);
+        $contextlistids = $contextlist->get_contextids();
+        sort($contextlistids);
+        $this->assertEquals($contextids, $contextlistids);
+
+        // Check export data for each context.
+        $this->export_context_data_for_user($user->id, $context0, 'mod_forum');
+        $this->assertEquals(0, \core_privacy\local\request\writer::with_context($context0)->get_metadata([], 'digestpreference'));
+
+        $this->export_context_data_for_user($user->id, $context1, 'mod_forum');
+        $this->assertEquals(1, \core_privacy\local\request\writer::with_context($context1)->get_metadata([], 'digestpreference'));
+
+        $this->export_context_data_for_user($user->id, $context2, 'mod_forum');
+        $this->assertEquals(2, \core_privacy\local\request\writer::with_context($context2)->get_metadata([], 'digestpreference'));
+    }
+
+    /**
+     * Test that the per-user, per-forum user tracking data is exported.
+     */
+    public function test_user_tracking_data() {
+        $course = $this->getDataGenerator()->create_course();
+
+        $forumoff = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cmoff = get_coursemodule_from_instance('forum', $forumoff->id);
+        $contextoff = \context_module::instance($cmoff->id);
+
+        $forumon = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cmon = get_coursemodule_from_instance('forum', $forumon->id);
+        $contexton = \context_module::instance($cmon->id);
+
+        list($user) = $this->helper_create_users($course, 1);
+
+        // Set user tracking data.
+        forum_tp_stop_tracking($forumoff->id, $user->id);
+        forum_tp_start_tracking($forumon->id, $user->id);
+
+        // Run as the user under test.
+        $this->setUser($user);
+
+        // Retrieve all contexts - only the forum tracking reads should be included.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($contextoff, $contextlist->current());
+
+        // Check export data for each context.
+        $this->export_context_data_for_user($user->id, $contextoff, 'mod_forum');
+        $this->assertEquals(0,
+                \core_privacy\local\request\writer::with_context($contextoff)->get_metadata([], 'trackreadpreference'));
+    }
+
+    /**
+     * Test that the posts which a user has read are returned correctly.
+     */
+    public function test_user_read_posts() {
+        global $DB;
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum1 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm1 = get_coursemodule_from_instance('forum', $forum1->id);
+        $context1 = \context_module::instance($cm1->id);
+
+        $forum2 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm2 = get_coursemodule_from_instance('forum', $forum2->id);
+        $context2 = \context_module::instance($cm2->id);
+
+        $forum3 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm3 = get_coursemodule_from_instance('forum', $forum3->id);
+        $context3 = \context_module::instance($cm3->id);
+
+        $forum4 = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        $cm4 = get_coursemodule_from_instance('forum', $forum4->id);
+        $context4 = \context_module::instance($cm4->id);
+
+        list($author, $user) = $this->helper_create_users($course, 2);
+
+        list($f1d1, $f1p1) = $this->helper_post_to_forum($forum1, $author);
+        $f1p1reply = $this->helper_post_to_discussion($forum1, $f1d1, $author);
+        $f1d1 = $DB->get_record('forum_discussions', ['id' => $f1d1->id]);
+        list($f1d2, $f1p2) = $this->helper_post_to_forum($forum1, $author);
+
+        list($f2d1, $f2p1) = $this->helper_post_to_forum($forum2, $author);
+        $f2p1reply = $this->helper_post_to_discussion($forum2, $f2d1, $author);
+        $f2d1 = $DB->get_record('forum_discussions', ['id' => $f2d1->id]);
+        list($f2d2, $f2p2) = $this->helper_post_to_forum($forum2, $author);
+
+        list($f3d1, $f3p1) = $this->helper_post_to_forum($forum3, $author);
+        $f3p1reply = $this->helper_post_to_discussion($forum3, $f3d1, $author);
+        $f3d1 = $DB->get_record('forum_discussions', ['id' => $f3d1->id]);
+        list($f3d2, $f3p2) = $this->helper_post_to_forum($forum3, $author);
+
+        list($f4d1, $f4p1) = $this->helper_post_to_forum($forum4, $author);
+        $f4p1reply = $this->helper_post_to_discussion($forum4, $f4d1, $author);
+        $f4d1 = $DB->get_record('forum_discussions', ['id' => $f4d1->id]);
+        list($f4d2, $f4p2) = $this->helper_post_to_forum($forum4, $author);
+
+        // Insert read info.
+        // User has read post1, but not the reply or second post in forum1.
+        forum_tp_add_read_record($user->id, $f1p1->id);
+
+        // User has read post1 and its reply, but not the second post in forum2.
+        forum_tp_add_read_record($user->id, $f2p1->id);
+        forum_tp_add_read_record($user->id, $f2p1reply->id);
+
+        // User has read post2 in forum3.
+        forum_tp_add_read_record($user->id, $f3p2->id);
+
+        // Nothing has been read in forum4.
+
+        // Run as the user under test.
+        $this->setUser($user);
+
+        // Retrieve all contexts - should be three - forum4 has no data.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(3, $contextlist);
+
+        $contextids = [
+                $context1->id,
+                $context2->id,
+                $context3->id,
+            ];
+        sort($contextids);
+        $contextlistids = $contextlist->get_contextids();
+        sort($contextlistids);
+        $this->assertEquals($contextids, $contextlistids);
+
+        // Forum 1.
+        $this->export_context_data_for_user($user->id, $context1, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context1);
+
+        // User has read f1p1.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum1, $f1d1, $f1p1),
+                'postread'
+            );
+        $this->assertNotEmpty($readdata);
+        $this->assertTrue(isset($readdata->firstread));
+        $this->assertTrue(isset($readdata->lastread));
+
+        // User has not f1p1reply.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum1, $f1d1, $f1p1reply),
+                'postread'
+            );
+        $this->assertEmpty($readdata);
+
+        // User has not f1p2.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum1, $f1d2, $f1p2),
+                'postread'
+            );
+        $this->assertEmpty($readdata);
+
+        // Forum 2.
+        $this->export_context_data_for_user($user->id, $context2, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context2);
+
+        // User has read f2p1.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum2, $f2d1, $f2p1),
+                'postread'
+            );
+        $this->assertNotEmpty($readdata);
+        $this->assertTrue(isset($readdata->firstread));
+        $this->assertTrue(isset($readdata->lastread));
+
+        // User has read f2p1reply.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum2, $f2d1, $f2p1reply),
+                'postread'
+            );
+        $this->assertNotEmpty($readdata);
+        $this->assertTrue(isset($readdata->firstread));
+        $this->assertTrue(isset($readdata->lastread));
+
+        // User has not read f2p2.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum2, $f2d2, $f2p2),
+                'postread'
+            );
+        $this->assertEmpty($readdata);
+
+        // Forum 3.
+        $this->export_context_data_for_user($user->id, $context3, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context3);
+
+        // User has not read f3p1.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum3, $f3d1, $f3p1),
+                'postread'
+            );
+        $this->assertEmpty($readdata);
+
+        // User has not read f3p1reply.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum3, $f3d1, $f3p1reply),
+                'postread'
+            );
+        $this->assertEmpty($readdata);
+
+        // User has read f3p2.
+        $readdata = $writer->get_metadata(
+                $this->get_subcontext($forum3, $f3d2, $f3p2),
+                'postread'
+            );
+        $this->assertNotEmpty($readdata);
+        $this->assertTrue(isset($readdata->firstread));
+        $this->assertTrue(isset($readdata->lastread));
+    }
+
+    /**
+     * Test that posts with attachments have their attachments correctly exported.
+     */
+    public function test_post_attachment_inclusion() {
+        global $DB;
+
+        $fs = get_file_storage();
+        $course = $this->getDataGenerator()->create_course();
+        list($author, $otheruser) = $this->helper_create_users($course, 2);
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'scale' => 100,
+        ]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Create a new discussion + post in the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+        $discussion = $DB->get_record('forum_discussions', ['id' => $discussion->id]);
+
+        // Add a number of replies.
+        $reply = $this->helper_reply_to_post($post, $author);
+        $reply = $this->helper_reply_to_post($post, $author);
+        $reply = $this->helper_reply_to_post($reply, $author);
+        $posts[$reply->id] = $reply;
+
+        // Add a fake inline image to the original post.
+        $createdfile = $fs->create_file_from_string([
+                'contextid' => $context->id,
+                'component' => 'mod_forum',
+                'filearea'  => 'post',
+                'itemid'    => $post->id,
+                'filepath'  => '/',
+                'filename'  => 'example.jpg',
+            ],
+        'image contents (not really)');
+
+        // Tag the post and the final reply.
+        \core_tag_tag::set_item_tags('mod_forum', 'forum_posts', $post->id, $context, ['example', 'tag']);
+        \core_tag_tag::set_item_tags('mod_forum', 'forum_posts', $reply->id, $context, ['example', 'differenttag']);
+
+        // Create a second discussion + post in the forum without tags.
+        list($otherdiscussion, $otherpost) = $this->helper_post_to_forum($forum, $author);
+        $otherdiscussion = $DB->get_record('forum_discussions', ['id' => $otherdiscussion->id]);
+
+        // Add a number of replies.
+        $reply = $this->helper_reply_to_post($otherpost, $author);
+        $reply = $this->helper_reply_to_post($otherpost, $author);
+
+        // Run as the user under test.
+        $this->setUser($author);
+
+        // Retrieve all contexts - should be one.
+        $contextlist = $this->get_contexts_for_userid($author->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+
+        $this->export_context_data_for_user($author->id, $context, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+
+        // The inline file should be on the first forum post.
+        $subcontext = $this->get_subcontext($forum, $discussion, $post);
+        $foundfiles = $writer->get_files($subcontext);
+        $this->assertCount(1, $foundfiles);
+        $this->assertEquals($createdfile, reset($foundfiles));
+    }
+
+    /**
+     * Test that posts which include tags have those tags exported.
+     */
+    public function test_post_tags() {
+        global $DB;
+
+        $course = $this->getDataGenerator()->create_course();
+        list($author, $otheruser) = $this->helper_create_users($course, 2);
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'scale' => 100,
+        ]);
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+        $context = \context_module::instance($cm->id);
+
+        // Create a new discussion + post in the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+        $discussion = $DB->get_record('forum_discussions', ['id' => $discussion->id]);
+
+        // Add a number of replies.
+        $reply = $this->helper_reply_to_post($post, $author);
+        $reply = $this->helper_reply_to_post($post, $author);
+        $reply = $this->helper_reply_to_post($reply, $author);
+        $posts[$reply->id] = $reply;
+
+        // Tag the post and the final reply.
+        \core_tag_tag::set_item_tags('mod_forum', 'forum_posts', $post->id, $context, ['example', 'tag']);
+        \core_tag_tag::set_item_tags('mod_forum', 'forum_posts', $reply->id, $context, ['example', 'differenttag']);
+
+        // Create a second discussion + post in the forum without tags.
+        list($otherdiscussion, $otherpost) = $this->helper_post_to_forum($forum, $author);
+        $otherdiscussion = $DB->get_record('forum_discussions', ['id' => $otherdiscussion->id]);
+
+        // Add a number of replies.
+        $reply = $this->helper_reply_to_post($otherpost, $author);
+        $reply = $this->helper_reply_to_post($otherpost, $author);
+
+        // Run as the user under test.
+        $this->setUser($author);
+
+        // Retrieve all contexts - should be two.
+        $contextlist = $this->get_contexts_for_userid($author->id, 'mod_forum');
+        $this->assertCount(1, $contextlist);
+
+        $this->export_all_data_for_user($author->id, 'mod_forum');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+
+        $this->assert_all_tags_match_on_context(
+            $author->id,
+            $context,
+            $this->get_subcontext($forum, $discussion, $post),
+            'mod_forum',
+            'forum_posts',
+            $post->id
+        );
+    }
+
+    /**
+     * Ensure that all user data is deleted from a context.
+     */
+    public function test_all_users_deleted_from_context() {
+        global $DB;
+
+        $fs = get_file_storage();
+        $course = $this->getDataGenerator()->create_course();
+        $users = $this->helper_create_users($course, 5);
+
+        $forums = [];
+        $contexts = [];
+        for ($i = 0; $i < 2; $i++) {
+            $forum = $this->getDataGenerator()->create_module('forum', [
+                'course' => $course->id,
+                'scale' => 100,
+            ]);
+            $cm = get_coursemodule_from_instance('forum', $forum->id);
+            $context = \context_module::instance($cm->id);
+            $forums[$forum->id] = $forum;
+            $contexts[$forum->id] = $context;
+        }
+
+        $discussions = [];
+        $posts = [];
+        foreach ($users as $user) {
+            foreach ($forums as $forum) {
+                $context = $contexts[$forum->id];
+
+                // Create a new discussion + post in the forum.
+                list($discussion, $post) = $this->helper_post_to_forum($forum, $user);
+                $discussion = $DB->get_record('forum_discussions', ['id' => $discussion->id]);
+                $discussions[$discussion->id] = $discussion;
+
+                // Add a number of replies.
+                $posts[$post->id] = $post;
+                $reply = $this->helper_reply_to_post($post, $user);
+                $posts[$reply->id] = $reply;
+                $reply = $this->helper_reply_to_post($post, $user);
+                $posts[$reply->id] = $reply;
+                $reply = $this->helper_reply_to_post($reply, $user);
+                $posts[$reply->id] = $reply;
+
+                // Add a fake inline image to the original post.
+                $fs->create_file_from_string([
+                        'contextid' => $context->id,
+                        'component' => 'mod_forum',
+                        'filearea'  => 'post',
+                        'itemid'    => $post->id,
+                        'filepath'  => '/',
+                        'filename'  => 'example.jpg',
+                    ], 'image contents (not really)');
+            }
+        }
+
+        // Mark all posts as read by user1.
+        $user1 = reset($users);
+        $ratedposts = [];
+        foreach ($posts as $post) {
+            $discussion = $discussions[$post->discussion];
+            $forum = $forums[$discussion->forum];
+            $context = $contexts[$forum->id];
+
+            // Mark the post as being read by user1.
+            forum_tp_add_read_record($user1->id, $post->id);
+
+            // Tag the post.
+            \core_tag_tag::set_item_tags('mod_forum', 'forum_posts', $post->id, $context, ['example', 'tag']);
+
+            // Rate the other users content.
+            if ($post->userid != $user1->id) {
+                $ratedposts[$post->id] = $post;
+                $rm = new rating_manager();
+                $ratingoptions = (object) [
+                    'context' => $context,
+                    'component' => 'mod_forum',
+                    'ratingarea' => 'post',
+                    'itemid' => $post->id,
+                    'scaleid' => $forum->scale,
+                    'userid' => $user->id,
+                ];
+
+                $rating = new \rating($ratingoptions);
+                $rating->update_rating(75);
+            }
+            $posttags = \core_tag_tag::get_item_tags('mod_forum', 'forum_posts', $post->id);
+        }
+
+        // Run as the user under test.
+        $this->setUser($user);
+
+        // Retrieve all contexts - should be two.
+        $contextlist = $this->get_contexts_for_userid($user->id, 'mod_forum');
+        $this->assertCount(2, $contextlist);
+
+        // These are the contexts we expect.
+        $contextids = array_map(function($context) {
+            return $context->id;
+        }, $contexts);
+        sort($contextids);
+
+        $contextlistids = $contextlist->get_contextids();
+        sort($contextlistids);
+        $this->assertEquals($contextids, $contextlistids);
+
+        // Delete for the first forum.
+        $forum = reset($forums);
+        $context = $contexts[$forum->id];
+        $this->delete_data_for_all_users_in_context('mod_forum', $context);
+
+        // Determine what should have been deleted.
+        $discussionsinforum = array_filter($discussions, function($discussion) use ($forum) {
+            return $discussion->forum == $forum->id;
+        });
+
+        $postsinforum = array_filter($posts, function($post) use ($discussionsinforum) {
+            return isset($discussionsinforum[$post->discussion]);
+        });
+
+        // All forum discussions and posts should have been deleted in this forum.
+        $this->assertCount(0, $DB->get_records('forum_discussions', ['forum' => $forum->id]));
+
+        list ($insql, $inparams) = $DB->get_in_or_equal(array_keys($discussionsinforum));
+        $this->assertCount(0, $DB->get_records_select('forum_posts', "discussion {$insql}", $inparams));
+
+        // All uploaded files relating to this context should have been deleted (post content).
+        foreach ($postsinforum as $post) {
+            $this->assertEmpty($fs->get_area_files($context->id, 'mod_forum', 'post', $post->id));
+        }
+
+        // All ratings should have been deleted.
+        $rm = new rating_manager();
+        foreach ($postsinforum as $post) {
+            $ratings = $rm->get_all_ratings_for_item((object) [
+                'context' => $context,
+                'component' => 'mod_forum',
+                'ratingarea' => 'post',
+                'itemid' => $post->id,
+            ]);
+            $this->assertEmpty($ratings);
+        }
+
+        // All tags should have been deleted.
+        $posttags = \core_tag_tag::get_items_tags('mod_forum', 'post', array_keys($postsinforum));
+        foreach ($posttags as $tags) {
+            $this->assertEmpty($tags);
+        }
+
+        // Check the other forum too. It should remain intact.
+        $forum = next($forums);
+        $context = $contexts[$forum->id];
+
+        // Grab the list of discussions and posts in the forum.
+        $discussionsinforum = array_filter($discussions, function($discussion) use ($forum) {
+            return $discussion->forum == $forum->id;
+        });
+
+        $postsinforum = array_filter($posts, function($post) use ($discussionsinforum) {
+            return isset($discussionsinforum[$post->discussion]);
+        });
+
+        // Forum discussions and posts should not have been deleted in this forum.
+        $this->assertGreaterThan(0, $DB->count_records('forum_discussions', ['forum' => $forum->id]));
+
+        list ($insql, $inparams) = $DB->get_in_or_equal(array_keys($discussionsinforum));
+        $this->assertGreaterThan(0, $DB->count_records_select('forum_posts', "discussion {$insql}", $inparams));
+
+        // Uploaded files relating to this context should remain.
+        foreach ($postsinforum as $post) {
+            if ($post->parent == 0) {
+                $this->assertNotEmpty($fs->get_area_files($context->id, 'mod_forum', 'post', $post->id));
+            }
+        }
+
+        // Ratings should not have been deleted.
+        $rm = new rating_manager();
+        foreach ($postsinforum as $post) {
+            if (!isset($ratedposts[$post->id])) {
+                continue;
+            }
+            $ratings = $rm->get_all_ratings_for_item((object) [
+                'context' => $context,
+                'component' => 'mod_forum',
+                'ratingarea' => 'post',
+                'itemid' => $post->id,
+            ]);
+            $this->assertNotEmpty($ratings);
+        }
+
+        // All tags should remain.
+        $posttags = \core_tag_tag::get_items_tags('mod_forum', 'forum_posts', array_keys($postsinforum));
+        foreach ($posttags as $tags) {
+            $this->assertNotEmpty($tags);
+        }
+    }
+}
index 276ccf7..d48bf0e 100644 (file)
@@ -26,8 +26,13 @@ defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
 require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/helper.php');
 
 class mod_forum_subscriptions_testcase extends advanced_testcase {
+    // Include the mod_forum test helpers.
+    // This includes functions to create forums, users, discussions, and posts.
+    use helper;
+
     /**
      * Test setUp.
      */
@@ -50,52 +55,6 @@ class mod_forum_subscriptions_testcase extends advanced_testcase {
         \mod_forum\subscriptions::reset_discussion_cache();
     }
 
-    /**
-     * 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
-     * @param 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);
-    }
-
     public function test_subscription_modes() {
         global $DB;