From 2fefe3ca68f00a825cdbd84b38cb952ef1ff1319 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Thu, 8 Feb 2018 10:22:02 +0800 Subject: [PATCH] MDL-61309 mod_forum: Implement the Privacy API --- mod/forum/classes/privacy/provider.php | 887 ++++++++++++++ mod/forum/classes/privacy/subcontext_info.php | 123 ++ mod/forum/lang/en/forum.php | 52 + mod/forum/tests/helper.php | 166 +++ mod/forum/tests/privacy_provider_test.php | 1031 +++++++++++++++++ tag/classes/tests/privacy_helper.php | 5 +- 6 files changed, 2260 insertions(+), 4 deletions(-) create mode 100644 mod/forum/classes/privacy/provider.php create mode 100644 mod/forum/classes/privacy/subcontext_info.php create mode 100644 mod/forum/tests/helper.php create mode 100644 mod/forum/tests/privacy_provider_test.php diff --git a/mod/forum/classes/privacy/provider.php b/mod/forum/classes/privacy/provider.php new file mode 100644 index 00000000000..30e26b8136d --- /dev/null +++ b/mod/forum/classes/privacy/provider.php @@ -0,0 +1,887 @@ +. + +/** + * Privacy Subsystem implementation for mod_forum. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @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 + * @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) { + // 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($userid) { + $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($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($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($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($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($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($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($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($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($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($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($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 index 00000000000..41013a5fecc --- /dev/null +++ b/mod/forum/classes/privacy/subcontext_info.php @@ -0,0 +1,123 @@ +. + +/** + * Privacy Subsystem implementation for mod_forum. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @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 subcontext_info trait. + * + * @copyright 2018 Andrew Nicols + * @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) { + $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) { + $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; + + } +} diff --git a/mod/forum/lang/en/forum.php b/mod/forum/lang/en/forum.php index 67a6c23d83b..5ccffce5e08 100644 --- a/mod/forum/lang/en/forum.php +++ b/mod/forum/lang/en/forum.php @@ -225,6 +225,7 @@ $string['forum:allowforcesubscribe'] = 'Allow force subscribe'; $string['forum:canoverridediscussionlock'] = 'Reply to locked discussions'; $string['forumauthorhidden'] = 'Author (hidden)'; $string['forumblockingalmosttoomanyposts'] = 'You are approaching the posting threshold. You have posted {$a->numposts} times in the last {$a->blockperiod} and the limit is {$a->blockafter} posts.'; +$string['forumbodydeleted'] = 'The content of this forum post has been removed and can no longer be accessed.'; $string['forumbodyhidden'] = 'This post cannot be viewed by you, probably because you have not posted in the discussion, the maximum editing time hasn\'t passed yet, the discussion has not started or the discussion has expired.'; $string['forum:canposttomygroups'] = 'Can post to all groups you have access to'; $string['forum:createattachment'] = 'Create attachments'; @@ -247,6 +248,7 @@ $string['forum:replypost'] = 'Reply to posts'; $string['forums'] = 'Forums'; $string['forum:splitdiscussions'] = 'Split discussions'; $string['forum:startdiscussion'] = 'Start new discussions'; +$string['forumsubjectdeleted'] = 'This forum post has been removed'; $string['forumsubjecthidden'] = 'Subject (hidden)'; $string['forumtracked'] = 'Unread posts are being tracked'; $string['forumtrackednot'] = 'Unread posts are not being tracked'; @@ -424,6 +426,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'; diff --git a/mod/forum/tests/helper.php b/mod/forum/tests/helper.php new file mode 100644 index 00000000000..163c454a018 --- /dev/null +++ b/mod/forum/tests/helper.php @@ -0,0 +1,166 @@ +. + +/** + * Helper functions used by several tests. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @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 + * @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; + } +} diff --git a/mod/forum/tests/privacy_provider_test.php b/mod/forum/tests/privacy_provider_test.php new file mode 100644 index 00000000000..85d24abb55f --- /dev/null +++ b/mod/forum/tests/privacy_provider_test.php @@ -0,0 +1,1031 @@ +. + +/** + * Tests for the forum implementation of the Privacy Provider API. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @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 + * @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); + } + } +} diff --git a/tag/classes/tests/privacy_helper.php b/tag/classes/tests/privacy_helper.php index b6351a715db..1d3b3e9a7ef 100644 --- a/tag/classes/tests/privacy_helper.php +++ b/tag/classes/tests/privacy_helper.php @@ -75,10 +75,7 @@ trait privacy_helper { $this->assertCount(count($dbtags), $exportedtags); foreach ($dbtags as $tag) { - $this->assertTrue(isset($exportedtags[$tag->id])); - $this->assertEquals($exportedtags[$tag->id]->name, $tag->name); - $this->assertEquals($exportedtags[$tag->id]->rawname, $tag->rawname); - $this->assertEquals($exportedtags[$tag->id]->flag, $tag->flag); + $this->assertContains($tag->rawname, $exportedtags); } } }