From caef6489aec3a0dabeeca6c4cf4cb76a8a59a23f 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/mail_test.php | 1 + mod/forum/tests/privacy_provider_test.php | 1031 +++++++++++++++++ mod/forum/tests/subscriptions_test.php | 51 +- 7 files changed, 2265 insertions(+), 46 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..f8aa3bcfe8e --- /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) : 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 index 00000000000..0a02743ed35 --- /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 helper 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) : 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; + + } +} diff --git a/mod/forum/lang/en/forum.php b/mod/forum/lang/en/forum.php index abb841078e6..3b5884eea17 100644 --- a/mod/forum/lang/en/forum.php +++ b/mod/forum/lang/en/forum.php @@ -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 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/mail_test.php b/mod/forum/tests/mail_test.php index c1508f7f0a4..e15ceaa7f97 100644 --- a/mod/forum/tests/mail_test.php +++ b/mod/forum/tests/mail_test.php @@ -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 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/mod/forum/tests/subscriptions_test.php b/mod/forum/tests/subscriptions_test.php index 276ccf7d081..d48bf0e80e9 100644 --- a/mod/forum/tests/subscriptions_test.php +++ b/mod/forum/tests/subscriptions_test.php @@ -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;