From 28fd710f8406fab5dff858046dc4cc86dd7276c3 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 1 Jun 2018 11:37:35 +0800 Subject: [PATCH 01/10] MDL-46881 core_task: Add a logging trait --- lib/classes/task/logging_trait.php | 113 +++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 lib/classes/task/logging_trait.php diff --git a/lib/classes/task/logging_trait.php b/lib/classes/task/logging_trait.php new file mode 100644 index 00000000000..ac2d4014a0b --- /dev/null +++ b/lib/classes/task/logging_trait.php @@ -0,0 +1,113 @@ +. + +/** + * This file defines a trait to assist with logging in tasks. + * + * @package core + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\task; + +defined('MOODLE_INTERNAL') || die(); + +/** + * This trait includes functions to assist with logging in tasks. + * + * @package core + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait logging_trait { + + /** + * @var \progress_trace + */ + protected $_trace = null; + + /** + * @var \stdClass + */ + protected $_tracestats = null; + + /** + * Get the progress_trace. + * + * @return \progress_trace + */ + protected function get_trace() { + if (null === $this->_trace) { + $this->_trace = new \text_progress_trace(); + $this->_tracestats = (object) []; + } + + return $this->_trace; + } + + /** + * Log a message to the progress tracer. + * + * @param string $message + * @param int $depth + */ + protected function log($message, $depth = 1) { + $this->get_trace() + ->output($message, $depth); + } + + /** + * Log a start message to the progress tracer. + * + * @param string $message + * @param int $depth + */ + protected function log_start($message, $depth = 0) { + $this->get_trace(); + + if (defined('MDL_PERFTOLOG') && MDL_PERFTOLOG) { + $this->_tracestats->$depth = [ + 'mem' => memory_get_usage(), + 'time' => microtime(), + ]; + } + + $this->log($message, $depth); + } + + /** + * Log an end message to the progress tracer. + * + * @param string $message + * @param int $depth + */ + protected function log_finish($message, $depth = 0) { + $this->log($message, $depth); + if (isset($this->_tracestats->$depth)) { + $startstats = $this->_tracestats->$depth; + $this->log( + sprintf("Time taken %s, memory total: %s, Memory growth: %s, Memory peak: %s", + microtime_diff($startstats['time'], microtime()), + display_size(memory_get_usage()), + display_size(memory_get_usage() - $startstats['mem']), + display_size(memory_get_peak_usage()) + ), + $depth + 1 + ); + } + } +} From 4459ad290d8d156d32df2af2fcb251ae51bb8735 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 1 Jun 2018 11:39:39 +0800 Subject: [PATCH 02/10] MDL-46881 mod_forum: Move cron around --- mod/forum/classes/task/cron_task.php | 564 ++++++++- mod/forum/classes/task/send_user_digests.php | 599 +++++++++ .../classes/task/send_user_notifications.php | 534 ++++++++ mod/forum/deprecatedlib.php | 38 + mod/forum/lib.php | 823 ------------- ...um_post_emaildigestfull_textemail.mustache | 3 +- mod/forum/tests/cron_trait.php | 129 ++ mod/forum/tests/generator_trait.php | 159 +++ mod/forum/tests/mail_group_test.php | 246 ++++ mod/forum/tests/mail_test.php | 1083 ++++++++++------- mod/forum/tests/maildigest_test.php | 236 ++-- mod/forum/tests/qanda_test.php | 153 +++ 12 files changed, 3166 insertions(+), 1401 deletions(-) create mode 100644 mod/forum/classes/task/send_user_digests.php create mode 100644 mod/forum/classes/task/send_user_notifications.php create mode 100644 mod/forum/tests/cron_trait.php create mode 100644 mod/forum/tests/generator_trait.php create mode 100644 mod/forum/tests/mail_group_test.php create mode 100644 mod/forum/tests/qanda_test.php diff --git a/mod/forum/classes/task/cron_task.php b/mod/forum/classes/task/cron_task.php index f232c622c8b..ee2d2961934 100644 --- a/mod/forum/classes/task/cron_task.php +++ b/mod/forum/classes/task/cron_task.php @@ -17,16 +17,68 @@ /** * A scheduled task for forum cron. * - * @todo MDL-44734 This job will be split up properly. - * * @package mod_forum * @copyright 2014 Dan Poltawski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_forum\task; +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/forum/lib.php'); + +/** + * The main scheduled task for the forum. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ class cron_task extends \core\task\scheduled_task { + // Use the logging trait to get some nice, juicy, logging. + use \core\task\logging_trait; + + /** + * @var The list of courses which contain posts to be sent. + */ + protected $courses = []; + + /** + * @var The list of forums which contain posts to be sent. + */ + protected $forums = []; + + /** + * @var The list of discussions which contain posts to be sent. + */ + protected $discussions = []; + + /** + * @var The list of posts to be sent. + */ + protected $posts = []; + + /** + * @var The list of post authors. + */ + protected $users = []; + + /** + * @var The list of subscribed users. + */ + protected $subscribedusers = []; + + /** + * @var The list of digest users. + */ + protected $digestusers = []; + + /** + * @var The list of adhoc data for sending. + */ + protected $adhocdata = []; + /** * Get a descriptive name for this task (shown to admins). * @@ -37,12 +89,512 @@ class cron_task extends \core\task\scheduled_task { } /** - * Run forum cron. + * Execute the scheduled task. */ public function execute() { - global $CFG; - require_once($CFG->dirroot . '/mod/forum/lib.php'); - forum_cron(); + global $CFG, $DB; + + $timenow = time(); + + // Delete any really old posts in the digest queue. + $weekago = $timenow - (7 * 24 * 3600); + $this->log_start("Removing old digest records from 7 days ago."); + $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago)); + $this->log_finish("Removed all old digest records."); + + $endtime = $timenow - $CFG->maxeditingtime; + $starttime = $endtime - (2 * DAYSECS); + $this->log_start("Fetching unmailed posts."); + if (!$posts = $this->get_unmailed_posts($starttime, $endtime, $timenow)) { + $this->log_finish("No posts found.", 1); + return false; + } + $this->log_finish("Done"); + + // Process post data and turn into adhoc tasks. + $this->process_post_data($posts); + + // Mark posts as read. + list($in, $params) = $DB->get_in_or_equal(array_keys($posts)); + $DB->set_field_select('forum_posts', 'mailed', 1, "id {$in}", $params); } + /** + * Process all posts and convert to appropriated hoc tasks. + * + * @param \stdClass[] $posts + */ + protected function process_post_data($posts) { + $trace = $this->get_trace(); + + $discussionids = []; + $forumids = []; + $courseids = []; + + $this->log_start("Processing post information"); + + $start = microtime(true); + foreach ($posts as $id => $post) { + $discussionids[$post->discussion] = true; + $forumids[$post->forum] = true; + $courseids[$post->course] = true; + $this->add_data_for_post($post); + $this->posts[$id] = $post; + } + $this->log_finish(sprintf("Processed %s posts", count($this->posts))); + + if (empty($this->posts)) { + $this->log("No posts found. Returning early."); + return; + } + + // Please note, this order is intentional. + // The forum cache makes use of the course. + $this->log_start("Filling caches"); + + $start = microtime(true); + $this->log_start("Filling course cache", 1); + $this->fill_course_cache(array_keys($courseids)); + $this->log_finish("Done", 1); + + $this->log_start("Filling forum cache", 1); + $this->fill_forum_cache(array_keys($forumids)); + $this->log_finish("Done", 1); + + $this->log_start("Filling discussion cache", 1); + $this->fill_discussion_cache(array_keys($discussionids)); + $this->log_finish("Done", 1); + + $this->log_start("Filling user subscription cache", 1); + $this->fill_user_subscription_cache(); + $this->log_finish("Done", 1); + + $this->log_start("Filling digest cache", 1); + $this->fill_digest_cache(); + $this->log_finish("Done", 1); + + $this->log_start("Filling user posted cache for Q&A forums", 1); + $this->fetch_user_has_posted(); + $this->log_finish("Done", 1); + + $this->log_start("Queueing user tasks."); + $this->queue_user_tasks(); + $this->log_finish("All tasks queued.", 1); + + $this->log_finish("All caches filled"); + } + + /** + * Fill the course cache. + * + * @param int[] $courseids + */ + protected function fill_course_cache($courseids) { + global $DB; + + list($in, $params) = $DB->get_in_or_equal($courseids); + $this->courses = $DB->get_records_select('course', "id $in", $params); + } + + /** + * Fill the forum cache. + * + * @param int[] $forumids + */ + protected function fill_forum_cache($forumids) { + global $DB; + + $requiredfields = [ + 'id', + 'course', + 'forcesubscribe', + 'type', + ]; + list($in, $params) = $DB->get_in_or_equal($forumids); + $this->forums = $DB->get_records_select('forum', "id $in", $params, '', implode(', ', $requiredfields)); + foreach ($this->forums as $id => $forum) { + \mod_forum\subscriptions::fill_subscription_cache($id); + \mod_forum\subscriptions::fill_discussion_subscription_cache($id); + } + } + + /** + * Fill the discussion cache. + * + * @param int[] $discussionids + */ + protected function fill_discussion_cache($discussionids) { + global $DB; + + if (empty($discussionids)) { + $this->discussion = []; + } else { + + $requiredfields = [ + 'id', + 'groupid', + 'firstpost', + 'timestart', + 'timeend', + ]; + + list($in, $params) = $DB->get_in_or_equal($discussionids); + $this->discussions = $DB->get_records_select( + 'forum_discussions', "id $in", $params, '', implode(', ', $requiredfields)); + } + } + + /** + * Fill the cache of user digest preferences. + */ + protected function fill_digest_cache() { + global $DB; + + if (empty($this->users)) { + return; + } + // Get the list of forum subscriptions for per-user per-forum maildigest settings. + list($in, $params) = $DB->get_in_or_equal(array_keys($this->users)); + $digestspreferences = $DB->get_recordset_select( + 'forum_digests', "userid $in", $params, '', 'id, userid, forum, maildigest'); + foreach ($digestspreferences as $digestpreference) { + if (!isset($this->digestusers[$digestpreference->forum])) { + $this->digestusers[$digestpreference->forum] = []; + } + $this->digestusers[$digestpreference->forum][$digestpreference->userid] = $digestpreference->maildigest; + } + $digestspreferences->close(); + } + + /** + * Add dsta for the current forum post to the structure of adhoc data. + * + * @param \stdClass $post + */ + protected function add_data_for_post($post) { + if (!isset($this->adhocdata[$post->course])) { + $this->adhocdata[$post->course] = []; + } + + if (!isset($this->adhocdata[$post->course][$post->forum])) { + $this->adhocdata[$post->course][$post->forum] = []; + } + + if (!isset($this->adhocdata[$post->course][$post->forum][$post->discussion])) { + $this->adhocdata[$post->course][$post->forum][$post->discussion] = []; + } + + $this->adhocdata[$post->course][$post->forum][$post->discussion][$post->id] = $post->id; + } + + /** + * Fill the cache of user subscriptions. + */ + protected function fill_user_subscription_cache() { + foreach ($this->forums as $forum) { + $cm = get_fast_modinfo($this->courses[$forum->course])->instances['forum'][$forum->id]; + $modcontext = \context_module::instance($cm->id); + + $this->subscribedusers[$forum->id] = []; + if ($users = \mod_forum\subscriptions::fetch_subscribed_users($forum, 0, $modcontext, 'u.id, u.maildigest', true)) { + foreach ($users as $user) { + // This user is subscribed to this forum. + $this->subscribedusers[$forum->id][$user->id] = $user->id; + if (!isset($this->users[$user->id])) { + // Store minimal user info. + $this->users[$user->id] = $user; + } + } + // Release memory. + unset($users); + } + } + } + + /** + * Fill the cache for Q&A forums. + */ + protected function fetch_user_has_posted() { + global $DB; + + $forums = array_filter($this->forums, function($forum) { + return $forum->type === 'qanda'; + }); + + if (empty($forums)) { + return; + } + + list($in, $params) = $DB->get_in_or_equal(array_keys($forums)); + + $sql = "SELECT d.forum, d.firstpost, p.userid + FROM {forum} f + INNER JOIN {forum_discussions} d ON d.forum = f.id + LEFT JOIN {forum_posts} p ON p.discussion = d.id + WHERE f.type = 'qanda' + AND f.id {$in} + GROUP BY p.userid, d.forum, d.firstpost"; + + $rs = $DB->get_recordset_sql($sql, $params); + foreach ($rs as $row) { + if (empty($this->qandametadata[$row->forum])) { + $this->qandametadata[$row->forum] = (object) [ + 'users' => [], + 'firstpost' => [$row->firstpost], + ]; + } + $this->qandametadata[$row->forum]->users[$row->userid] = true; + } + $rs->close(); + + } + + /** + * Queue the user tasks. + */ + protected function queue_user_tasks() { + global $CFG, $DB; + + $trace = $this->get_trace(); + + $timenow = time(); + $sitetimezone = \core_date::get_server_timezone(); + $counts = [ + 'digests' => 0, + 'individuals' => 0, + 'users' => 0, + 'ignored' => 0, + 'messages' => 0, + ]; + $this->log("Processing " . count($this->users) . " users", 1); + foreach ($this->users as $user) { + $usercounts = [ + 'digests' => 0, + 'messages' => 0, + ]; + + $send = false; + // Setup this user so that the capabilities are cached, and environment matches receiving user. + cron_setup_user($user); + + list($individualpostdata, $digestpostdata) = $this->fetch_posts_for_user($user); + + if (!empty($digestpostdata)) { + // Insert all of the records for the digest. + $DB->insert_records('forum_queue', $digestpostdata); + $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600); + + $task = new \mod_forum\task\send_user_digests(); + $task->set_userid($user->id); + $task->set_component('mod_forum'); + $task->set_next_run_time($digesttime); + \core\task\manager::queue_adhoc_task($task, true); + $usercounts['digests']++; + $send = true; + } + + if (!empty($individualpostdata)) { + $usercounts['messages'] += count($individualpostdata); + + $task = new \mod_forum\task\send_user_notifications(); + $task->set_userid($user->id); + $task->set_custom_data($individualpostdata); + $task->set_component('mod_forum'); + \core\task\manager::queue_adhoc_task($task); + $counts['individuals']++; + $send = true; + } + + if ($send) { + $counts['users']++; + $counts['messages'] += $usercounts['messages']; + $counts['digests'] += $usercounts['digests']; + } else { + $counts['ignored']++; + } + + $this->log(sprintf("Queued %d digests and %d messages for %s", + $usercounts['digests'], + $usercounts['messages'], + $user->id + ), 2); + } + $this->log( + sprintf( + "Queued %d digests, and %d individual tasks for %d post mails. " . + "Unique users: %d (%d ignored)", + $counts['digests'], + $counts['individuals'], + $counts['messages'], + $counts['users'], + $counts['ignored'] + ), 1); + } + + /** + * Fetch posts for this user. + * + * @param \stdClass $user The user to fetch posts for. + */ + protected function fetch_posts_for_user($user) { + // We maintain a mapping of user groups for each forum. + $usergroups = []; + $digeststructure = []; + + $poststructure = $this->adhocdata; + $poststosend = []; + foreach ($poststructure as $courseid => $forumids) { + $course = $this->courses[$courseid]; + foreach ($forumids as $forumid => $discussionids) { + $forum = $this->forums[$forumid]; + $maildigest = forum_get_user_maildigest_bulk($this->digestusers, $user, $forumid); + + if (!isset($this->subscribedusers[$forumid][$user->id])) { + // This user has no subscription of any kind to this forum. + // Do not send them any posts at all. + unset($poststructure[$courseid][$forumid]); + continue; + } + + $subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $user->id); + + $cm = get_fast_modinfo($course)->instances['forum'][$forumid]; + foreach ($discussionids as $discussionid => $postids) { + $discussion = $this->discussions[$discussionid]; + if (!\mod_forum\subscriptions::is_subscribed($user->id, $forum, $discussionid, $cm)) { + // The user does not subscribe to this forum as a whole, or to this specific discussion. + unset($poststructure[$courseid][$forumid][$discussionid]); + continue; + } + + if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) { + // This discussion has a groupmode set (SEPARATEGROUPS or VISIBLEGROUPS). + // Check whether the user can view it based on their groups. + if (!isset($usergroups[$forum->id])) { + $usergroups[$forum->id] = groups_get_all_groups($courseid, $user->id, $cm->groupingid); + } + + if (!isset($usergroups[$forum->id][$discussion->groupid])) { + // This user is not a member of this group, or the group no longer exists. + + $modcontext = \context_module::instance($cm->id); + if (!has_capability('moodle/site:accessallgroups', $modcontext, $user)) { + // This user does not have the accessallgroups and is not a member of the group. + // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS. + unset($poststructure[$courseid][$forumid][$discussionid]); + continue; + } + } + } + + foreach ($postids as $postid) { + $post = $this->posts[$postid]; + if ($subscriptiontime) { + // Skip posts if the user subscribed to the discussion after it was created. + $subscribedafter = isset($subscriptiontime[$post->discussion]); + $subscribedafter = $subscribedafter && ($subscriptiontime[$post->discussion] > $post->created); + if ($subscribedafter) { + // The user subscribed to the discussion/forum after this post was created. + unset($poststructure[$courseid][$forumid][$discussionid]); + continue; + } + } + + if ($forum->type === 'qanda' && $postid != $discussion->firstpost) { + $hasqandapost = isset($this->qandametadata[$forumid]); + $hasqandapost = $hasqandapost && isset($this->qandametadata[$forumid]->users[$user->id]); + if (!$hasqandapost) { + // The user has not posted to this qanda forum. + unset($poststructure[$courseid][$forumid][$discussionid]); + continue; + } + } + + if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) { + // The user is not allowed to see the post for some other reason. + unset($poststructure[$courseid][$forumid][$discussionid][$postid]); + continue; + } + + if ($maildigest > 0) { + // This user wants the mails to be in digest form. + $digeststructure[] = (object) [ + 'userid' => $user->id, + 'discussionid' => $discussion->id, + 'postid' => $post->id, + 'timemodified' => $post->created, + ]; + unset($poststructure[$courseid][$forumid][$discussionid][$postid]); + continue; + } else { + // Add this post to the list of postids to be sent. + $poststosend[] = $postid; + } + } + } + + if (empty($poststructure[$courseid][$forumid])) { + // This user is not subscribed to any discussions in this forum at all. + unset($poststructure[$courseid][$forumid]); + continue; + } + } + if (empty($poststructure[$courseid])) { + // This user is not subscribed to any forums in this course. + unset($poststructure[$courseid]); + } + } + + return [$poststosend, $digeststructure]; + } + + /** + * Returns a list of all new posts that have not been mailed yet + * + * @param int $starttime posts created after this time + * @param int $endtime posts created before this + * @param int $now used for timed discussions only + * @return array + */ + protected function get_unmailed_posts($starttime, $endtime, $now = null) { + global $CFG, $DB; + + $params = array(); + $params['mailed'] = FORUM_MAILED_PENDING; + $params['ptimestart'] = $starttime; + $params['ptimeend'] = $endtime; + $params['mailnow'] = 1; + + if (!empty($CFG->forum_enabletimedposts)) { + if (empty($now)) { + $now = time(); + } + $selectsql = "AND (p.created >= :ptimestart OR d.timestart >= :pptimestart)"; + $params['pptimestart'] = $starttime; + $timedsql = "AND (d.timestart < :dtimestart AND (d.timeend = 0 OR d.timeend > :dtimeend))"; + $params['dtimestart'] = $now; + $params['dtimeend'] = $now; + } else { + $timedsql = ""; + $selectsql = "AND p.created >= :ptimestart"; + } + + return $DB->get_records_sql( + "SELECT + p.id, + p.discussion, + d.forum, + d.course, + p.created, + p.parent, + p.userid + FROM {forum_posts} p + JOIN {forum_discussions} d ON d.id = p.discussion + WHERE p.mailed = :mailed + $selectsql + AND (p.created < :ptimeend OR p.mailnow = :mailnow) + $timedsql + ORDER BY p.modified ASC", + $params); + } } diff --git a/mod/forum/classes/task/send_user_digests.php b/mod/forum/classes/task/send_user_digests.php new file mode 100644 index 00000000000..816ad76d738 --- /dev/null +++ b/mod/forum/classes/task/send_user_digests.php @@ -0,0 +1,599 @@ +. + +/** + * This file defines an adhoc task to send notifications. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_forum\task; + +defined('MOODLE_INTERNAL') || die(); + +use html_writer; +require_once($CFG->dirroot . '/mod/forum/lib.php'); + +/** + * Adhoc task to send moodle forum digests for the specified user. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class send_user_digests extends \core\task\adhoc_task { + + // Use the logging trait to get some nice, juicy, logging. + use \core\task\logging_trait; + + /** + * @var \stdClass A shortcut to $USER. + */ + protected $recipient; + + /** + * @var bool[] Whether the user can view fullnames for each forum. + */ + protected $viewfullnames = []; + + /** + * @var bool[] Whether the user can post in each forum. + */ + protected $canpostto = []; + + /** + * @var \stdClass[] Courses with posts them. + */ + protected $courses = []; + + /** + * @var \stdClass[] Forums with posts them. + */ + protected $forums = []; + + /** + * @var \stdClass[] Discussions with posts them. + */ + protected $discussions = []; + + /** + * @var \stdClass[] The posts to be sent. + */ + protected $posts = []; + + /** + * @var \stdClass[] The various authors. + */ + protected $users = []; + + /** + * @var \stdClass[] A list of any per-forum digest preference that this user holds. + */ + protected $forumdigesttypes = []; + + /** + * @var bool Whether the user has requested HTML or not. + */ + protected $allowhtml = true; + + /** + * @var string The subject of the message. + */ + protected $postsubject = ''; + + /** + * @var string The plaintext content of the whole message. + */ + protected $notificationtext = ''; + + /** + * @var string The HTML content of the whole message. + */ + protected $notificationhtml = ''; + + /** + * @var string The plaintext content for the current discussion being processed. + */ + protected $discussiontext = ''; + + /** + * @var string The HTML content for the current discussion being processed. + */ + protected $discussionhtml = ''; + + /** + * @var int The number of messages sent in this digest. + */ + protected $sentcount = 0; + + /** + * @var \renderer[][] A cache of the different types of renderer, stored both by target (HTML, or Text), and type. + */ + protected $renderers = [ + 'html' => [], + 'text' => [], + ]; + + /** + * @var int[] A list of post IDs to be marked as read for this user. + */ + protected $markpostsasread = []; + + /** + * Send out messages. + */ + public function execute() { + // Terminate if not able to fetch all digests in 5 minutes. + \core_php_time_limit::raise(300); + + $starttime = time(); + + $this->recipient = \core_user::get_user($this->get_userid()); + $this->log_start("Sending forum digests for {$this->recipient->username} ({$this->recipient->id})"); + + if (empty($this->recipient->mailformat) || $this->recipient->mailformat != 1) { + // This user does not want to receive HTML. + $this->allowhtml = false; + } + + // Fetch all of the data we need to mail these posts. + $this->prepare_data($starttime); + + if (empty($this->posts) || empty($this->discussions) || empty($this->forums)) { + $this->log_finish("No messages found to send."); + return; + } + + // Add the message headers. + $this->add_message_header(); + + foreach ($this->discussions as $discussion) { + // Raise the time limit for each discussion. + \core_php_time_limit::raise(120); + + // Grab the data pertaining to this discussion. + $forum = $this->forums[$discussion->forum]; + $course = $this->courses[$forum->course]; + $cm = get_fast_modinfo($course)->instances['forum'][$forum->id]; + $modcontext = \context_module::instance($cm->id); + $coursecontext = \context_course::instance($course->id); + + if (empty($this->posts[$discussion->id])) { + // Somehow there are no posts. + // This should not happen but better safe than sorry. + continue; + } + + if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) { + // The course is hidden and the user does not have access to it. + // Permissions may have changed since it was queued. + continue; + } + + if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) { + // User cannot see this discussion. + // Permissions may have changed since it was queued. + continue; + } + + if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussion->id, $cm)) { + // The user does not subscribe to this forum as a whole, or to this specific discussion. + continue; + } + + // Fetch additional values relating to this forum. + if (!isset($this->canpostto[$discussion->id])) { + $this->canpostto[$discussion->id] = forum_user_can_post( + $forum, $discussion, $this->recipient, $cm, $course, $modcontext); + } + + if (!isset($this->viewfullnames[$forum->id])) { + $this->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $this->recipient->id); + } + + // Set the discussion storage values. + $discussionpostcount = 0; + $this->discussiontext = ''; + $this->discussionhtml = ''; + + // Add the header for this discussion. + $this->add_discussion_header($discussion, $forum, $course); + $this->log_start("Adding messages in discussion {$discussion->id} (forum {$forum->id})", 1); + + // Add all posts in this forum. + foreach ($this->posts[$discussion->id] as $post) { + $author = $this->get_post_author($post->userid, $course, $forum, $cm, $modcontext); + if (empty($author)) { + // Unable to find the author. Skip to avoid errors. + continue; + } + + if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) { + // User cannot see this post. + // Permissions may have changed since it was queued. + continue; + } + + $this->add_post_body($author, $post, $discussion, $forum, $cm, $course); + $discussionpostcount++; + } + + // Add the forum footer. + $this->add_discussion_footer($discussion, $forum, $course); + + // Add the data for this discussion to the notification body. + if ($discussionpostcount) { + $this->sentcount += $discussionpostcount; + $this->notificationtext .= $this->discussiontext; + $this->notificationhtml .= $this->discussionhtml; + $this->log_finish("Added {$discussionpostcount} messages to discussion {$discussion->id}", 1); + } else { + $this->log_finish("No messages found in discussion {$discussion->id} - skipped.", 1); + } + } + + if ($this->sentcount) { + // This digest has at least one post and should therefore be sent. + if ($this->send_mail()) { + $this->log_finish("Digest sent with {$this->sentcount} messages."); + if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) { + forum_tp_mark_posts_read($this->recipient, $this->markpostsasread); + } + } else { + $this->log_finish("Issue sending digest. Skipping."); + } + } else { + $this->log_finish("No messages found to send."); + } + + // We have finishied all digest emails, update $CFG->digestmailtimelast. + set_config('digestmailtimelast', $starttime); + } + + /** + * Prepare the data for this run. + * + * Note: This will also remove posts from the queue. + * + * @param int $timenow + */ + protected function prepare_data(int $timenow) { + global $DB; + + $sql = "SELECT p.*, f.id AS forum, f.course + FROM {forum_queue} q + INNER JOIN {forum_posts} p ON p.id = q.postid + INNER JOIN {forum_discussions} d ON d.id = p.discussion + INNER JOIN {forum} f ON f.id = d.forum + WHERE q.userid = :userid + AND q.timemodified < :timemodified + ORDER BY d.id, q.timemodified ASC"; + + $queueparams = [ + 'userid' => $this->recipient->id, + 'timemodified' => $timenow, + ]; + + $posts = $DB->get_recordset_sql($sql, $queueparams); + $discussionids = []; + $forumids = []; + $courseids = []; + $userids = []; + foreach ($posts as $post) { + $discussionids[] = $post->discussion; + $forumids[] = $post->forum; + $courseids[] = $post->course; + $userids[] = $post->userid; + unset($post->forum); + if (!isset($this->posts[$post->discussion])) { + $this->posts[$post->discussion] = []; + } + $this->posts[$post->discussion][$post->id] = $post; + } + $posts->close(); + + list($in, $params) = $DB->get_in_or_equal($discussionids); + $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params); + + list($in, $params) = $DB->get_in_or_equal($forumids); + $this->forums = $DB->get_records_select('forum', "id {$in}", $params); + + list($in, $params) = $DB->get_in_or_equal($courseids); + $this->courses = $DB->get_records_select('course', "id $in", $params); + + list($in, $params) = $DB->get_in_or_equal($userids); + $this->users = $DB->get_records_select('user', "id $in", $params); + + $this->fill_digest_cache(); + + $DB->delete_records_select('forum_queue', "userid = :userid AND timemodified < :timemodified", $queueparams); + } + + /** + * Fill the cron digest cache. + */ + protected function fill_digest_cache() { + global $DB; + + $this->forumdigesttypes = $DB->get_records_menu('forum_digests', [ + 'userid' => $this->recipient->id, + ], '', 'forum, maildigest'); + } + + /** + * Fetch and initialise the post author. + * + * @param int $userid The id of the user to fetch + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $cm + * @param \context $context + * @return \stdClass + */ + protected function get_post_author($userid, $course, $forum, $cm, $context) { + if (!isset($this->users[$userid])) { + // This user no longer exists. + return false; + } + + $user = $this->users[$userid]; + + if (!isset($user->groups)) { + // Initialise the groups list. + $user->groups = []; + } + + if (!isset($user->groups[$forum->id])) { + $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid); + } + + // Clone the user object to prevent leaks between messages. + return (object) (array) $user; + } + + /** + * Add the header to this message. + */ + protected function add_message_header() { + $site = get_site(); + + // Set the subject of the message. + $this->postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true)); + + // And the content of the header in body. + $headerdata = (object) [ + 'sitename' => format_string($site->fullname, true), + 'userprefs' => (new \moodle_url('/user/forum.php', [ + 'id' => $this->recipient->id, + 'course' => $site->id, + ]))->out(false), + ]; + + $this->notificationtext .= get_string('digestmailheader', 'forum', $headerdata) . "\n"; + + if ($this->allowhtml) { + $headerdata->userprefs = html_writer::link($headerdata->userprefs, get_string('digestmailprefs', 'forum'), [ + 'target' => '_blank', + ]); + + $this->notificationhtml .= html_writer::tag('p', get_string('digestmailheader', 'forum', $headerdata)); + $this->notificationhtml .= html_writer::empty_tag('br'); + $this->notificationhtml .= html_writer::empty_tag('hr', [ + 'size' => 1, + 'noshade' => 'noshade', + ]); + } + } + + /** + * Add the header for this discussion. + * + * @param \stdClass $discussion The discussion to add the footer for + * @param \stdClass $forum The forum that the discussion belongs to + * @param \stdClass $course The course that the forum belongs to + */ + protected function add_discussion_header($discussion, $forum, $course) { + global $CFG; + + $shortname = format_string($course->shortname, true, [ + 'context' => \context_course::instance($course->id), + ]); + + $strforums = get_string('forums', 'forum'); + + $this->discussiontext .= "\n=====================================================================\n\n"; + $this->discussiontext .= "$shortname -> $strforums -> " . format_string($forum->name, true); + if ($discussion->name != $forum->name) { + $this->discussiontext .= " -> " . format_string($discussion->name, true); + } + $this->discussiontext .= "\n"; + $this->discussiontext .= new \moodle_url('/mod/forum/discuss.php', [ + 'd' => $discussion->id, + ]); + $this->discussiontext .= "\n"; + + if ($this->allowhtml) { + $this->discussionhtml .= "

". + "wwwroot/course/view.php?id=$course->id\">$shortname -> ". + "wwwroot/mod/forum/index.php?id=$course->id\">$strforums -> ". + "wwwroot/mod/forum/view.php?f=$forum->id\">" . + format_string($forum->name, true).""; + if ($discussion->name == $forum->name) { + $this->discussionhtml .= "

"; + } else { + $this->discussionhtml .= + " -> wwwroot/mod/forum/discuss.php?d=$discussion->id\">" . + format_string($discussion->name, true)."

"; + } + $this->discussionhtml .= '

'; + } + + } + + /** + * Add the body of this post. + * + * @param \stdClass $author The author of the post + * @param \stdClass $post The post being sent + * @param \stdClass $discussion The discussion that the post is in + * @param \stdClass $forum The forum that the discussion belongs to + * @param \cminfo $cm The cminfo object for the forum + * @param \stdClass $course The course that the forum belongs to + */ + protected function add_post_body($author, $post, $discussion, $forum, $cm, $course) { + global $CFG; + + $canreply = $this->canpostto[$discussion->id]; + + $data = new \mod_forum\output\forum_post_email( + $course, + $cm, + $forum, + $discussion, + $post, + $author, + $this->recipient, + $canreply + ); + + // Override the viewfullnames value. + $data->viewfullnames = $this->viewfullnames[$forum->id]; + + // Determine the type of digest being sent. + $maildigest = $this->get_maildigest($forum->id); + + $textrenderer = $this->get_renderer($maildigest); + $this->discussiontext .= $textrenderer->render($data); + $this->discussiontext .= "\n"; + if ($this->allowhtml) { + $htmlrenderer = $this->get_renderer($maildigest, true); + $this->discussionhtml .= $htmlrenderer->render($data); + $this->log("Adding post {$post->id} in format {$maildigest} with HTML", 2); + } else { + $this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2); + } + + if ($maildigest == 1 && $CFG->forum_usermarksread) { + // Create an array of postid's for this user to mark as read. + $this->markpostsasread[] = $post->id; + } + + } + + /** + * Add the footer for this discussion. + * + * @param \stdClass $discussion The discussion to add the footer for + */ + protected function add_discussion_footer($discussion) { + global $CFG; + + if ($this->allowhtml) { + $footerlinks = []; + + $forum = $this->forums[$discussion->forum]; + if (\mod_forum\subscriptions::is_forcesubscribed($forum)) { + // This forum is force subscribed. The user cannot unsubscribe. + $footerlinks[] = get_string("everyoneissubscribed", "forum"); + } else { + $footerlinks[] = "wwwroot/mod/forum/subscribe.php?id=$forum->id\">" . + get_string("unsubscribe", "forum") . ""; + } + $footerlinks[] = "" . + get_string("digestmailpost", "forum") . ''; + + $this->discussionhtml .= "\n

" . + implode(' ', $footerlinks) . '
'; + $this->discussionhtml .= '

'; + } + } + + /** + * Get the forum digest type for the specified forum, failing back to + * the default setting for the current user if not specified. + * + * @param int $forumid + * @return int + */ + protected function get_maildigest($forumid) { + $maildigest = -1; + + if (isset($this->forumdigesttypes[$forumid])) { + $maildigest = $this->forumdigesttypes[$forumid]; + } + + if ($maildigest === -1 && !empty($this->recipient->maildigest)) { + $maildigest = $this->recipient->maildigest; + } + + if ($maildigest === -1) { + // There is no maildigest type right now. + $maildigest = 1; + } + + return $maildigest; + } + + /** + * Send the composed message to the user. + */ + protected function send_mail() { + // Headers to help prevent auto-responders. + $userfrom = \core_user::get_noreply_user(); + $userfrom->customheaders = array( + "Precedence: Bulk", + 'X-Auto-Response-Suppress: All', + 'Auto-Submitted: auto-generated', + ); + + $eventdata = new \core\message\message(); + $eventdata->courseid = SITEID; + $eventdata->component = 'mod_forum'; + $eventdata->name = 'digests'; + $eventdata->userfrom = $userfrom; + $eventdata->userto = $this->recipient; + $eventdata->subject = $this->postsubject; + $eventdata->fullmessage = $this->notificationtext; + $eventdata->fullmessageformat = FORMAT_PLAIN; + $eventdata->fullmessagehtml = $this->notificationhtml; + $eventdata->notification = 1; + $eventdata->smallmessage = get_string('smallmessagedigest', 'forum', $this->sentcount); + + return message_send($eventdata); + } + + /** + * Helper to fetch the required renderer, instantiating as required. + * + * @param int $maildigest The type of mail digest being sent + * @param bool $html Whether to fetch the HTML renderer + * @return \core_renderer + */ + protected function get_renderer($maildigest, $html = false) { + global $PAGE; + + $type = $maildigest == 2 ? 'emaildigestbasic' : 'emaildigestfull'; + $target = $html ? 'htmlemail' : 'textemail'; + + if (!isset($this->renderers[$target][$type])) { + $this->renderers[$target][$type] = $PAGE->get_renderer('mod_forum', $type, $target); + } + + return $this->renderers[$target][$type]; + } +} diff --git a/mod/forum/classes/task/send_user_notifications.php b/mod/forum/classes/task/send_user_notifications.php new file mode 100644 index 00000000000..cd146d57b58 --- /dev/null +++ b/mod/forum/classes/task/send_user_notifications.php @@ -0,0 +1,534 @@ +. + +/** + * This file defines an adhoc task to send notifications. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_forum\task; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Adhoc task to send user forum notifications. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class send_user_notifications extends \core\task\adhoc_task { + + // Use the logging trait to get some nice, juicy, logging. + use \core\task\logging_trait; + + /** + * @var \stdClass A shortcut to $USER. + */ + protected $recipient; + + /** + * @var \stdClass[] List of courses the messages are in, indexed by courseid. + */ + protected $courses = []; + + /** + * @var \stdClass[] List of forums the messages are in, indexed by courseid. + */ + protected $forums = []; + + /** + * @var int[] List of IDs for forums in each course. + */ + protected $courseforums = []; + + /** + * @var \stdClass[] List of discussions the messages are in, indexed by forumid. + */ + protected $discussions = []; + + /** + * @var \stdClass[] List of IDs for discussions in each forum. + */ + protected $forumdiscussions = []; + + /** + * @var \stdClass[] List of posts the messages are in, indexed by discussionid. + */ + protected $posts = []; + + /** + * @var bool[] Whether the user can view fullnames for each forum. + */ + protected $viewfullnames = []; + + /** + * @var bool[] Whether the user can post in each discussion. + */ + protected $canpostto = []; + + /** + * @var \renderer[] The renderers. + */ + protected $renderers = []; + + /** + * @var \core\message\inbound\address_manager The inbound message address manager. + */ + protected $inboundmanager; + + /** + * Send out messages. + */ + public function execute() { + global $CFG; + + $this->recipient = \core_user::get_user($this->get_userid()); + + // Create the generic messageinboundgenerator. + $this->inboundmanager = new \core\message\inbound\address_manager(); + $this->inboundmanager->set_handler('\mod_forum\message\inbound\reply_handler'); + + $data = $this->get_custom_data(); + + $this->prepare_data((array) $data); + + $markposts = []; + $errorcount = 0; + $sentcount = 0; + $this->log_start("Sending messages to {$this->recipient->username} ({$this->recipient->id})"); + foreach ($this->courses as $course) { + $coursecontext = \context_course::instance($course->id); + if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) { + // The course is hidden and the user does not have access to it. + // Permissions may have changed since it was queued. + continue; + } + foreach ($this->courseforums[$course->id] as $forumid) { + $forum = $this->forums[$forumid]; + + $cm = get_fast_modinfo($course)->instances['forum'][$forumid]; + $modcontext = \context_module::instance($cm->id); + + foreach (array_values($this->forumdiscussions[$forumid]) as $discussionid) { + $discussion = $this->discussions[$discussionid]; + + if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) { + // User cannot see this discussion. + // Permissions may have changed since it was queued. + continue; + } + + if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussionid, $cm)) { + // The user does not subscribe to this forum as a whole, or to this specific discussion. + continue; + } + + foreach ($this->posts[$discussionid] as $post) { + if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) { + // User cannot see this post. + // Permissions may have changed since it was queued. + continue; + } + + if ($this->send_post($course, $forum, $discussion, $post, $cm, $modcontext)) { + $this->log("Post {$post->id} sent", 1); + // Mark post as read if forum_usermarksread is set off. + if (!$CFG->forum_usermarksread) { + $markposts[$post->id] = true; + } + $sentcount++; + } else { + $this->log("Failed to send post {$post->id}", 1); + $errorcount++; + } + } + } + } + } + + $this->log_finish("Sent {$sentcount} messages with {$errorcount} failures"); + if (!empty($markposts)) { + if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) { + $this->log_start("Marking posts as read"); + $count = count($markposts); + forum_tp_mark_posts_read($this->recipient, $markposts); + $this->log_finish("Marked {$count} posts as read"); + } + } + } + + /** + * Prepare all data for this run. + * + * Take all post ids, and fetch the relevant authors, discussions, forums, and courses for them. + * + * @param int[] $postids The list of post IDs + */ + protected function prepare_data(array $postids) { + global $DB; + + if (empty($postids)) { + return; + } + + list($in, $params) = $DB->get_in_or_equal(array_values($postids)); + $sql = "SELECT p.*, f.id AS forum, f.course + FROM {forum_posts} p + INNER JOIN {forum_discussions} d ON d.id = p.discussion + INNER JOIN {forum} f ON f.id = d.forum + WHERE p.id {$in}"; + + $posts = $DB->get_recordset_sql($sql, $params); + $discussionids = []; + $forumids = []; + $courseids = []; + $userids = []; + foreach ($posts as $post) { + $discussionids[] = $post->discussion; + $forumids[] = $post->forum; + $courseids[] = $post->course; + $userids[] = $post->userid; + unset($post->forum); + if (!isset($this->posts[$post->discussion])) { + $this->posts[$post->discussion] = []; + } + $this->posts[$post->discussion][$post->id] = $post; + } + $posts->close(); + + // Fetch all discussions. + list($in, $params) = $DB->get_in_or_equal(array_values($discussionids)); + $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params); + foreach ($this->discussions as $discussion) { + if (empty($this->forumdiscussions[$discussion->forum])) { + $this->forumdiscussions[$discussion->forum] = []; + } + $this->forumdiscussions[$discussion->forum][] = $discussion->id; + } + + // Fetch all forums. + list($in, $params) = $DB->get_in_or_equal(array_values($forumids)); + $this->forums = $DB->get_records_select('forum', "id {$in}", $params); + foreach ($this->forums as $forum) { + if (empty($this->courseforums[$forum->course])) { + $this->courseforums[$forum->course] = []; + } + $this->courseforums[$forum->course][] = $forum->id; + } + + // Fetch all courses. + list($in, $params) = $DB->get_in_or_equal(array_values($courseids)); + $this->courses = $DB->get_records_select('course', "id $in", $params); + + // Fetch all authors. + list($in, $params) = $DB->get_in_or_equal(array_values($userids)); + $users = $DB->get_recordset_select('user', "id $in", $params); + foreach ($users as $user) { + $this->minimise_user_record($user); + $this->users[$user->id] = $user; + } + $users->close(); + + // Fill subscription caches for each forum. + // These are per-user. + foreach (array_values($forumids) as $id) { + \mod_forum\subscriptions::fill_subscription_cache($id); + \mod_forum\subscriptions::fill_discussion_subscription_cache($id); + } + } + + /** + * Send the specified post for the current user. + * + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $discussion + * @param \stdClass $post + * @param \stdClass $cm + * @param \context $context + */ + protected function send_post($course, $forum, $discussion, $post, $cm, $context) { + global $CFG; + + $author = $this->get_post_author($post->userid, $course, $forum, $cm, $context); + if (empty($author)) { + return false; + } + + // Prepare to actually send the post now, and build up the content. + $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name))); + + $shortname = format_string($course->shortname, true, [ + 'context' => \context_course::instance($course->id), + ]); + + // Generate a reply-to address from using the Inbound Message handler. + $replyaddress = $this->get_reply_address($course, $forum, $discussion, $post, $cm, $context); + + $data = new \mod_forum\output\forum_post_email( + $course, + $cm, + $forum, + $discussion, + $post, + $author, + $this->recipient, + $this->can_post($course, $forum, $discussion, $post, $cm, $context) + ); + $data->viewfullnames = $this->can_view_fullnames($course, $forum, $discussion, $post, $cm, $context); + + // Not all of these variables are used in the default string but are made available to support custom subjects. + $site = get_site(); + $a = (object) [ + 'subject' => $data->get_subject(), + 'forumname' => $cleanforumname, + 'sitefullname' => format_string($site->fullname), + 'siteshortname' => format_string($site->shortname), + 'courseidnumber' => $data->get_courseidnumber(), + 'coursefullname' => $data->get_coursefullname(), + 'courseshortname' => $data->get_coursename(), + ]; + $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0); + + // Message headers are stored against the message author. + $author->customheaders = $this->get_message_headers($course, $forum, $discussion, $post, $a, $data); + + $eventdata = new \core\message\message(); + $eventdata->courseid = $course->id; + $eventdata->component = 'mod_forum'; + $eventdata->name = 'posts'; + $eventdata->userfrom = $author; + $eventdata->userto = $this->recipient; + $eventdata->subject = $postsubject; + $eventdata->fullmessage = $this->get_renderer()->render($data); + $eventdata->fullmessageformat = FORMAT_PLAIN; + $eventdata->fullmessagehtml = $this->get_renderer(true)->render($data); + $eventdata->notification = 1; + $eventdata->replyto = $replyaddress; + if (!empty($replyaddress)) { + // Add extra text to email messages if they can reply back. + $eventdata->set_additional_content('email', [ + 'fullmessage' => [ + 'footer' => "\n\n" . get_string('replytopostbyemail', 'mod_forum'), + ], + 'fullmessagehtml' => [ + 'footer' => \html_writer::tag('p', get_string('replytopostbyemail', 'mod_forum')), + ] + ]); + } + + $eventdata->smallmessage = get_string('smallmessage', 'forum', (object) [ + 'user' => fullname($author), + 'forumname' => "$shortname: " . format_string($forum->name, true) . ": " . $discussion->name, + 'message' => $post->message, + ]); + + $contexturl = new \moodle_url('/mod/forum/discuss.php', ['d' => $discussion->id], "p{$post->id}"); + $eventdata->contexturl = $contexturl->out(); + $eventdata->contexturlname = $discussion->name; + + return message_send($eventdata); + } + + /** + * Fetch and initialise the post author. + * + * @param int $userid The id of the user to fetch + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $cm + * @param \context $context + * @return \stdClass + */ + protected function get_post_author($userid, $course, $forum, $cm, $context) { + if (!isset($this->users[$userid])) { + // This user no longer exists. + return false; + } + + $user = $this->users[$userid]; + + if (!isset($user->groups)) { + // Initialise the groups list. + $user->groups = []; + } + + if (!isset($user->groups[$forum->id])) { + $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid); + } + + // Clone the user object to prevent leaks between messages. + return (object) (array) $user; + } + + /** + * Helper to fetch the required renderer, instantiating as required. + * + * @param bool $html Whether to fetch the HTML renderer + * @return \core_renderer + */ + protected function get_renderer($html = false) { + global $PAGE; + + $target = $html ? 'htmlemail' : 'textemail'; + + if (!isset($this->renderers[$target])) { + $this->renderers[$target] = $PAGE->get_renderer('mod_forum', 'email', $target); + } + + return $this->renderers[$target]; + } + + /** + * Get the list of message headers. + * + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $discussion + * @param \stdClass $post + * @param \stdClass $a The list of strings for this post + * @param \core\message\message $message The message to be sent + * @return \stdClass + */ + protected function get_message_headers($course, $forum, $discussion, $post, $a, $message) { + $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name))); + $viewurl = new \moodle_url('/mod/forum/view.php', ['f' => $forum->id]); + + $headers = [ + // Headers to make emails easier to track. + 'List-Id: "' . $cleanforumname . '" ' . generate_email_messageid('moodleforum' . $forum->id), + 'List-Help: ' . $viewurl->out(), + 'Message-ID: ' . forum_get_email_message_id($post->id, $this->recipient->id), + 'X-Course-Id: ' . $course->id, + 'X-Course-Name: '. format_string($course->fullname, true), + + // Headers to help prevent auto-responders. + 'Precedence: Bulk', + 'X-Auto-Response-Suppress: All', + 'Auto-Submitted: auto-generated', + 'List-Unsubscribe: <' . $message->get_unsubscribediscussionlink() . '>', + ]; + + $rootid = forum_get_email_message_id($discussion->firstpost, $this->recipient->id); + + if ($post->parent) { + // This post is a reply, so add reply header (RFC 2822). + $parentid = forum_get_email_message_id($post->parent, $this->recipient->id); + $headers[] = "In-Reply-To: $parentid"; + + // If the post is deeply nested we also reference the parent message id and + // the root message id (if different) to aid threading when parts of the email + // conversation have been deleted (RFC1036). + if ($post->parent != $discussion->firstpost) { + $headers[] = "References: $rootid $parentid"; + } else { + $headers[] = "References: $parentid"; + } + } + + // MS Outlook / Office uses poorly documented and non standard headers, including + // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc. + $aclone = (object) (array) $a; + $aclone->subject = $discussion->name; + $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $aclone), 0); + $headers[] = "Thread-Topic: $threadtopic"; + $headers[] = "Thread-Index: " . substr($rootid, 1, 28); + + return $headers; + } + + /** + * Get a no-reply address for this user to reply to the current post. + * + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $discussion + * @param \stdClass $post + * @param \stdClass $cm + * @param \context $context + * @return string + */ + protected function get_reply_address($course, $forum, $discussion, $post, $cm, $context) { + if ($this->can_post($course, $forum, $discussion, $post, $cm, $context)) { + // Generate a reply-to address from using the Inbound Message handler. + $this->inboundmanager->set_data($post->id); + return $this->inboundmanager->generate($this->recipient->id); + } + + // TODO Check if we can return a string. + // This will be controlled by the event. + return null; + } + + /** + * Check whether the user can post. + * + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $discussion + * @param \stdClass $post + * @param \stdClass $cm + * @param \context $context + * @return bool + */ + protected function can_post($course, $forum, $discussion, $post, $cm, $context) { + if (!isset($this->canpostto[$discussion->id])) { + $this->canpostto[$discussion->id] = forum_user_can_post($forum, $discussion, $this->recipient, $cm, $course, $context); + } + return $this->canpostto[$discussion->id]; + } + + /** + * Check whether the user can view full names of other users. + * + * @param \stdClass $course + * @param \stdClass $forum + * @param \stdClass $discussion + * @param \stdClass $post + * @param \stdClass $cm + * @param \context $context + * @return bool + */ + protected function can_view_fullnames($course, $forum, $discussion, $post, $cm, $context) { + if (!isset($this->viewfullnames[$forum->id])) { + $this->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $context, $this->recipient->id); + } + + return $this->viewfullnames[$forum->id]; + } + + /** + * Removes properties from user record that are not necessary for sending post notifications. + * + * @param \stdClass $user + */ + protected function minimise_user_record(\stdClass $user) { + // We store large amount of users in one huge array, make sure we do not store info there we do not actually + // need in mail generation code or messaging. + unset($user->institution); + unset($user->department); + unset($user->address); + unset($user->city); + unset($user->url); + unset($user->currentlogin); + unset($user->description); + unset($user->descriptionformat); + } +} diff --git a/mod/forum/deprecatedlib.php b/mod/forum/deprecatedlib.php index be682bd5fa4..2ca2ee7a56a 100644 --- a/mod/forum/deprecatedlib.php +++ b/mod/forum/deprecatedlib.php @@ -372,3 +372,41 @@ function forum_make_mail_post($course, $cm, $forum, $discussion, $post, $userfro return $renderer->render($renderable); } + +/** + * Removes properties from user record that are not necessary for sending post notifications. + * + * @param stdClass $user + * @return void, $user parameter is modified + * @deprecated since Moodle 3.7 + */ +function forum_cron_minimise_user_record(stdClass $user) { + debugging("forum_cron_minimise_user_record() has been deprecated and has not been replaced.", + DEBUG_DEVELOPER); + + // We store large amount of users in one huge array, + // make sure we do not store info there we do not actually need + // in mail generation code or messaging. + + unset($user->institution); + unset($user->department); + unset($user->address); + unset($user->city); + unset($user->url); + unset($user->currentlogin); + unset($user->description); + unset($user->descriptionformat); +} + +/** + * Function to be run periodically according to the scheduled task. + * + * Finds all posts that have yet to be mailed out, and mails them out to all subscribers as well as other maintance + * tasks. + * + * @deprecated since Moodle 3.7 + */ +function forum_cron() { + debugging("forum_cron() has been deprecated and replaced with new tasks. Please uses these instead.", + DEBUG_DEVELOPER); +} diff --git a/mod/forum/lib.php b/mod/forum/lib.php index 50e28663a48..39c27ccd95e 100644 --- a/mod/forum/lib.php +++ b/mod/forum/lib.php @@ -423,829 +423,6 @@ function forum_get_email_message_id($postid, $usertoid) { return generate_email_messageid(hash('sha256', $postid . 'to' . $usertoid)); } -/** - * Removes properties from user record that are not necessary - * for sending post notifications. - * @param stdClass $user - * @return void, $user parameter is modified - */ -function forum_cron_minimise_user_record(stdClass $user) { - - // We store large amount of users in one huge array, - // make sure we do not store info there we do not actually need - // in mail generation code or messaging. - - unset($user->institution); - unset($user->department); - unset($user->address); - unset($user->city); - unset($user->url); - unset($user->currentlogin); - unset($user->description); - unset($user->descriptionformat); -} - -/** - * Function to be run periodically according to the scheduled task. - * - * Finds all posts that have yet to be mailed out, and mails them - * out to all subscribers as well as other maintance tasks. - * - * NOTE: Since 2.7.2 this function is run by scheduled task rather - * than standard cron. - * - * @todo MDL-44734 The function will be split up into seperate tasks. - */ -function forum_cron() { - global $CFG, $USER, $DB, $PAGE; - - $site = get_site(); - - // The main renderers. - $htmlout = $PAGE->get_renderer('mod_forum', 'email', 'htmlemail'); - $textout = $PAGE->get_renderer('mod_forum', 'email', 'textemail'); - $htmldigestfullout = $PAGE->get_renderer('mod_forum', 'emaildigestfull', 'htmlemail'); - $textdigestfullout = $PAGE->get_renderer('mod_forum', 'emaildigestfull', 'textemail'); - $htmldigestbasicout = $PAGE->get_renderer('mod_forum', 'emaildigestbasic', 'htmlemail'); - $textdigestbasicout = $PAGE->get_renderer('mod_forum', 'emaildigestbasic', 'textemail'); - - // All users that are subscribed to any post that needs sending, - // please increase $CFG->extramemorylimit on large sites that - // send notifications to a large number of users. - $users = array(); - $userscount = 0; // Cached user counter - count($users) in PHP is horribly slow!!! - - // Status arrays. - $mailcount = array(); - $errorcount = array(); - - // caches - $discussions = array(); - $forums = array(); - $courses = array(); - $coursemodules = array(); - $subscribedusers = array(); - $messageinboundhandlers = array(); - - // Posts older than 2 days will not be mailed. This is to avoid the problem where - // cron has not been running for a long time, and then suddenly people are flooded - // with mail from the past few weeks or months - $timenow = time(); - $endtime = $timenow - $CFG->maxeditingtime; - $starttime = $endtime - 48 * 3600; // Two days earlier - - // Get the list of forum subscriptions for per-user per-forum maildigest settings. - $digestsset = $DB->get_recordset('forum_digests', null, '', 'id, userid, forum, maildigest'); - $digests = array(); - foreach ($digestsset as $thisrow) { - if (!isset($digests[$thisrow->forum])) { - $digests[$thisrow->forum] = array(); - } - $digests[$thisrow->forum][$thisrow->userid] = $thisrow->maildigest; - } - $digestsset->close(); - - // Create the generic messageinboundgenerator. - $messageinboundgenerator = new \core\message\inbound\address_manager(); - $messageinboundgenerator->set_handler('\mod_forum\message\inbound\reply_handler'); - - if ($posts = forum_get_unmailed_posts($starttime, $endtime, $timenow)) { - // Mark them all now as being mailed. It's unlikely but possible there - // might be an error later so that a post is NOT actually mailed out, - // but since mail isn't crucial, we can accept this risk. Doing it now - // prevents the risk of duplicated mails, which is a worse problem. - - if (!forum_mark_old_posts_as_mailed($endtime)) { - mtrace('Errors occurred while trying to mark some posts as being mailed.'); - return false; // Don't continue trying to mail them, in case we are in a cron loop - } - - // checking post validity, and adding users to loop through later - foreach ($posts as $pid => $post) { - - $discussionid = $post->discussion; - if (!isset($discussions[$discussionid])) { - if ($discussion = $DB->get_record('forum_discussions', array('id'=> $post->discussion))) { - $discussions[$discussionid] = $discussion; - \mod_forum\subscriptions::fill_subscription_cache($discussion->forum); - \mod_forum\subscriptions::fill_discussion_subscription_cache($discussion->forum); - - } else { - mtrace('Could not find discussion ' . $discussionid); - unset($posts[$pid]); - continue; - } - } - $forumid = $discussions[$discussionid]->forum; - if (!isset($forums[$forumid])) { - if ($forum = $DB->get_record('forum', array('id' => $forumid))) { - $forums[$forumid] = $forum; - } else { - mtrace('Could not find forum '.$forumid); - unset($posts[$pid]); - continue; - } - } - $courseid = $forums[$forumid]->course; - if (!isset($courses[$courseid])) { - if ($course = $DB->get_record('course', array('id' => $courseid))) { - $courses[$courseid] = $course; - } else { - mtrace('Could not find course '.$courseid); - unset($posts[$pid]); - continue; - } - } - if (!isset($coursemodules[$forumid])) { - if ($cm = get_coursemodule_from_instance('forum', $forumid, $courseid)) { - $coursemodules[$forumid] = $cm; - } else { - mtrace('Could not find course module for forum '.$forumid); - unset($posts[$pid]); - continue; - } - } - - $modcontext = context_module::instance($coursemodules[$forumid]->id); - - // Save the Inbound Message datakey here to reduce DB queries later. - $messageinboundgenerator->set_data($pid); - $messageinboundhandlers[$pid] = $messageinboundgenerator->fetch_data_key(); - - // Caching subscribed users of each forum. - if (!isset($subscribedusers[$forumid])) { - if ($subusers = \mod_forum\subscriptions::fetch_subscribed_users($forums[$forumid], 0, $modcontext, 'u.*', true)) { - - foreach ($subusers as $postuser) { - // this user is subscribed to this forum - $subscribedusers[$forumid][$postuser->id] = $postuser->id; - $userscount++; - if ($userscount > FORUM_CRON_USER_CACHE) { - // Store minimal user info. - $minuser = new stdClass(); - $minuser->id = $postuser->id; - $users[$postuser->id] = $minuser; - } else { - // Cache full user record. - forum_cron_minimise_user_record($postuser); - $users[$postuser->id] = $postuser; - } - } - // Release memory. - unset($subusers); - unset($postuser); - } - } - $mailcount[$pid] = 0; - $errorcount[$pid] = 0; - } - } - - if ($users && $posts) { - - foreach ($users as $userto) { - // Terminate if processing of any account takes longer than 2 minutes. - core_php_time_limit::raise(120); - - mtrace('Processing user ' . $userto->id); - - // Init user caches - we keep the cache for one cycle only, otherwise it could consume too much memory. - if (isset($userto->username)) { - $userto = clone($userto); - } else { - $userto = $DB->get_record('user', array('id' => $userto->id)); - forum_cron_minimise_user_record($userto); - } - $userto->viewfullnames = array(); - $userto->canpost = array(); - $userto->markposts = array(); - - // Setup this user so that the capabilities are cached, and environment matches receiving user. - cron_setup_user($userto); - - // Reset the caches. - foreach ($coursemodules as $forumid => $unused) { - $coursemodules[$forumid]->cache = new stdClass(); - $coursemodules[$forumid]->cache->caps = array(); - unset($coursemodules[$forumid]->uservisible); - } - - foreach ($posts as $pid => $post) { - $discussion = $discussions[$post->discussion]; - $forum = $forums[$discussion->forum]; - $course = $courses[$forum->course]; - $cm =& $coursemodules[$forum->id]; - - // Do some checks to see if we can bail out now. - - // Only active enrolled users are in the list of subscribers. - // This does not necessarily mean that the user is subscribed to the forum or to the discussion though. - if (!isset($subscribedusers[$forum->id][$userto->id])) { - // The user does not subscribe to this forum. - continue; - } - - if (!\mod_forum\subscriptions::is_subscribed($userto->id, $forum, $post->discussion, $coursemodules[$forum->id])) { - // The user does not subscribe to this forum, or to this specific discussion. - continue; - } - - if ($subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $userto->id)) { - // Skip posts if the user subscribed to the discussion after it was created. - if (isset($subscriptiontime[$post->discussion]) && ($subscriptiontime[$post->discussion] > $post->created)) { - continue; - } - } - - $coursecontext = context_course::instance($course->id); - if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext, $userto->id)) { - // The course is hidden and the user does not have access to it. - continue; - } - - // Don't send email if the forum is Q&A and the user has not posted. - // Initial topics are still mailed. - if ($forum->type == 'qanda' && !forum_get_user_posted_time($discussion->id, $userto->id) && $pid != $discussion->firstpost) { - mtrace('Did not email ' . $userto->id.' because user has not posted in discussion'); - continue; - } - - // Get info about the sending user. - if (array_key_exists($post->userid, $users)) { - // We might know the user already. - $userfrom = $users[$post->userid]; - if (!isset($userfrom->idnumber)) { - // Minimalised user info, fetch full record. - $userfrom = $DB->get_record('user', array('id' => $userfrom->id)); - forum_cron_minimise_user_record($userfrom); - } - - } else if ($userfrom = $DB->get_record('user', array('id' => $post->userid))) { - forum_cron_minimise_user_record($userfrom); - // Fetch only once if possible, we can add it to user list, it will be skipped anyway. - if ($userscount <= FORUM_CRON_USER_CACHE) { - $userscount++; - $users[$userfrom->id] = $userfrom; - } - } else { - mtrace('Could not find user ' . $post->userid . ', author of post ' . $post->id . '. Unable to send message.'); - continue; - } - - // Note: If we want to check that userto and userfrom are not the same person this is probably the spot to do it. - - // Setup global $COURSE properly - needed for roles and languages. - cron_setup_user($userto, $course); - - // Fill caches. - if (!isset($userto->viewfullnames[$forum->id])) { - $modcontext = context_module::instance($cm->id); - $userto->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext); - } - if (!isset($userto->canpost[$discussion->id])) { - $modcontext = context_module::instance($cm->id); - $userto->canpost[$discussion->id] = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext); - } - if (!isset($userfrom->groups[$forum->id])) { - if (!isset($userfrom->groups)) { - $userfrom->groups = array(); - if (isset($users[$userfrom->id])) { - $users[$userfrom->id]->groups = array(); - } - } - $userfrom->groups[$forum->id] = groups_get_all_groups($course->id, $userfrom->id, $cm->groupingid); - if (isset($users[$userfrom->id])) { - $users[$userfrom->id]->groups[$forum->id] = $userfrom->groups[$forum->id]; - } - } - - // Make sure groups allow this user to see this email. - if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) { - // Groups are being used. - if (!groups_group_exists($discussion->groupid)) { - // Can't find group - be safe and don't this message. - continue; - } - - if (!groups_is_member($discussion->groupid) and !has_capability('moodle/site:accessallgroups', $modcontext)) { - // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS. - continue; - } - } - - // Make sure we're allowed to see the post. - if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) { - mtrace('User ' . $userto->id .' can not see ' . $post->id . '. Not sending message.'); - continue; - } - - // OK so we need to send the email. - - // Does the user want this post in a digest? If so postpone it for now. - $maildigest = forum_get_user_maildigest_bulk($digests, $userto, $forum->id); - - if ($maildigest > 0) { - // This user wants the mails to be in digest form. - $queue = new stdClass(); - $queue->userid = $userto->id; - $queue->discussionid = $discussion->id; - $queue->postid = $post->id; - $queue->timemodified = $post->created; - $DB->insert_record('forum_queue', $queue); - continue; - } - - // Prepare to actually send the post now, and build up the content. - - $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name))); - - $userfrom->customheaders = array ( - // Headers to make emails easier to track. - 'List-Id: "' . $cleanforumname . '" ' . generate_email_messageid('moodleforum' . $forum->id), - 'List-Help: ' . $CFG->wwwroot . '/mod/forum/view.php?f=' . $forum->id, - 'Message-ID: ' . forum_get_email_message_id($post->id, $userto->id), - 'X-Course-Id: ' . $course->id, - 'X-Course-Name: ' . format_string($course->fullname, true), - - // Headers to help prevent auto-responders. - 'Precedence: Bulk', - 'X-Auto-Response-Suppress: All', - 'Auto-Submitted: auto-generated', - ); - - $shortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id))); - - // Generate a reply-to address from using the Inbound Message handler. - $replyaddress = null; - if ($userto->canpost[$discussion->id] && array_key_exists($post->id, $messageinboundhandlers)) { - $messageinboundgenerator->set_data($post->id, $messageinboundhandlers[$post->id]); - $replyaddress = $messageinboundgenerator->generate($userto->id); - } - - if (!isset($userto->canpost[$discussion->id])) { - $canreply = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext); - } else { - $canreply = $userto->canpost[$discussion->id]; - } - - $data = new \mod_forum\output\forum_post_email( - $course, - $cm, - $forum, - $discussion, - $post, - $userfrom, - $userto, - $canreply - ); - - $userfrom->customheaders[] = sprintf('List-Unsubscribe: <%s>', - $data->get_unsubscribediscussionlink()); - - if (!isset($userto->viewfullnames[$forum->id])) { - $data->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $userto->id); - } else { - $data->viewfullnames = $userto->viewfullnames[$forum->id]; - } - - // Not all of these variables are used in the default language - // string but are made available to support custom subjects. - $a = new stdClass(); - $a->subject = $data->get_subject(); - $a->forumname = $cleanforumname; - $a->sitefullname = format_string($site->fullname); - $a->siteshortname = format_string($site->shortname); - $a->courseidnumber = $data->get_courseidnumber(); - $a->coursefullname = $data->get_coursefullname(); - $a->courseshortname = $data->get_coursename(); - $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0); - - $rootid = forum_get_email_message_id($discussion->firstpost, $userto->id); - - if ($post->parent) { - // This post is a reply, so add reply header (RFC 2822). - $parentid = forum_get_email_message_id($post->parent, $userto->id); - $userfrom->customheaders[] = "In-Reply-To: $parentid"; - - // If the post is deeply nested we also reference the parent message id and - // the root message id (if different) to aid threading when parts of the email - // conversation have been deleted (RFC1036). - if ($post->parent != $discussion->firstpost) { - $userfrom->customheaders[] = "References: $rootid $parentid"; - } else { - $userfrom->customheaders[] = "References: $parentid"; - } - } - - // MS Outlook / Office uses poorly documented and non standard headers, including - // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc. - $a->subject = $discussion->name; - $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $a), 0); - $userfrom->customheaders[] = "Thread-Topic: $threadtopic"; - $userfrom->customheaders[] = "Thread-Index: " . substr($rootid, 1, 28); - - // Send the post now! - mtrace('Sending ', ''); - - $eventdata = new \core\message\message(); - $eventdata->courseid = $course->id; - $eventdata->component = 'mod_forum'; - $eventdata->name = 'posts'; - $eventdata->userfrom = $userfrom; - $eventdata->userto = $userto; - $eventdata->subject = $postsubject; - $eventdata->fullmessage = $textout->render($data); - $eventdata->fullmessageformat = FORMAT_PLAIN; - $eventdata->fullmessagehtml = $htmlout->render($data); - $eventdata->notification = 1; - $eventdata->replyto = $replyaddress; - if (!empty($replyaddress)) { - // Add extra text to email messages if they can reply back. - $textfooter = "\n\n" . get_string('replytopostbyemail', 'mod_forum'); - $htmlfooter = html_writer::tag('p', get_string('replytopostbyemail', 'mod_forum')); - $additionalcontent = array('fullmessage' => array('footer' => $textfooter), - 'fullmessagehtml' => array('footer' => $htmlfooter)); - $eventdata->set_additional_content('email', $additionalcontent); - } - - $smallmessagestrings = new stdClass(); - $smallmessagestrings->user = fullname($userfrom); - $smallmessagestrings->forumname = "$shortname: " . format_string($forum->name, true) . ": " . $discussion->name; - $smallmessagestrings->message = $post->message; - - // Make sure strings are in message recipients language. - $eventdata->smallmessage = get_string_manager()->get_string('smallmessage', 'forum', $smallmessagestrings, $userto->lang); - - $contexturl = new moodle_url('/mod/forum/discuss.php', array('d' => $discussion->id), 'p' . $post->id); - $eventdata->contexturl = $contexturl->out(); - $eventdata->contexturlname = $discussion->name; - - $mailresult = message_send($eventdata); - if (!$mailresult) { - mtrace("Error: mod/forum/lib.php forum_cron(): Could not send out mail for id $post->id to user $userto->id". - " ($userto->email) .. not trying again."); - $errorcount[$post->id]++; - } else { - $mailcount[$post->id]++; - - // Mark post as read if forum_usermarksread is set off. - if (!$CFG->forum_usermarksread) { - $userto->markposts[$post->id] = $post->id; - } - } - - mtrace('post ' . $post->id . ': ' . $post->subject); - } - - // Mark processed posts as read. - if (get_user_preferences('forum_markasreadonnotification', 1, $userto->id) == 1) { - forum_tp_mark_posts_read($userto, $userto->markposts); - } - - unset($userto); - } - } - - if ($posts) { - foreach ($posts as $post) { - mtrace($mailcount[$post->id]." users were sent post $post->id, '$post->subject'"); - if ($errorcount[$post->id]) { - $DB->set_field('forum_posts', 'mailed', FORUM_MAILED_ERROR, array('id' => $post->id)); - } - } - } - - // release some memory - unset($subscribedusers); - unset($mailcount); - unset($errorcount); - - cron_setup_user(); - - $sitetimezone = core_date::get_server_timezone(); - - // Now see if there are any digest mails waiting to be sent, and if we should send them - - mtrace('Starting digest processing...'); - - core_php_time_limit::raise(300); // terminate if not able to fetch all digests in 5 minutes - - if (!isset($CFG->digestmailtimelast)) { // To catch the first time - set_config('digestmailtimelast', 0); - } - - $timenow = time(); - $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600); - - // Delete any really old ones (normally there shouldn't be any) - $weekago = $timenow - (7 * 24 * 3600); - $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago)); - mtrace ('Cleaned old digest records'); - - if ($CFG->digestmailtimelast < $digesttime and $timenow > $digesttime) { - - mtrace('Sending forum digests: '.userdate($timenow, '', $sitetimezone)); - - $digestposts_rs = $DB->get_recordset_select('forum_queue', "timemodified < ?", array($digesttime)); - - if ($digestposts_rs->valid()) { - - // We have work to do - $usermailcount = 0; - - //caches - reuse the those filled before too - $discussionposts = array(); - $userdiscussions = array(); - - foreach ($digestposts_rs as $digestpost) { - if (!isset($posts[$digestpost->postid])) { - if ($post = $DB->get_record('forum_posts', array('id' => $digestpost->postid))) { - $posts[$digestpost->postid] = $post; - } else { - continue; - } - } - $discussionid = $digestpost->discussionid; - if (!isset($discussions[$discussionid])) { - if ($discussion = $DB->get_record('forum_discussions', array('id' => $discussionid))) { - $discussions[$discussionid] = $discussion; - } else { - continue; - } - } - $forumid = $discussions[$discussionid]->forum; - if (!isset($forums[$forumid])) { - if ($forum = $DB->get_record('forum', array('id' => $forumid))) { - $forums[$forumid] = $forum; - } else { - continue; - } - } - - $courseid = $forums[$forumid]->course; - if (!isset($courses[$courseid])) { - if ($course = $DB->get_record('course', array('id' => $courseid))) { - $courses[$courseid] = $course; - } else { - continue; - } - } - - if (!isset($coursemodules[$forumid])) { - if ($cm = get_coursemodule_from_instance('forum', $forumid, $courseid)) { - $coursemodules[$forumid] = $cm; - } else { - continue; - } - } - $userdiscussions[$digestpost->userid][$digestpost->discussionid] = $digestpost->discussionid; - $discussionposts[$digestpost->discussionid][$digestpost->postid] = $digestpost->postid; - } - $digestposts_rs->close(); /// Finished iteration, let's close the resultset - - // Data collected, start sending out emails to each user - foreach ($userdiscussions as $userid => $thesediscussions) { - - core_php_time_limit::raise(120); // terminate if processing of any account takes longer than 2 minutes - - cron_setup_user(); - - mtrace(get_string('processingdigest', 'forum', $userid), '... '); - - // First of all delete all the queue entries for this user - $DB->delete_records_select('forum_queue', "userid = ? AND timemodified < ?", array($userid, $digesttime)); - - // Init user caches - we keep the cache for one cycle only, - // otherwise it would unnecessarily consume memory. - if (array_key_exists($userid, $users) and isset($users[$userid]->username)) { - $userto = clone($users[$userid]); - } else { - $userto = $DB->get_record('user', array('id' => $userid)); - forum_cron_minimise_user_record($userto); - } - $userto->viewfullnames = array(); - $userto->canpost = array(); - $userto->markposts = array(); - - // Override the language and timezone of the "current" user, so that - // mail is customised for the receiver. - cron_setup_user($userto); - - $postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true)); - - $headerdata = new stdClass(); - $headerdata->sitename = format_string($site->fullname, true); - $headerdata->userprefs = $CFG->wwwroot.'/user/forum.php?id='.$userid.'&course='.$site->id; - - $posttext = get_string('digestmailheader', 'forum', $headerdata)."\n\n"; - $headerdata->userprefs = ''.get_string('digestmailprefs', 'forum').''; - - $posthtml = '

'.get_string('digestmailheader', 'forum', $headerdata).'

' - . '

'; - - foreach ($thesediscussions as $discussionid) { - - core_php_time_limit::raise(120); // to be reset for each post - - $discussion = $discussions[$discussionid]; - $forum = $forums[$discussion->forum]; - $course = $courses[$forum->course]; - $cm = $coursemodules[$forum->id]; - - //override language - cron_setup_user($userto, $course); - - // Fill caches - if (!isset($userto->viewfullnames[$forum->id])) { - $modcontext = context_module::instance($cm->id); - $userto->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext); - } - if (!isset($userto->canpost[$discussion->id])) { - $modcontext = context_module::instance($cm->id); - $userto->canpost[$discussion->id] = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext); - } - - $strforums = get_string('forums', 'forum'); - $canunsubscribe = ! \mod_forum\subscriptions::is_forcesubscribed($forum); - $canreply = $userto->canpost[$discussion->id]; - $shortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id))); - - $posttext .= "\n \n"; - $posttext .= '====================================================================='; - $posttext .= "\n \n"; - $posttext .= "$shortname -> $strforums -> ".format_string($forum->name,true); - if ($discussion->name != $forum->name) { - $posttext .= " -> ".format_string($discussion->name,true); - } - $posttext .= "\n"; - $posttext .= $CFG->wwwroot.'/mod/forum/discuss.php?d='.$discussion->id; - $posttext .= "\n"; - - $posthtml .= "

". - "wwwroot/course/view.php?id=$course->id\">$shortname -> ". - "wwwroot/mod/forum/index.php?id=$course->id\">$strforums -> ". - "wwwroot/mod/forum/view.php?f=$forum->id\">".format_string($forum->name,true).""; - if ($discussion->name == $forum->name) { - $posthtml .= "

"; - } else { - $posthtml .= " -> wwwroot/mod/forum/discuss.php?d=$discussion->id\">".format_string($discussion->name,true)."

"; - } - $posthtml .= '

'; - - $postsarray = $discussionposts[$discussionid]; - sort($postsarray); - $sentcount = 0; - - foreach ($postsarray as $postid) { - $post = $posts[$postid]; - - if (array_key_exists($post->userid, $users)) { // we might know him/her already - $userfrom = $users[$post->userid]; - if (!isset($userfrom->idnumber)) { - $userfrom = $DB->get_record('user', array('id' => $userfrom->id)); - forum_cron_minimise_user_record($userfrom); - } - - } else if ($userfrom = $DB->get_record('user', array('id' => $post->userid))) { - forum_cron_minimise_user_record($userfrom); - if ($userscount <= FORUM_CRON_USER_CACHE) { - $userscount++; - $users[$userfrom->id] = $userfrom; - } - - } else { - mtrace('Could not find user '.$post->userid); - continue; - } - - if (!isset($userfrom->groups[$forum->id])) { - if (!isset($userfrom->groups)) { - $userfrom->groups = array(); - if (isset($users[$userfrom->id])) { - $users[$userfrom->id]->groups = array(); - } - } - $userfrom->groups[$forum->id] = groups_get_all_groups($course->id, $userfrom->id, $cm->groupingid); - if (isset($users[$userfrom->id])) { - $users[$userfrom->id]->groups[$forum->id] = $userfrom->groups[$forum->id]; - } - } - - // Headers to help prevent auto-responders. - $userfrom->customheaders = array( - "Precedence: Bulk", - 'X-Auto-Response-Suppress: All', - 'Auto-Submitted: auto-generated', - ); - - $maildigest = forum_get_user_maildigest_bulk($digests, $userto, $forum->id); - if (!isset($userto->canpost[$discussion->id])) { - $canreply = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext); - } else { - $canreply = $userto->canpost[$discussion->id]; - } - - $data = new \mod_forum\output\forum_post_email( - $course, - $cm, - $forum, - $discussion, - $post, - $userfrom, - $userto, - $canreply - ); - - if (!isset($userto->viewfullnames[$forum->id])) { - $data->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $userto->id); - } else { - $data->viewfullnames = $userto->viewfullnames[$forum->id]; - } - - if ($maildigest == 2) { - // Subjects and link only. - $posttext .= $textdigestbasicout->render($data); - $posthtml .= $htmldigestbasicout->render($data); - } else { - // The full treatment. - $posttext .= $textdigestfullout->render($data); - $posthtml .= $htmldigestfullout->render($data); - - // Create an array of postid's for this user to mark as read. - if (!$CFG->forum_usermarksread) { - $userto->markposts[$post->id] = $post->id; - } - } - $sentcount++; - } - $footerlinks = array(); - if ($canunsubscribe) { - $footerlinks[] = "wwwroot/mod/forum/subscribe.php?id=$forum->id\">" . get_string("unsubscribe", "forum") . ""; - } else { - $footerlinks[] = get_string("everyoneissubscribed", "forum"); - } - $footerlinks[] = "" . get_string("digestmailpost", "forum") . ''; - $posthtml .= "\n

" . implode(' ', $footerlinks) . '
'; - $posthtml .= '

'; - } - - if (empty($userto->mailformat) || $userto->mailformat != 1) { - // This user DOESN'T want to receive HTML - $posthtml = ''; - } - - $eventdata = new \core\message\message(); - $eventdata->courseid = SITEID; - $eventdata->component = 'mod_forum'; - $eventdata->name = 'digests'; - $eventdata->userfrom = core_user::get_noreply_user(); - $eventdata->userto = $userto; - $eventdata->subject = $postsubject; - $eventdata->fullmessage = $posttext; - $eventdata->fullmessageformat = FORMAT_PLAIN; - $eventdata->fullmessagehtml = $posthtml; - $eventdata->notification = 1; - $eventdata->smallmessage = get_string('smallmessagedigest', 'forum', $sentcount); - $mailresult = message_send($eventdata); - - if (!$mailresult) { - mtrace("ERROR: mod/forum/cron.php: Could not send out digest mail to user $userto->id ". - "($userto->email)... not trying again."); - } else { - mtrace("success."); - $usermailcount++; - - // Mark post as read if forum_usermarksread is set off - if (get_user_preferences('forum_markasreadonnotification', 1, $userto->id) == 1) { - forum_tp_mark_posts_read($userto, $userto->markposts); - } - } - } - } - /// We have finishied all digest emails, update $CFG->digestmailtimelast - set_config('digestmailtimelast', $timenow); - } - - cron_setup_user(); - - if (!empty($usermailcount)) { - mtrace(get_string('digestsentusers', 'forum', $usermailcount)); - } - - if (!empty($CFG->forum_lastreadclean)) { - $timenow = time(); - if ($CFG->forum_lastreadclean + (24*3600) < $timenow) { - set_config('forum_lastreadclean', $timenow); - mtrace('Removing old forum read tracking info...'); - forum_tp_clean_read_records(); - } - } else { - set_config('forum_lastreadclean', time()); - } - - return true; -} - /** * * @param object $course diff --git a/mod/forum/templates/forum_post_emaildigestfull_textemail.mustache b/mod/forum/templates/forum_post_emaildigestfull_textemail.mustache index 2cec6e7ebec..1c2251a0dc7 100644 --- a/mod/forum/templates/forum_post_emaildigestfull_textemail.mustache +++ b/mod/forum/templates/forum_post_emaildigestfull_textemail.mustache @@ -44,7 +44,8 @@ } {{/ str }} --------------------------------------------------------------------- {{{ message }}} - +{{# attachments }} {{{ attachments }}} +{{/ attachments }} --------------------------------------------------------------------- {{# str }} digestmailpostlink, forum, {{{ forumindexlink }}} {{/ str }} diff --git a/mod/forum/tests/cron_trait.php b/mod/forum/tests/cron_trait.php new file mode 100644 index 00000000000..c68306fe7c1 --- /dev/null +++ b/mod/forum/tests/cron_trait.php @@ -0,0 +1,129 @@ +. + +/** + * The forum module cron trait. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +trait mod_forum_tests_cron_trait { + /** + * Run the main cron task to queue all tasks, and ensure that posts + * were sent to the correct users. + * + * @param \stdClass[] $expectations The list of users, along with their expected count of messages and digests. + */ + protected function queue_tasks_and_assert($expectations = []) { + global $DB; + + // Note, we cannot use expectOutputRegex because it only allows for a single RegExp. + ob_start(); + cron_setup_user(); + $cron = new \mod_forum\task\cron_task(); + $cron->execute(); + $output = ob_get_contents(); + ob_end_clean(); + + $uniqueusers = 0; + foreach ($expectations as $expect) { + $expect->digests = isset($expect->digests) ? $expect->digests : 0; + $expect->messages = isset($expect->messages) ? $expect->messages : 0; + $expect->mentioned = isset($expect->mentioned) ? $expect->mentioned : false; + if ($expect->digests || $expect->messages) { + $expect->mentioned = true; + } + if (!$expect->mentioned) { + $this->assertNotRegExp("/Queued 0 for {$expect->userid}/", $output); + } else { + $uniqueusers++; + $this->assertRegExp( + "/Queued {$expect->digests} digests and {$expect->messages} messages for {$expect->userid}/", + $output + ); + } + } + + if (empty($expectations)) { + $this->assertRegExp("/No posts found./", $output); + } else { + $this->assertRegExp("/Unique users: {$uniqueusers}/", $output); + } + + // Update the forum queue for digests. + $DB->execute("UPDATE {forum_queue} SET timemodified = timemodified - 1"); + } + + /** + * Run any send_user_notifications tasks for the specified user, and + * ensure that the posts specified were sent. + * + * @param \stdClass $user + * @param \stdClass[] $posts + * @param bool $ignoreemptyposts + */ + protected function send_notifications_and_assert($user, $posts = [], $ignoreemptyposts = false) { + ob_start(); + $this->runAdhocTasks(\mod_forum\task\send_user_notifications::class, $user->id); + $output = ob_get_contents(); + ob_end_clean(); + + if (empty($posts) && !$ignoreemptyposts) { + $this->assertEquals('', $output); + } else { + $this->assertRegExp("/Sending messages to {$user->username}/", $output); + foreach ($posts as $post) { + $this->assertRegExp("/Post {$post->id} sent/", $output); + } + $count = count($posts); + $this->assertRegExp("/Sent {$count} messages with 0 failures/", $output); + } + } + + /** + * Run any send_user_digests tasks for the specified user, and + * ensure that the posts specified were sent. + * + * @param \stdClass $user + * @param \stdClass[] $fullposts + * @param \stdClass[] $shortposts + */ + protected function send_digests_and_assert($user, $fullposts = [], $shortposts = []) { + ob_start(); + $this->runAdhocTasks(\mod_forum\task\send_user_digests::class, $user->id); + $output = ob_get_contents(); + ob_end_clean(); + + if (empty($shortposts) && empty($fullposts)) { + $this->assertEquals('', $output); + $this->assertRegExp("/Digest sent with 0 messages./", $output); + } else { + $this->assertRegExp("/Sending forum digests for {$user->username}/", $output); + foreach ($fullposts as $post) { + $this->assertRegExp("/Adding post {$post->id} in format 1/", $output); + } + foreach ($shortposts as $post) { + $this->assertRegExp("/Adding post {$post->id} in format 2/", $output); + } + $count = count($fullposts) + count($shortposts); + $this->assertRegExp("/Digest sent with {$count} messages./", $output); + } + } +} diff --git a/mod/forum/tests/generator_trait.php b/mod/forum/tests/generator_trait.php new file mode 100644 index 00000000000..4c536ab0b7f --- /dev/null +++ b/mod/forum/tests/generator_trait.php @@ -0,0 +1,159 @@ +. + +/** + * The forum module trait with additional generator helpers. + * + * @package mod_forum + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +trait mod_forum_tests_generator_trait { + + /** + * Helper to create the required number of users in the specified course. + * Users are enrolled as students by default. + * + * @param stdClass $course The course object + * @param integer $count The number of users to create + * @param string $role The role to assign users as + * @return array The users created + */ + protected function helper_create_users($course, $count, $role = null) { + $users = array(); + + for ($i = 0; $i < $count; $i++) { + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id, $role); + $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 $fields any other fields in discussion (name, message, messageformat, ...) + * @return array An array containing the discussion object, and the post object + */ + protected function helper_post_to_forum($forum, $author, $fields = array()) { + 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 = (object)$fields; + $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_group_test.php b/mod/forum/tests/mail_group_test.php new file mode 100644 index 00000000000..904151f01cd --- /dev/null +++ b/mod/forum/tests/mail_group_test.php @@ -0,0 +1,246 @@ +. + +/** + * The forum module mail generation tests for groups. + * + * @package mod_forum + * @copyright 2013 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/forum/lib.php'); +require_once(__DIR__ . '/cron_trait.php'); +require_once(__DIR__ . '/generator_trait.php'); + +/** + * The forum module mail generation tests for groups. + * + * @copyright 2013 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_forum_mail_group_testcase extends advanced_testcase { + // Make use of the cron tester trait. + use mod_forum_tests_cron_trait; + + // Make use of the test generator trait. + use mod_forum_tests_generator_trait; + + /** + * @var \phpunit_message_sink + */ + protected $messagesink; + + /** + * @var \phpunit_mailer_sink + */ + protected $mailsink; + + public function setUp() { + global $CFG; + + // We must clear the subscription caches. This has to be done both before each test, and after in case of other + // tests using these functions. + \mod_forum\subscriptions::reset_forum_cache(); + \mod_forum\subscriptions::reset_discussion_cache(); + + // Messaging is not compatible with transactions... + $this->preventResetByRollback(); + + // Catch all messages. + $this->messagesink = $this->redirectMessages(); + $this->mailsink = $this->redirectEmails(); + + // Forcibly reduce the maxeditingtime to a second in the past to + // ensure that messages are sent out. + $CFG->maxeditingtime = -1; + } + + public function tearDown() { + // We must clear the subscription caches. This has to be done both before each test, and after in case of other + // tests using these functions. + \mod_forum\subscriptions::reset_forum_cache(); + + $this->messagesink->clear(); + $this->messagesink->close(); + unset($this->messagesink); + + $this->mailsink->clear(); + $this->mailsink->close(); + unset($this->mailsink); + } + + /** + * Ensure that posts written in a forum marked for separate groups includes notifications for the members of that + * group, and any user with accessallgroups. + */ + public function test_separate_group() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + 'forcesubscribe' => FORUM_INITIALSUBSCRIBE, + 'groupmode' => SEPARATEGROUPS, + ]); + + // Create three students: + // - author, enrolled in group A; and + // - recipient, enrolled in group B; and + // - other, enrolled in the course, but no groups. + list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3); + + // Create one teacher, not in any group and no accessallgroups capability. + list($teacher) = $this->helper_create_users($course, 1, 'teacher'); + + // Create one editing teacher, not in any group but with accessallgroups capability. + list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher'); + + $groupa = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $groupb = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupa->id, + 'userid' => $author->id, + ]); + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupb->id, + 'userid' => $recipient->id, + ]); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author, [ + 'groupid' => $groupa->id, + ]); + + // Only the author should receive. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + 'otheruser' => (object) [ + 'userid' => $otheruser->id, + 'messages' => 0, + ], + 'teacher' => (object) [ + 'userid' => $teacher->id, + 'messages' => 0, + ], + 'editingteacher' => (object) [ + 'userid' => $editingteacher->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // No notifications should be queued. + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($recipient, []); + $this->send_notifications_and_assert($otheruser, []); + $this->send_notifications_and_assert($teacher, []); + $this->send_notifications_and_assert($editingteacher, [$post]); + } + + /** + * Ensure that posts written in a forum marked for visible groups includes notifications for the members of that + * group, and any user with accessallgroups. + */ + public function test_visible_group() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + 'forcesubscribe' => FORUM_INITIALSUBSCRIBE, + 'groupmode' => VISIBLEGROUPS, + ]); + + // Create three students: + // - author, enrolled in group A; and + // - recipient, enrolled in group B; and + // - other, enrolled in the course, but no groups. + list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3); + + // Create one teacher, not in any group and no accessallgroups capability. + list($teacher) = $this->helper_create_users($course, 1, 'teacher'); + + // Create one editing teacher, not in any group but with accessallgroups capability. + list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher'); + + $groupa = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $groupb = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupa->id, + 'userid' => $author->id, + ]); + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupb->id, + 'userid' => $recipient->id, + ]); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author, [ + 'groupid' => $groupa->id, + ]); + + // Only the author should receive. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + 'otheruser' => (object) [ + 'userid' => $otheruser->id, + 'messages' => 0, + ], + 'teacher' => (object) [ + 'userid' => $teacher->id, + 'messages' => 0, + ], + 'editingteacher' => (object) [ + 'userid' => $editingteacher->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // No notifications should be queued. + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($recipient, []); + $this->send_notifications_and_assert($otheruser, []); + $this->send_notifications_and_assert($teacher, []); + $this->send_notifications_and_assert($editingteacher, [$post]); + } +} diff --git a/mod/forum/tests/mail_test.php b/mod/forum/tests/mail_test.php index d233252f84d..957a4edcc9d 100644 --- a/mod/forum/tests/mail_test.php +++ b/mod/forum/tests/mail_test.php @@ -26,42 +26,45 @@ defined('MOODLE_INTERNAL') || die(); global $CFG; +require_once($CFG->dirroot . '/mod/forum/lib.php'); +require_once(__DIR__ . '/cron_trait.php'); +require_once(__DIR__ . '/generator_trait.php'); class mod_forum_mail_testcase extends advanced_testcase { + // Make use of the cron tester trait. + use mod_forum_tests_cron_trait; + // Make use of the test generator trait. + use mod_forum_tests_generator_trait; - protected $helper; + /** + * @var \phpunit_message_sink + */ + protected $messagesink; + + /** + * @var \phpunit_mailer_sink + */ + protected $mailsink; public function setUp() { + global $CFG; + // We must clear the subscription caches. This has to be done both before each test, and after in case of other // tests using these functions. \mod_forum\subscriptions::reset_forum_cache(); \mod_forum\subscriptions::reset_discussion_cache(); - global $CFG; - require_once($CFG->dirroot . '/mod/forum/lib.php'); - - $helper = new stdClass(); - // Messaging is not compatible with transactions... $this->preventResetByRollback(); // Catch all messages. - $helper->messagesink = $this->redirectMessages(); - $helper->mailsink = $this->redirectEmails(); - - // Confirm that we have an empty message sink so far. - $messages = $helper->messagesink->get_messages(); - $this->assertEquals(0, count($messages)); - - $messages = $helper->mailsink->get_messages(); - $this->assertEquals(0, count($messages)); + $this->messagesink = $this->redirectMessages(); + $this->mailsink = $this->redirectEmails(); // Forcibly reduce the maxeditingtime to a second in the past to // ensure that messages are sent out. $CFG->maxeditingtime = -1; - - $this->helper = $helper; } public function tearDown() { @@ -69,11 +72,13 @@ class mod_forum_mail_testcase extends advanced_testcase { // tests using these functions. \mod_forum\subscriptions::reset_forum_cache(); - $this->helper->messagesink->clear(); - $this->helper->messagesink->close(); + $this->messagesink->clear(); + $this->messagesink->close(); + unset($this->messagesink); - $this->helper->mailsink->clear(); - $this->helper->mailsink->close(); + $this->mailsink->clear(); + $this->mailsink->close(); + unset($this->mailsink); } /** @@ -93,166 +98,6 @@ class mod_forum_mail_testcase extends advanced_testcase { $record->id = $DB->update_record('messageinbound_handlers', $record); } - /** - * 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 $fields any other fields in discussion (name, message, messageformat, ...) - * @param array An array containing the discussion object, and the post object - */ - protected function helper_post_to_forum($forum, $author, $fields = array()) { - 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 = (object)$fields; - $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; - } - - /** - * Run the forum cron, and check that the specified post was sent the - * specified number of times. - * - * @param stdClass $post The forum post object - * @param integer $expected The number of times that the post should have been sent - * @return array An array of the messages caught by the message sink - */ - protected function helper_run_cron_check_count($post, $expected) { - - // Clear the sinks before running cron. - $this->helper->messagesink->clear(); - $this->helper->mailsink->clear(); - - // Cron daily uses mtrace, turn on buffering to silence output. - $this->expectOutputRegex("/{$expected} users were sent post {$post->id}, '{$post->subject}'/"); - forum_cron(); - - // Now check the results in the message sink. - $messages = $this->helper->messagesink->get_messages(); - - // There should be the expected number of messages. - $this->assertEquals($expected, count($messages)); - - return $messages; - } - - /** - * Run the forum cron, and check that the specified posts were sent the - * specified number of times. - * - * @param stdClass $post The forum post object - * @param integer $expected The number of times that the post should have been sent - * @return array An array of the messages caught by the message sink - */ - protected function helper_run_cron_check_counts($posts, $expected) { - - // Clear the sinks before running cron. - $this->helper->messagesink->clear(); - $this->helper->mailsink->clear(); - - // Cron daily uses mtrace, turn on buffering to silence output. - foreach ($posts as $post) { - $this->expectOutputRegex("/{$post['count']} users were sent post {$post['id']}, '{$post['subject']}'/"); - } - forum_cron(); - - // Now check the results in the message sink. - $messages = $this->helper->messagesink->get_messages(); - - // There should be the expected number of messages. - $this->assertEquals($expected, count($messages)); - - return $messages; - } - public function test_cron_message_includes_courseid() { $this->resetAfterTest(true); @@ -268,25 +113,22 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // Run cron and check that \core\event\message_sent contains the course id. - // Close the message sink so that message_send is run. - $this->helper->messagesink->close(); + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Catch just the cron events. For each message sent two events are fired: - // core\event\message_sent - // core\event\message_viewed. - $this->helper->eventsink = $this->redirectEvents(); - $this->expectOutputRegex('/Processing user/'); - - forum_cron(); - - // Get the events and close the sink so that remaining events can be triggered. - $events = $this->helper->eventsink->get_events(); - $this->helper->eventsink->close(); - - // Reset the message sink for other tests. - $this->helper->messagesink = $this->redirectMessages(); - // Notification has been marked as read, so now first event should be a 'notification_viewed' one. + $this->messagesink->close(); + $this->eventsink = $this->redirectEvents(); + $this->send_notifications_and_assert($author, [$post]); + $events = $this->eventsink->get_events(); $event = reset($events); $this->assertInstanceOf('\core\event\notification_viewed', $event); @@ -294,6 +136,8 @@ class mod_forum_mail_testcase extends advanced_testcase { $event = $events[1]; $this->assertInstanceOf('\core\event\notification_sent', $event); $this->assertEquals($course->id, $event->other['courseid']); + + $this->send_notifications_and_assert($recipient, [$post]); } public function test_forced_subscription() { @@ -311,31 +155,26 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // We expect both users to receive this post. - $expected = 2; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we saw messages for both users. - $this->assertTrue($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($recipient, [$post]); } - public function test_subscription_disabled() { + /** + * Ensure that for a forum with subscription disabled that standard users will not receive posts. + */ + public function test_subscription_disabled_standard_users() { global $DB; $this->resetAfterTest(true); @@ -352,50 +191,119 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // We expect both users to receive this post. - $expected = 0; - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); + } + + /** + * Ensure that for a forum with subscription disabled that a user subscribed to the forum will receive the post. + */ + public function test_subscription_disabled_user_subscribed_forum() { + global $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); // A user with the manageactivities capability within the course can subscribe. - $expected = 1; $roleids = $DB->get_records_menu('role', null, '', 'shortname, id'); assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleids['student'], context_course::instance($course->id)); + + // Suscribe the recipient only. \mod_forum\subscriptions::subscribe_user($recipient->id, $forum); - $this->assertEquals($expected, $DB->count_records('forum_subscriptions', array( + $this->assertEquals(1, $DB->count_records('forum_subscriptions', array( 'userid' => $recipient->id, 'forum' => $forum->id, ))); + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + // Run cron and check that the expected number of users received the notification. - list($discussion, $post) = $this->helper_post_to_forum($forum, $recipient); - $messages = $this->helper_run_cron_check_count($post, $expected); + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Unsubscribe the user again. - \mod_forum\subscriptions::unsubscribe_user($recipient->id, $forum); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$post]); + } - $expected = 0; - $this->assertEquals($expected, $DB->count_records('forum_subscriptions', array( - 'userid' => $recipient->id, - 'forum' => $forum->id, - ))); + /** + * Ensure that for a forum with subscription disabled that a user subscribed to the discussion will receive the + * post. + */ + public function test_subscription_disabled_user_subscribed_discussion() { + global $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // A user with the manageactivities capability within the course can subscribe. + $roleids = $DB->get_records_menu('role', null, '', 'shortname, id'); + assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleids['student'], context_course::instance($course->id)); // Run cron and check that the expected number of users received the notification. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - $messages = $this->helper_run_cron_check_count($post, $expected); // Subscribe the user to the discussion. \mod_forum\subscriptions::subscribe_user_to_discussion($recipient->id, $discussion); $this->helper_update_subscription_time($recipient, $discussion, -60); - $reply = $this->helper_post_to_discussion($forum, $discussion, $author); - $this->helper_update_post_time($reply, -30); + // Run cron and check that the expected number of users received the notification. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - $messages = $this->helper_run_cron_check_count($reply, $expected); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$post]); } + /** + * Ensure that for a forum with automatic subscription that users receive posts. + */ public function test_automatic() { $this->resetAfterTest(true); @@ -411,28 +319,20 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // We expect both users to receive this post. - $expected = 2; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we saw messages for both users. - $this->assertTrue($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($recipient, [$post]); } public function test_optional() { @@ -450,11 +350,20 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // We expect both users to receive this post. - $expected = 0; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); } public function test_automatic_with_unsubscribed_user() { @@ -475,28 +384,20 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // We expect only one user to receive this post. - $expected = 1; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we only saw one user. - $this->assertFalse($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$post]); } public function test_optional_with_subscribed_user() { @@ -517,28 +418,20 @@ class mod_forum_mail_testcase extends advanced_testcase { // Post a discussion to the forum. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - // We expect only one user to receive this post. - $expected = 1; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we only saw one user. - $this->assertFalse($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$post]); } public function test_automatic_with_unsubscribed_discussion() { @@ -562,28 +455,20 @@ class mod_forum_mail_testcase extends advanced_testcase { $this->assertFalse(\mod_forum\subscriptions::is_subscribed($author->id, $forum, $discussion->id)); $this->assertTrue(\mod_forum\subscriptions::is_subscribed($recipient->id, $forum, $discussion->id)); - // We expect only one user to receive this post. - $expected = 1; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we only saw one user. - $this->assertFalse($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$post]); } public function test_optional_with_subscribed_discussion() { @@ -608,37 +493,40 @@ class mod_forum_mail_testcase extends advanced_testcase { // Initially we don't expect any user to receive this post as you cannot subscribe to a discussion until after // you have read it. - $expected = 0; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); // Have a user reply to the discussion. $reply = $this->helper_post_to_discussion($forum, $discussion, $author); $this->helper_update_post_time($reply, -30); // We expect only one user to receive this post. - $expected = 1; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($reply, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we only saw one user. - $this->assertFalse($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$reply]); } public function test_automatic_with_subscribed_discussion_in_unsubscribed_forum() { @@ -664,56 +552,39 @@ class mod_forum_mail_testcase extends advanced_testcase { \mod_forum\subscriptions::subscribe_user_to_discussion($author->id, $discussion); $this->helper_update_subscription_time($author, $discussion, -60); - // We expect just the user subscribed to the forum to receive this post at the moment as the discussion - // subscription time is after the post time. - $expected = 1; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we only saw one user. - $this->assertFalse($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$post]); // Now post a reply to the original post. $reply = $this->helper_post_to_discussion($forum, $discussion, $author); $this->helper_update_post_time($reply, -30); - // We expect two users to receive this post. - $expected = 2; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($reply, $expected); - - $seenauthor = false; - $seenrecipient = false; - foreach ($messages as $message) { - // They should both be from our user. - $this->assertEquals($author->id, $message->useridfrom); - - if ($message->useridto == $author->id) { - $seenauthor = true; - } else if ($message->useridto = $recipient->id) { - $seenrecipient = true; - } - } - - // Check we saw both users. - $this->assertTrue($seenauthor); - $this->assertTrue($seenrecipient); + $this->send_notifications_and_assert($author, [$reply]); + $this->send_notifications_and_assert($recipient, [$reply]); } public function test_optional_with_unsubscribed_discussion_in_subscribed_forum() { @@ -738,10 +609,20 @@ class mod_forum_mail_testcase extends advanced_testcase { \mod_forum\subscriptions::unsubscribe_user_from_discussion($recipient->id, $discussion); // We don't expect any users to receive this post. - $expected = 0; + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + ]; + $this->queue_tasks_and_assert($expect); - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, $expected); + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); } /** @@ -780,16 +661,15 @@ class mod_forum_mail_testcase extends advanced_testcase { $reply = $this->helper_post_to_discussion($forum, $discussion, $author); $this->helper_update_post_time($reply, -30); - $expectedmessages[] = array( - 'id' => $reply->id, - 'subject' => $reply->subject, - 'count' => 1, - ); + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); - $expectedcount = 1; - - // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_counts($expectedmessages, $expectedcount); + $this->send_notifications_and_assert($author, [$reply]); } public function test_forum_message_inbound_multiple_posts() { @@ -809,23 +689,21 @@ class mod_forum_mail_testcase extends advanced_testcase { list($discussion, $post) = $this->helper_post_to_forum($forum, $author); $this->helper_update_post_time($post, -90); - $expectedmessages[] = array( + $expectedmessages[] = (object) [ 'id' => $post->id, 'subject' => $post->subject, 'count' => 0, - ); + ]; // Then post a reply to the first discussion. $reply = $this->helper_post_to_discussion($forum, $discussion, $author); $this->helper_update_post_time($reply, -60); - $expectedmessages[] = array( + $expectedmessages[] = (object) [ 'id' => $reply->id, 'subject' => $reply->subject, 'count' => 1, - ); - - $expectedcount = 2; + ]; // Ensure that messageinbound is enabled and configured for the forum handler. $this->helper_spoof_message_inbound_setup(); @@ -836,19 +714,22 @@ class mod_forum_mail_testcase extends advanced_testcase { // Run cron and check that the expected number of users received the notification. // Clear the mailsink, and close the messagesink. - $this->helper->mailsink->clear(); - $this->helper->messagesink->close(); + $this->mailsink->clear(); + $this->messagesink->close(); - // Cron daily uses mtrace, turn on buffering to silence output. - foreach ($expectedmessages as $post) { - $this->expectOutputRegex("/{$post['count']} users were sent post {$post['id']}, '{$post['subject']}'/"); - } + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => count($expectedmessages), + ], + ]; + $this->queue_tasks_and_assert($expect); - forum_cron(); - $messages = $this->helper->mailsink->get_messages(); + $this->send_notifications_and_assert($author, $expectedmessages); + $messages = $this->mailsink->get_messages(); // There should be the expected number of messages. - $this->assertEquals($expectedcount, count($messages)); + $this->assertEquals(2, count($messages)); foreach ($messages as $message) { $this->assertRegExp('/Reply-To: moodlemoodle123\+[^@]*@example.com/', $message->header); @@ -874,7 +755,16 @@ class mod_forum_mail_testcase extends advanced_testcase { list($discussion, $post) = $this->helper_post_to_forum($forum, $author, array('name' => $subject)); // Run cron and check that the expected number of users received the notification. - $messages = $this->helper_run_cron_check_count($post, 1); + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($author, [$post]); + $messages = $this->messagesink->get_messages(); $message = reset($messages); $this->assertEquals($author->id, $message->useridfrom); $this->assertEquals($expectedsubject, $message->subject); @@ -898,13 +788,44 @@ class mod_forum_mail_testcase extends advanced_testcase { // New posts should not have Re: in the subject. list($discussion, $post) = $this->helper_post_to_forum($forum, $author); - $messages = $this->helper_run_cron_check_count($post, 2); + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'commenter' => (object) [ + 'userid' => $commenter->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($commenter, [$post]); + $messages = $this->messagesink->get_messages(); $this->assertNotContains($strre, $messages[0]->subject); + $this->messagesink->clear(); // Replies should have Re: in the subject. $reply = $this->helper_post_to_discussion($forum, $discussion, $commenter); - $messages = $this->helper_run_cron_check_count($reply, 2); + + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'commenter' => (object) [ + 'userid' => $commenter->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($commenter, [$reply]); + $this->send_notifications_and_assert($author, [$reply]); + $messages = $this->messagesink->get_messages(); $this->assertContains($strre, $messages[0]->subject); + $this->assertContains($strre, $messages[1]->subject); } /** @@ -1127,17 +1048,21 @@ class mod_forum_mail_testcase extends advanced_testcase { // Clear the mailsink and close the messagesink. // (surely setup should provide us this cleared but...) - $this->helper->mailsink->clear(); - $this->helper->messagesink->close(); + $this->mailsink->clear(); + $this->messagesink->close(); - // Capture and silence cron output, verifying contents. - foreach ($posts as $post) { - $this->expectOutputRegex("/1 users were sent post {$post->id}, '{$post->subject}'/"); - } - forum_cron(); // It's really annoying that we have to run cron to test this. + $expect = [ + 'author' => (object) [ + 'userid' => $user->id, + 'messages' => count($posts), + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($user, $posts); // Get the mails. - $mails = $this->helper->mailsink->get_messages(); + $mails = $this->mailsink->get_messages(); // Start testing the expectations. $expectations = $data['expectations']; @@ -1182,7 +1107,301 @@ class mod_forum_mail_testcase extends advanced_testcase { } } } + // Finished, there should not be remaining expectations. $this->assertCount(0, $expectations); } + + /** + * Ensure that posts already mailed are not re-sent. + */ + public function test_already_mailed() { + global $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + $DB->set_field('forum_posts', 'mailed', 1); + + // No posts shoudl be considered. + $this->queue_tasks_and_assert([]); + + // No notifications should be queued. + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); + } + + /** + * Ensure that posts marked mailnow are not suspect to the maxeditingtime. + */ + public function test_mailnow() { + global $CFG, $DB; + + // Update the maxeditingtime to 1 day so that posts won't be sent. + $CFG->maxeditingtime = DAYSECS; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + + // Post a discussion to the forum. + list($discussion, $postmailednow) = $this->helper_post_to_forum($forum, $author, ['mailnow' => 1]); + + // Only the mailnow post should be considered. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // No notifications should be queued. + $this->send_notifications_and_assert($author, [$postmailednow]); + $this->send_notifications_and_assert($recipient, [$postmailednow]); + } + + /** + * Ensure that if a user has no permission to view a post, then it is not sent. + */ + public function test_access_coursemodule_hidden() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Create one users enrolled in the course as an editing teacher. + list($editor) = $this->helper_create_users($course, 1, 'editingteacher'); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + + // Hide the coursemodule. + set_coursemodule_visible($forum->cmid, 0); + + // Only the mailnow post should be considered. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 0, + ], + 'editor' => (object) [ + 'userid' => $editor->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // No notifications should be queued. + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); + $this->send_notifications_and_assert($editor, [$post], true); + } + + /** + * Ensure that if a user loses permission to view a post after it is queued, that it is not sent. + */ + public function test_access_coursemodule_hidden_after_queue() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Create one users enrolled in the course as an editing teacher. + list($editor) = $this->helper_create_users($course, 1, 'editingteacher'); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + + // Only the mailnow post should be considered. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + 'editor' => (object) [ + 'userid' => $editor->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // Hide the coursemodule. + set_coursemodule_visible($forum->cmid, 0); + + // No notifications should be queued for the students. + $this->send_notifications_and_assert($author, [], true); + $this->send_notifications_and_assert($recipient, [], true); + + // The editing teacher should still receive the post. + $this->send_notifications_and_assert($editor, [$post]); + } + + /** + * Ensure that messages are not sent until the timestart. + */ + public function test_access_before_timestart() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Create one users enrolled in the course as an editing teacher. + list($editor) = $this->helper_create_users($course, 1, 'editingteacher'); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + + // Update the discussion to have a timestart in the future. + $DB->set_field('forum_discussions', 'timestart', time() + DAYSECS); + + // None should be sent. + $this->queue_tasks_and_assert([]); + + // No notifications should be queued for any user. + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); + $this->send_notifications_and_assert($editor, []); + + // Update the discussion to have a timestart in the past. + $DB->set_field('forum_discussions', 'timestart', time() - DAYSECS); + + // Now should be sent to all. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + 'editor' => (object) [ + 'userid' => $editor->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // No notifications should be queued for any user. + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($recipient, [$post]); + $this->send_notifications_and_assert($editor, [$post]); + } + + /** + * Ensure that messages are not sent after the timeend. + */ + public function test_access_after_timeend() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Create one users enrolled in the course as an editing teacher. + list($editor) = $this->helper_create_users($course, 1, 'editingteacher'); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + + // Update the discussion to have a timestart in the past. + $DB->set_field('forum_discussions', 'timeend', time() - DAYSECS); + + // None should be sent. + $this->queue_tasks_and_assert([]); + + // No notifications should be queued for any user. + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, []); + $this->send_notifications_and_assert($editor, []); + + // Update the discussion to have a timestart in the past. + $DB->set_field('forum_discussions', 'timeend', time() + DAYSECS); + + // Now should be sent to all. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 1, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + 'editor' => (object) [ + 'userid' => $editor->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + // No notifications should be queued for any user. + $this->send_notifications_and_assert($author, [$post]); + $this->send_notifications_and_assert($recipient, [$post]); + $this->send_notifications_and_assert($editor, [$post]); + } } diff --git a/mod/forum/tests/maildigest_test.php b/mod/forum/tests/maildigest_test.php index 8dbb5e1738f..86a3613d46f 100644 --- a/mod/forum/tests/maildigest_test.php +++ b/mod/forum/tests/maildigest_test.php @@ -26,17 +26,16 @@ defined('MOODLE_INTERNAL') || die(); -global $CFG; +require_once(__DIR__ . '/cron_trait.php'); +require_once(__DIR__ . '/generator_trait.php'); class mod_forum_maildigest_testcase extends advanced_testcase { - /** - * Keep track of the message and mail sinks that we set up for each - * test. - * - * @var stdClass $helper - */ - protected $helper; + // Make use of the cron tester trait. + use mod_forum_tests_cron_trait; + + // Make use of the test generator trait. + use mod_forum_tests_generator_trait; /** * Set up message and mail sinks, and set up other requirements for the @@ -45,20 +44,18 @@ class mod_forum_maildigest_testcase extends advanced_testcase { public function setUp() { global $CFG; - $this->helper = new stdClass(); - // Messaging is not compatible with transactions... $this->preventResetByRollback(); // Catch all messages - $this->helper->messagesink = $this->redirectMessages(); - $this->helper->mailsink = $this->redirectEmails(); + $this->messagesink = $this->redirectMessages(); + $this->mailsink = $this->redirectEmails(); // Confirm that we have an empty message sink so far. - $messages = $this->helper->messagesink->get_messages(); + $messages = $this->messagesink->get_messages(); $this->assertEquals(0, count($messages)); - $messages = $this->helper->mailsink->get_messages(); + $messages = $this->mailsink->get_messages(); $this->assertEquals(0, count($messages)); // Tell Moodle that we've not sent any digest messages out recently. @@ -82,11 +79,11 @@ class mod_forum_maildigest_testcase extends advanced_testcase { * Clear the message sinks set up in this test. */ public function tearDown() { - $this->helper->messagesink->clear(); - $this->helper->messagesink->close(); + $this->messagesink->clear(); + $this->messagesink->close(); - $this->helper->mailsink->clear(); - $this->helper->mailsink->close(); + $this->mailsink->clear(); + $this->mailsink->close(); } /** @@ -131,52 +128,6 @@ class mod_forum_maildigest_testcase extends advanced_testcase { return $return; } - /** - * Helper to falsify all forum post records for a digest run. - */ - protected function helper_force_digest_mail_times() { - global $CFG, $DB; - // Fake all of the post editing times because digests aren't sent until - // the start of an hour where the modification time on the message is before - // the start of that hour - $sitetimezone = core_date::get_server_timezone(); - $digesttime = usergetmidnight(time(), $sitetimezone) + ($CFG->digestmailtime * 3600) - (60 * 60); - $DB->set_field('forum_posts', 'modified', $digesttime, array('mailed' => 0)); - $DB->set_field('forum_posts', 'created', $digesttime, array('mailed' => 0)); - } - - /** - * Run the forum cron, and check that the specified post was sent the - * specified number of times. - * - * @param integer $expected The number of times that the post should have been sent - * @param integer $individualcount The number of individual messages sent - * @param integer $digestcount The number of digest messages sent - */ - protected function helper_run_cron_check_count($expected, $individualcount, $digestcount) { - if ($expected === 0) { - $this->expectOutputRegex('/(Email digests successfully sent to .* users.){0}/'); - } else { - $this->expectOutputRegex("/Email digests successfully sent to {$expected} users/"); - } - forum_cron(); - - // Now check the results in the message sink. - $messages = $this->helper->messagesink->get_messages(); - - $counts = (object) array('digest' => 0, 'individual' => 0); - foreach ($messages as $message) { - if (strpos($message->subject, 'forum digest') !== false) { - $counts->digest++; - } else { - $counts->individual++; - } - } - - $this->assertEquals($digestcount, $counts->digest); - $this->assertEquals($individualcount, $counts->individual); - } - public function test_set_maildigest() { global $DB; @@ -302,10 +253,9 @@ class mod_forum_maildigest_testcase extends advanced_testcase { $this->resetAfterTest(true); - $this->helper_force_digest_mail_times(); - // Initially the forum cron should generate no messages as we've made no posts. - $this->helper_run_cron_check_count(0, 0, 0); + $expect = []; + $this->queue_tasks_and_assert($expect); } /** @@ -324,27 +274,19 @@ class mod_forum_maildigest_testcase extends advanced_testcase { $forum1 = $userhelper->forums->forum1; $forum2 = $userhelper->forums->forum2; - // Add some discussions to the forums. - $record = new stdClass(); - $record->course = $course1->id; - $record->userid = $user->id; - $record->mailnow = 1; - // Add 5 discussions to forum 1. - $record->forum = $forum1->id; + $posts = []; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]); + $posts[] = $post; } // Add 5 discussions to forum 2. - $record->forum = $forum2->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]); + $posts[] = $post; } - // Ensure that the creation times mean that the messages will be sent. - $this->helper_force_digest_mail_times(); - // Set the tested user's default maildigest setting. $DB->set_field('user', 'maildigest', 0, array('id' => $user->id)); @@ -355,7 +297,16 @@ class mod_forum_maildigest_testcase extends advanced_testcase { forum_set_user_maildigest($forum2, -1, $user); // No digests mails should be sent, but 10 forum mails will be sent. - $this->helper_run_cron_check_count(0, 10, 0); + $expect = [ + (object) [ + 'userid' => $user->id, + 'messages' => 10, + 'digests' => 0, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($user, $posts); } /** @@ -373,28 +324,20 @@ class mod_forum_maildigest_testcase extends advanced_testcase { $course1 = $userhelper->courses->course1; $forum1 = $userhelper->forums->forum1; $forum2 = $userhelper->forums->forum2; - - // Add a discussion to the forums. - $record = new stdClass(); - $record->course = $course1->id; - $record->userid = $user->id; - $record->mailnow = 1; + $posts = []; // Add 5 discussions to forum 1. - $record->forum = $forum1->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]); + $posts[] = $post; } // Add 5 discussions to forum 2. - $record->forum = $forum2->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]); + $posts[] = $post; } - // Ensure that the creation times mean that the messages will be sent. - $this->helper_force_digest_mail_times(); - // Set the tested user's default maildigest setting. $DB->set_field('user', 'maildigest', 1, array('id' => $user->id)); @@ -404,8 +347,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase { // Set the maildigest preference for forum2 to default. forum_set_user_maildigest($forum2, -1, $user); - // One digest mail should be sent, with no notifications, and one e-mail. - $this->helper_run_cron_check_count(1, 0, 1); + // No digests mails should be sent, but 10 forum mails will be sent. + $expect = [ + (object) [ + 'userid' => $user->id, + 'messages' => 0, + 'digests' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_digests_and_assert($user, $posts); } /** @@ -424,28 +376,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase { $course1 = $userhelper->courses->course1; $forum1 = $userhelper->forums->forum1; $forum2 = $userhelper->forums->forum2; - - // Add a discussion to the forums. - $record = new stdClass(); - $record->course = $course1->id; - $record->userid = $user->id; - $record->mailnow = 1; + $posts = []; + $digests = []; // Add 5 discussions to forum 1. - $record->forum = $forum1->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]); + $digests[] = $post; } // Add 5 discussions to forum 2. - $record->forum = $forum2->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]); + $posts[] = $post; } - // Ensure that the creation times mean that the messages will be sent. - $this->helper_force_digest_mail_times(); - // Set the tested user's default maildigest setting. $DB->set_field('user', 'maildigest', 0, array('id' => $user->id)); @@ -456,7 +401,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase { forum_set_user_maildigest($forum2, -1, $user); // One digest e-mail should be sent, and five individual notifications. - $this->helper_run_cron_check_count(1, 5, 1); + $expect = [ + (object) [ + 'userid' => $user->id, + 'messages' => 5, + 'digests' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($user, $posts); + $this->send_digests_and_assert($user, $digests); } /** @@ -475,28 +430,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase { $course1 = $userhelper->courses->course1; $forum1 = $userhelper->forums->forum1; $forum2 = $userhelper->forums->forum2; - - // Add a discussion to the forums. - $record = new stdClass(); - $record->course = $course1->id; - $record->userid = $user->id; - $record->mailnow = 1; + $posts = []; + $digests = []; // Add 5 discussions to forum 1. - $record->forum = $forum1->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]); + $digests[] = $post; } // Add 5 discussions to forum 2. - $record->forum = $forum2->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]); + $posts[] = $post; } - // Ensure that the creation times mean that the messages will be sent. - $this->helper_force_digest_mail_times(); - // Set the tested user's default maildigest setting. $DB->set_field('user', 'maildigest', 1, array('id' => $user->id)); @@ -507,7 +455,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase { forum_set_user_maildigest($forum2, 0, $user); // One digest e-mail should be sent, and five individual notifications. - $this->helper_run_cron_check_count(1, 5, 1); + $expect = [ + (object) [ + 'userid' => $user->id, + 'messages' => 5, + 'digests' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($user, $posts); + $this->send_digests_and_assert($user, $digests); } /** @@ -525,28 +483,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase { $course1 = $userhelper->courses->course1; $forum1 = $userhelper->forums->forum1; $forum2 = $userhelper->forums->forum2; - - // Add a discussion to the forums. - $record = new stdClass(); - $record->course = $course1->id; - $record->userid = $user->id; - $record->mailnow = 1; + $fulldigests = []; + $shortdigests = []; // Add 5 discussions to forum 1. - $record->forum = $forum1->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]); + $fulldigests[] = $post; } // Add 5 discussions to forum 2. - $record->forum = $forum2->id; for ($i = 0; $i < 5; $i++) { - $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record); + list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]); + $shortdigests[] = $post; } - // Ensure that the creation times mean that the messages will be sent. - $this->helper_force_digest_mail_times(); - // Set the tested user's default maildigest setting. $DB->set_field('user', 'maildigest', 0, array('id' => $user->id)); @@ -557,7 +508,14 @@ class mod_forum_maildigest_testcase extends advanced_testcase { forum_set_user_maildigest($forum2, 2, $user); // One digest e-mail should be sent, and no individual notifications. - $this->helper_run_cron_check_count(1, 0, 1); - } + $expect = [ + (object) [ + 'userid' => $user->id, + 'digests' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + $this->send_digests_and_assert($user, $fulldigests, $shortdigests); + } } diff --git a/mod/forum/tests/qanda_test.php b/mod/forum/tests/qanda_test.php new file mode 100644 index 00000000000..417ea2a8482 --- /dev/null +++ b/mod/forum/tests/qanda_test.php @@ -0,0 +1,153 @@ +. + +/** + * The forum module mail generation tests for groups. + * + * @package mod_forum + * @copyright 2013 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/forum/lib.php'); +require_once(__DIR__ . '/cron_trait.php'); +require_once(__DIR__ . '/generator_trait.php'); + +/** + * The forum module mail generation tests for groups. + * + * @copyright 2013 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_forum_qanda_testcase extends advanced_testcase { + // Make use of the cron tester trait. + use mod_forum_tests_cron_trait; + + // Make use of the test generator trait. + use mod_forum_tests_generator_trait; + + /** + * @var \phpunit_message_sink + */ + protected $messagesink; + + /** + * @var \phpunit_mailer_sink + */ + protected $mailsink; + + public function setUp() { + global $CFG; + + // We must clear the subscription caches. This has to be done both before each test, and after in case of other + // tests using these functions. + \mod_forum\subscriptions::reset_forum_cache(); + \mod_forum\subscriptions::reset_discussion_cache(); + + // Messaging is not compatible with transactions... + $this->preventResetByRollback(); + + // Catch all messages. + $this->messagesink = $this->redirectMessages(); + $this->mailsink = $this->redirectEmails(); + + // Forcibly reduce the maxeditingtime to a second in the past to + // ensure that messages are sent out. + $CFG->maxeditingtime = -1; + } + + public function tearDown() { + // We must clear the subscription caches. This has to be done both before each test, and after in case of other + // tests using these functions. + \mod_forum\subscriptions::reset_forum_cache(); + + $this->messagesink->clear(); + $this->messagesink->close(); + unset($this->messagesink); + + $this->mailsink->clear(); + $this->mailsink->close(); + unset($this->mailsink); + } + + /** + * Test that a user who has not posted in a q&a forum does not receive + * notificatinos. + */ + public function test_user_has_not_posted() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $forum = $this->getDataGenerator()->create_module('forum', [ + 'course' => $course->id, + 'forcesubscribe' => FORUM_INITIALSUBSCRIBE, + 'groupmode' => SEPARATEGROUPS, + 'type' => 'qanda', + ]); + + // Create three students: + // - author, enrolled in group A; and + // - recipient, enrolled in group B; and + // - other, enrolled in the course, but no groups. + list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3); + + // Create one editing teacher, not in any group but with accessallgroups capability. + list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher'); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $editingteacher); + $reply = $this->helper_reply_to_post($post, $author); + $otherreply = $this->helper_reply_to_post($post, $recipient); + $DB->execute("UPDATE {forum_posts} SET modified = modified - 1"); + $DB->execute("UPDATE {forum_posts} SET created = created - 1"); + $DB->execute("UPDATE {forum_discussions} SET timemodified = timemodified - 1"); + + // Only the author, recipient, and teachers should receive. + $expect = [ + 'author' => (object) [ + 'userid' => $author->id, + 'messages' => 3, + ], + 'recipient' => (object) [ + 'userid' => $recipient->id, + 'messages' => 3, + ], + 'otheruser' => (object) [ + 'userid' => $otheruser->id, + 'messages' => 1, + ], + 'editingteacher' => (object) [ + 'userid' => $editingteacher->id, + 'messages' => 3, + ], + ]; + $this->queue_tasks_and_assert($expect); + $posts = [$post, $reply, $otherreply]; + + // No notifications should be queued. + $this->send_notifications_and_assert($author, $posts); + $this->send_notifications_and_assert($recipient, $posts); + $this->send_notifications_and_assert($otheruser, [$post]); + $this->send_notifications_and_assert($editingteacher, $posts); + } +} From b231aabb2d3a33c95f666a7a2ccead6afeb48386 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Sun, 10 Jun 2018 21:34:24 +0800 Subject: [PATCH 03/10] MDL-46881 mod_forum: Remove unused functions Note: I have not applied the deprecation policy to these functions as they are extremely core to the cron functionality and were never intended to be used outside of cron. --- mod/forum/lib.php | 80 ------------- mod/forum/tests/lib_test.php | 216 ----------------------------------- 2 files changed, 296 deletions(-) diff --git a/mod/forum/lib.php b/mod/forum/lib.php index 39c27ccd95e..83a7bc22ca0 100644 --- a/mod/forum/lib.php +++ b/mod/forum/lib.php @@ -1338,86 +1338,6 @@ function forum_search_posts($searchterms, $courseid=0, $limitfrom=0, $limitnum=5 return $DB->get_records_sql($searchsql, $params, $limitfrom, $limitnum); } -/** - * Returns a list of all new posts that have not been mailed yet - * - * @param int $starttime posts created after this time - * @param int $endtime posts created before this - * @param int $now used for timed discussions only - * @return array - */ -function forum_get_unmailed_posts($starttime, $endtime, $now=null) { - global $CFG, $DB; - - $params = array(); - $params['mailed'] = FORUM_MAILED_PENDING; - $params['ptimestart'] = $starttime; - $params['ptimeend'] = $endtime; - $params['mailnow'] = 1; - - if (!empty($CFG->forum_enabletimedposts)) { - if (empty($now)) { - $now = time(); - } - $selectsql = "AND (p.created >= :ptimestart OR d.timestart >= :pptimestart)"; - $params['pptimestart'] = $starttime; - $timedsql = "AND (d.timestart < :dtimestart AND (d.timeend = 0 OR d.timeend > :dtimeend))"; - $params['dtimestart'] = $now; - $params['dtimeend'] = $now; - } else { - $timedsql = ""; - $selectsql = "AND p.created >= :ptimestart"; - } - - return $DB->get_records_sql("SELECT p.*, d.course, d.forum - FROM {forum_posts} p - JOIN {forum_discussions} d ON d.id = p.discussion - WHERE p.mailed = :mailed - $selectsql - AND (p.created < :ptimeend OR p.mailnow = :mailnow) - $timedsql - ORDER BY p.modified ASC", $params); -} - -/** - * Marks posts before a certain time as being mailed already - * - * @global object - * @global object - * @param int $endtime - * @param int $now Defaults to time() - * @return bool - */ -function forum_mark_old_posts_as_mailed($endtime, $now=null) { - global $CFG, $DB; - - if (empty($now)) { - $now = time(); - } - - $params = array(); - $params['mailedsuccess'] = FORUM_MAILED_SUCCESS; - $params['now'] = $now; - $params['endtime'] = $endtime; - $params['mailnow'] = 1; - $params['mailedpending'] = FORUM_MAILED_PENDING; - - if (empty($CFG->forum_enabletimedposts)) { - return $DB->execute("UPDATE {forum_posts} - SET mailed = :mailedsuccess - WHERE (created < :endtime OR mailnow = :mailnow) - AND mailed = :mailedpending", $params); - } else { - return $DB->execute("UPDATE {forum_posts} - SET mailed = :mailedsuccess - WHERE discussion NOT IN (SELECT d.id - FROM {forum_discussions} d - WHERE d.timestart > :now) - AND (created < :endtime OR mailnow = :mailnow) - AND mailed = :mailedpending", $params); - } -} - /** * Get all the posts for a user in a forum suitable for forum_print_post * diff --git a/mod/forum/tests/lib_test.php b/mod/forum/tests/lib_test.php index cce78611b44..8099d0cffa7 100644 --- a/mod/forum/tests/lib_test.php +++ b/mod/forum/tests/lib_test.php @@ -2961,65 +2961,6 @@ class mod_forum_lib_testcase extends advanced_testcase { $this->assertEmpty($neighbours['next']); } - /** - * @dataProvider forum_get_unmailed_posts_provider - */ - public function test_forum_get_unmailed_posts($discussiondata, $enabletimedposts, $expectedcount, $expectedreplycount) { - global $CFG, $DB; - - $this->resetAfterTest(); - - // Configure timed posts. - $CFG->forum_enabletimedposts = $enabletimedposts; - - $course = $this->getDataGenerator()->create_course(); - $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]); - $user = $this->getDataGenerator()->create_user(); - $forumgen = $this->getDataGenerator()->get_plugin_generator('mod_forum'); - - // Keep track of the start time of the test. Do not use time() after this point to prevent random failures. - $time = time(); - - $record = new stdClass(); - $record->course = $course->id; - $record->userid = $user->id; - $record->forum = $forum->id; - if (isset($discussiondata['timecreated'])) { - $record->timemodified = $time + $discussiondata['timecreated']; - } - if (isset($discussiondata['timestart'])) { - $record->timestart = $time + $discussiondata['timestart']; - } - if (isset($discussiondata['timeend'])) { - $record->timeend = $time + $discussiondata['timeend']; - } - if (isset($discussiondata['mailed'])) { - $record->mailed = $discussiondata['mailed']; - } - - $discussion = $forumgen->create_discussion($record); - - // Fetch the unmailed posts. - $timenow = $time; - $endtime = $timenow - $CFG->maxeditingtime; - $starttime = $endtime - 2 * DAYSECS; - - $unmailed = forum_get_unmailed_posts($starttime, $endtime, $timenow); - $this->assertCount($expectedcount, $unmailed); - - // Add a reply just outside the maxeditingtime. - $replyto = $DB->get_record('forum_posts', array('discussion' => $discussion->id)); - $reply = new stdClass(); - $reply->userid = $user->id; - $reply->discussion = $discussion->id; - $reply->parent = $replyto->id; - $reply->created = max($replyto->created, $endtime - 1); - $forumgen->create_post($reply); - - $unmailed = forum_get_unmailed_posts($starttime, $endtime, $timenow); - $this->assertCount($expectedreplycount, $unmailed); - } - /** * Test for forum_is_author_hidden. */ @@ -3055,163 +2996,6 @@ class mod_forum_lib_testcase extends advanced_testcase { forum_is_author_hidden($post, $forum); } - public function forum_get_unmailed_posts_provider() { - return [ - 'Untimed discussion; Single post; maxeditingtime not expired' => [ - 'discussion' => [ - ], - 'timedposts' => false, - 'postcount' => 0, - 'replycount' => 0, - ], - 'Untimed discussion; Single post; maxeditingtime expired' => [ - 'discussion' => [ - 'timecreated' => - DAYSECS, - ], - 'timedposts' => false, - 'postcount' => 1, - 'replycount' => 2, - ], - 'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime not expired' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => 0, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 0, - ], - 'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => - DAYSECS, - ], - 'timedposts' => true, - 'postcount' => 1, - 'replycount' => 2, - ], - 'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend not reached' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => - DAYSECS, - 'timeend' => + DAYSECS - ], - 'timedposts' => true, - 'postcount' => 1, - 'replycount' => 2, - ], - 'Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend passed' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => - DAYSECS, - 'timeend' => - HOURSECS, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 0, - ], - 'Timed discussion; Single post; Posted 1 week ago; timeend not reached' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timeend' => + DAYSECS - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 1, - ], - 'Timed discussion; Single post; Posted 1 week ago; timeend passed' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timeend' => - DAYSECS, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 0, - ], - - 'Previously mailed; Untimed discussion; Single post; maxeditingtime not expired' => [ - 'discussion' => [ - 'mailed' => 1, - ], - 'timedposts' => false, - 'postcount' => 0, - 'replycount' => 0, - ], - - 'Previously mailed; Untimed discussion; Single post; maxeditingtime expired' => [ - 'discussion' => [ - 'timecreated' => - DAYSECS, - 'mailed' => 1, - ], - 'timedposts' => false, - 'postcount' => 0, - 'replycount' => 1, - ], - 'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime not expired' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => 0, - 'mailed' => 1, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 0, - ], - 'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => - DAYSECS, - 'mailed' => 1, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 1, - ], - 'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend not reached' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => - DAYSECS, - 'timeend' => + DAYSECS, - 'mailed' => 1, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 1, - ], - 'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timestart maxeditingtime expired; timeend passed' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timestart' => - DAYSECS, - 'timeend' => - HOURSECS, - 'mailed' => 1, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 0, - ], - 'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timeend not reached' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timeend' => + DAYSECS, - 'mailed' => 1, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 1, - ], - 'Previously mailed; Timed discussion; Single post; Posted 1 week ago; timeend passed' => [ - 'discussion' => [ - 'timecreated' => - WEEKSECS, - 'timeend' => - DAYSECS, - 'mailed' => 1, - ], - 'timedposts' => true, - 'postcount' => 0, - 'replycount' => 0, - ], - ]; - } - /** * Test the forum_discussion_is_locked function. * From c0284ccbc625b71ab4a92ea22c55517a6f4c9a76 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 23 Jan 2019 11:15:20 +0800 Subject: [PATCH 04/10] MDL-46881 forum: Ensure that there are posts to remove --- mod/forum/classes/task/send_user_digests.php | 22 ++++++++++++++++++- .../classes/task/send_user_notifications.php | 5 +++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/mod/forum/classes/task/send_user_digests.php b/mod/forum/classes/task/send_user_digests.php index 816ad76d738..395e61fe624 100644 --- a/mod/forum/classes/task/send_user_digests.php +++ b/mod/forum/classes/task/send_user_digests.php @@ -307,6 +307,12 @@ class send_user_digests extends \core\task\adhoc_task { } $posts->close(); + if (empty($discussionids)) { + // All posts have been removed since the task was queued. + $this->empty_queue($this->recipient->id, $timenow); + return; + } + list($in, $params) = $DB->get_in_or_equal($discussionids); $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params); @@ -321,7 +327,21 @@ class send_user_digests extends \core\task\adhoc_task { $this->fill_digest_cache(); - $DB->delete_records_select('forum_queue', "userid = :userid AND timemodified < :timemodified", $queueparams); + $this->empty_queue($this->recipient->id, $timenow); + } + + /** + * Empty the queue of posts for this user. + * + * @param array $queueparams The list of + */ + protected function empty_queue(int $userid, int $timemodified) : void { + global $DB; + + $DB->delete_records_select('forum_queue', "userid = :userid AND timemodified < :timemodified", [ + 'userid' => $userid, + 'timemodified' => $timemodified, + ]); } /** diff --git a/mod/forum/classes/task/send_user_notifications.php b/mod/forum/classes/task/send_user_notifications.php index cd146d57b58..0b6bd9eff4e 100644 --- a/mod/forum/classes/task/send_user_notifications.php +++ b/mod/forum/classes/task/send_user_notifications.php @@ -213,6 +213,11 @@ class send_user_notifications extends \core\task\adhoc_task { } $posts->close(); + if (empty($discussionids)) { + // All posts have been removed since the task was queued. + return; + } + // Fetch all discussions. list($in, $params) = $DB->get_in_or_equal(array_values($discussionids)); $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params); From af48d8bdd660e6531842766fc91037ebd2b2a16b Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 23 Jan 2019 10:08:53 +0800 Subject: [PATCH 05/10] MDL-46881 forum: Defer post visibility check to send time --- mod/forum/classes/task/cron_task.php | 58 ---------------------------- mod/forum/tests/mail_test.php | 8 ++-- mod/forum/tests/qanda_test.php | 2 +- 3 files changed, 5 insertions(+), 63 deletions(-) diff --git a/mod/forum/classes/task/cron_task.php b/mod/forum/classes/task/cron_task.php index ee2d2961934..2d0fd60a803 100644 --- a/mod/forum/classes/task/cron_task.php +++ b/mod/forum/classes/task/cron_task.php @@ -173,10 +173,6 @@ class cron_task extends \core\task\scheduled_task { $this->fill_digest_cache(); $this->log_finish("Done", 1); - $this->log_start("Filling user posted cache for Q&A forums", 1); - $this->fetch_user_has_posted(); - $this->log_finish("Done", 1); - $this->log_start("Queueing user tasks."); $this->queue_user_tasks(); $this->log_finish("All tasks queued.", 1); @@ -311,44 +307,6 @@ class cron_task extends \core\task\scheduled_task { } } - /** - * Fill the cache for Q&A forums. - */ - protected function fetch_user_has_posted() { - global $DB; - - $forums = array_filter($this->forums, function($forum) { - return $forum->type === 'qanda'; - }); - - if (empty($forums)) { - return; - } - - list($in, $params) = $DB->get_in_or_equal(array_keys($forums)); - - $sql = "SELECT d.forum, d.firstpost, p.userid - FROM {forum} f - INNER JOIN {forum_discussions} d ON d.forum = f.id - LEFT JOIN {forum_posts} p ON p.discussion = d.id - WHERE f.type = 'qanda' - AND f.id {$in} - GROUP BY p.userid, d.forum, d.firstpost"; - - $rs = $DB->get_recordset_sql($sql, $params); - foreach ($rs as $row) { - if (empty($this->qandametadata[$row->forum])) { - $this->qandametadata[$row->forum] = (object) [ - 'users' => [], - 'firstpost' => [$row->firstpost], - ]; - } - $this->qandametadata[$row->forum]->users[$row->userid] = true; - } - $rs->close(); - - } - /** * Queue the user tasks. */ @@ -500,22 +458,6 @@ class cron_task extends \core\task\scheduled_task { } } - if ($forum->type === 'qanda' && $postid != $discussion->firstpost) { - $hasqandapost = isset($this->qandametadata[$forumid]); - $hasqandapost = $hasqandapost && isset($this->qandametadata[$forumid]->users[$user->id]); - if (!$hasqandapost) { - // The user has not posted to this qanda forum. - unset($poststructure[$courseid][$forumid][$discussionid]); - continue; - } - } - - if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) { - // The user is not allowed to see the post for some other reason. - unset($poststructure[$courseid][$forumid][$discussionid][$postid]); - continue; - } - if ($maildigest > 0) { // This user wants the mails to be in digest form. $digeststructure[] = (object) [ diff --git a/mod/forum/tests/mail_test.php b/mod/forum/tests/mail_test.php index 957a4edcc9d..8bf818f94ba 100644 --- a/mod/forum/tests/mail_test.php +++ b/mod/forum/tests/mail_test.php @@ -1215,11 +1215,11 @@ class mod_forum_mail_testcase extends advanced_testcase { $expect = [ 'author' => (object) [ 'userid' => $author->id, - 'messages' => 0, + 'messages' => 1, ], 'recipient' => (object) [ 'userid' => $recipient->id, - 'messages' => 0, + 'messages' => 1, ], 'editor' => (object) [ 'userid' => $editor->id, @@ -1229,8 +1229,8 @@ class mod_forum_mail_testcase extends advanced_testcase { $this->queue_tasks_and_assert($expect); // No notifications should be queued. - $this->send_notifications_and_assert($author, []); - $this->send_notifications_and_assert($recipient, []); + $this->send_notifications_and_assert($author, [], true); + $this->send_notifications_and_assert($recipient, [], true); $this->send_notifications_and_assert($editor, [$post], true); } diff --git a/mod/forum/tests/qanda_test.php b/mod/forum/tests/qanda_test.php index 417ea2a8482..3e4795250ab 100644 --- a/mod/forum/tests/qanda_test.php +++ b/mod/forum/tests/qanda_test.php @@ -134,7 +134,7 @@ class mod_forum_qanda_testcase extends advanced_testcase { ], 'otheruser' => (object) [ 'userid' => $otheruser->id, - 'messages' => 1, + 'messages' => 3, ], 'editingteacher' => (object) [ 'userid' => $editingteacher->id, From 0dc54176ddc3379bbe9b92543af3a484b0e1e9f2 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 23 Jan 2019 11:33:17 +0800 Subject: [PATCH 06/10] MDL-46881 forum: Mark posts as ready properly --- mod/forum/classes/task/send_user_notifications.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod/forum/classes/task/send_user_notifications.php b/mod/forum/classes/task/send_user_notifications.php index 0b6bd9eff4e..552177a6307 100644 --- a/mod/forum/classes/task/send_user_notifications.php +++ b/mod/forum/classes/task/send_user_notifications.php @@ -168,7 +168,7 @@ class send_user_notifications extends \core\task\adhoc_task { if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) { $this->log_start("Marking posts as read"); $count = count($markposts); - forum_tp_mark_posts_read($this->recipient, $markposts); + forum_tp_mark_posts_read($this->recipient, array_keys($markposts)); $this->log_finish("Marked {$count} posts as read"); } } From 4129d898f75bea31b800c8ad3bd996d74bcc1601 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 29 Jan 2019 09:16:47 +0800 Subject: [PATCH 07/10] MDL-46881 forum: Tidy up trace variables --- lib/classes/task/logging_trait.php | 23 +++++++++++------------ mod/forum/classes/task/cron_task.php | 10 +++------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/lib/classes/task/logging_trait.php b/lib/classes/task/logging_trait.php index ac2d4014a0b..8701c2a5ae2 100644 --- a/lib/classes/task/logging_trait.php +++ b/lib/classes/task/logging_trait.php @@ -38,12 +38,12 @@ trait logging_trait { /** * @var \progress_trace */ - protected $_trace = null; + protected $trace = null; /** * @var \stdClass */ - protected $_tracestats = null; + protected $tracestats = null; /** * Get the progress_trace. @@ -51,12 +51,12 @@ trait logging_trait { * @return \progress_trace */ protected function get_trace() { - if (null === $this->_trace) { - $this->_trace = new \text_progress_trace(); - $this->_tracestats = (object) []; + if (null === $this->trace) { + $this->trace = new \text_progress_trace(); + $this->tracestats = new \stdClass(); } - return $this->_trace; + return $this->trace; } /** @@ -77,16 +77,14 @@ trait logging_trait { * @param int $depth */ protected function log_start($message, $depth = 0) { - $this->get_trace(); + $this->log($message, $depth); if (defined('MDL_PERFTOLOG') && MDL_PERFTOLOG) { - $this->_tracestats->$depth = [ + $this->tracestats->$depth = [ 'mem' => memory_get_usage(), 'time' => microtime(), ]; } - - $this->log($message, $depth); } /** @@ -97,8 +95,9 @@ trait logging_trait { */ protected function log_finish($message, $depth = 0) { $this->log($message, $depth); - if (isset($this->_tracestats->$depth)) { - $startstats = $this->_tracestats->$depth; + + if (isset($this->tracestats->$depth)) { + $startstats = $this->tracestats->$depth; $this->log( sprintf("Time taken %s, memory total: %s, Memory growth: %s, Memory peak: %s", microtime_diff($startstats['time'], microtime()), diff --git a/mod/forum/classes/task/cron_task.php b/mod/forum/classes/task/cron_task.php index 2d0fd60a803..74fdfd73230 100644 --- a/mod/forum/classes/task/cron_task.php +++ b/mod/forum/classes/task/cron_task.php @@ -125,8 +125,6 @@ class cron_task extends \core\task\scheduled_task { * @param \stdClass[] $posts */ protected function process_post_data($posts) { - $trace = $this->get_trace(); - $discussionids = []; $forumids = []; $courseids = []; @@ -173,11 +171,11 @@ class cron_task extends \core\task\scheduled_task { $this->fill_digest_cache(); $this->log_finish("Done", 1); + $this->log_finish("All caches filled"); + $this->log_start("Queueing user tasks."); $this->queue_user_tasks(); - $this->log_finish("All tasks queued.", 1); - - $this->log_finish("All caches filled"); + $this->log_finish("All tasks queued."); } /** @@ -313,8 +311,6 @@ class cron_task extends \core\task\scheduled_task { protected function queue_user_tasks() { global $CFG, $DB; - $trace = $this->get_trace(); - $timenow = time(); $sitetimezone = \core_date::get_server_timezone(); $counts = [ From b51b696927c8861b0aa2382626be85768ba0c96f Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 29 Jan 2019 09:17:19 +0800 Subject: [PATCH 08/10] MDL-46881 core: Allow adhoc tasks to be rescheduled --- lib/classes/task/manager.php | 39 +++++++++- lib/tests/adhoc_task_test.php | 108 +++++++++++++++++++++++++++ lib/upgrade.txt | 2 + mod/forum/classes/task/cron_task.php | 2 +- 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/lib/classes/task/manager.php b/lib/classes/task/manager.php index 59520182142..87fe34287ac 100644 --- a/lib/classes/task/manager.php +++ b/lib/classes/task/manager.php @@ -129,7 +129,18 @@ class manager { * @return bool */ protected static function task_is_scheduled($task) { + return false !== self::get_queued_adhoc_task_record($task); + } + + /** + * Checks if the task with the same classname, component and customdata is already scheduled + * + * @param adhoc_task $task + * @return bool + */ + protected static function get_queued_adhoc_task_record($task) { global $DB; + $record = self::record_from_adhoc_task($task); $params = [$record->classname, $record->component, $record->customdata]; $sql = 'classname = ? AND component = ? AND ' . @@ -139,14 +150,38 @@ class manager { $params[] = $record->userid; $sql .= " AND userid = ? "; } - return $DB->record_exists_select('task_adhoc', $sql, $params); + return $DB->get_record_select('task_adhoc', $sql, $params); + } + + /** + * Schedule a new task, or reschedule an existing adhoc task which has matching data. + * + * Only a task matching the same user, classname, component, and customdata will be rescheduled. + * If these values do not match exactly then a new task is scheduled. + * + * @param \core\task\adhoc_task $task - The new adhoc task information to store. + * @since Moodle 3.7 + */ + public static function reschedule_or_queue_adhoc_task(adhoc_task $task) : void { + global $DB; + + if ($existingrecord = self::get_queued_adhoc_task_record($task)) { + // Only update the next run time if it is explicitly set on the task. + $nextruntime = $task->get_next_run_time(); + if ($nextruntime && ($existingrecord->nextruntime != $nextruntime)) { + $DB->set_field('task_adhoc', 'nextruntime', $nextruntime, ['id' => $existingrecord->id]); + } + } else { + // There is nothing queued yet. Just queue as normal. + self::queue_adhoc_task($task); + } } /** * Queue an adhoc task to run in the background. * * @param \core\task\adhoc_task $task - The new adhoc task information to store. - * @param bool $checkforexisting - If set to true and the task with the same classname, component and customdata + * @param bool $checkforexisting - If set to true and the task with the same user, classname, component and customdata * is already scheduled then it will not schedule a new task. Can be used only for ASAP tasks. * @return boolean - True if the config was saved. */ diff --git a/lib/tests/adhoc_task_test.php b/lib/tests/adhoc_task_test.php index 2b8b7d1e19d..c5d796102f8 100644 --- a/lib/tests/adhoc_task_test.php +++ b/lib/tests/adhoc_task_test.php @@ -150,6 +150,114 @@ class core_adhoc_task_testcase extends advanced_testcase { } } + /** + * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if no tasks exist. + */ + public function test_reschedule_or_queue_adhoc_task_no_existing() { + $this->resetAfterTest(true); + + // Schedule adhoc task. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + $this->assertEquals(1, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + } + + /** + * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if a task for the same user does + * not exist. + */ + public function test_reschedule_or_queue_adhoc_task_different_user() { + $this->resetAfterTest(true); + $user = \core_user::get_user_by_username('admin'); + + // Schedule adhoc task. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + // Schedule adhoc task for a different user. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $task->set_userid($user->id); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + $this->assertEquals(2, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + } + + /** + * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if a task with different custom + * data exists. + */ + public function test_reschedule_or_queue_adhoc_task_different_data() { + $this->resetAfterTest(true); + + // Schedule adhoc task. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + // Schedule adhoc task for a different user. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 11]); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + $this->assertEquals(2, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + } + + /** + * Ensure that the reschedule_or_queue_adhoc_task function will not make any change for matching data if no time was + * specified. + */ + public function test_reschedule_or_queue_adhoc_task_match_no_change() { + $this->resetAfterTest(true); + + // Schedule adhoc task. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $task->set_next_run_time(time() + DAYSECS); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + $before = \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'); + + // Schedule the task again but do not specify a time. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + $this->assertEquals(1, count(\core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + $this->assertEquals($before, \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task')); + } + + /** + * Ensure that the reschedule_or_queue_adhoc_task function will update the run time if there are planned changes. + */ + public function test_reschedule_or_queue_adhoc_task_match_update_runtime() { + $this->resetAfterTest(true); + $initialruntime = time() + DAYSECS; + $newruntime = time() + WEEKSECS; + + // Schedule adhoc task. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $task->set_next_run_time($initialruntime); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + $before = \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'); + + // Schedule the task again. + $task = new \core\task\adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $task->set_next_run_time($newruntime); + \core\task\manager::reschedule_or_queue_adhoc_task($task); + + $tasks = \core\task\manager::get_adhoc_tasks('core\task\adhoc_test_task'); + $this->assertEquals(1, count($tasks)); + $this->assertNotEquals($before, $tasks); + $firsttask = reset($tasks); + $this->assertEquals($newruntime, $firsttask->get_next_run_time()); + } + /** * Test queue_adhoc_task "if not scheduled". */ diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 85225fdfb9d..54e55231053 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -6,6 +6,8 @@ information provided here is intended especially for developers. * The method core_user::is_real_user() now returns false for userid = 0 parameter * 'mform1' dependencies (in themes, js...) will stop working because a randomly generated string has been added to the id attribute on forms to avoid collisions in forms loaded in AJAX requests. +* A new method to allow queueing or rescheduling of an existing scheduled task was added. This allows an existing task + to be updated or queued as required. This new functionality can be found in \core\task\manager::reschedule_or_queue_adhoc_task. === 3.6 === diff --git a/mod/forum/classes/task/cron_task.php b/mod/forum/classes/task/cron_task.php index 74fdfd73230..5b88606c059 100644 --- a/mod/forum/classes/task/cron_task.php +++ b/mod/forum/classes/task/cron_task.php @@ -342,7 +342,7 @@ class cron_task extends \core\task\scheduled_task { $task->set_userid($user->id); $task->set_component('mod_forum'); $task->set_next_run_time($digesttime); - \core\task\manager::queue_adhoc_task($task, true); + \core\task\manager::reschedule_or_queue_adhoc_task($task); $usercounts['digests']++; $send = true; } From ba1a8ef7829e42c52f9777defd78f6db0659ec4c Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 29 Jan 2019 09:36:21 +0800 Subject: [PATCH 09/10] MDL-46881 forum: Adjust limits for tasks --- mod/forum/classes/task/send_user_digests.php | 3 --- mod/forum/classes/task/send_user_notifications.php | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mod/forum/classes/task/send_user_digests.php b/mod/forum/classes/task/send_user_digests.php index 395e61fe624..30fd7380cf0 100644 --- a/mod/forum/classes/task/send_user_digests.php +++ b/mod/forum/classes/task/send_user_digests.php @@ -138,9 +138,6 @@ class send_user_digests extends \core\task\adhoc_task { * Send out messages. */ public function execute() { - // Terminate if not able to fetch all digests in 5 minutes. - \core_php_time_limit::raise(300); - $starttime = time(); $this->recipient = \core_user::get_user($this->get_userid()); diff --git a/mod/forum/classes/task/send_user_notifications.php b/mod/forum/classes/task/send_user_notifications.php index 552177a6307..acc40344aae 100644 --- a/mod/forum/classes/task/send_user_notifications.php +++ b/mod/forum/classes/task/send_user_notifications.php @@ -99,6 +99,9 @@ class send_user_notifications extends \core\task\adhoc_task { public function execute() { global $CFG; + // Raise the time limit for each discussion. + \core_php_time_limit::raise(120); + $this->recipient = \core_user::get_user($this->get_userid()); // Create the generic messageinboundgenerator. From cd029f936df79cc9f4ecd5d77f207dc02040aa16 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 30 Jan 2019 08:10:10 +0800 Subject: [PATCH 10/10] MDL-46881 forum: Only skip post, not discussion --- mod/forum/classes/task/cron_task.php | 2 +- mod/forum/tests/mail_test.php | 46 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/mod/forum/classes/task/cron_task.php b/mod/forum/classes/task/cron_task.php index 5b88606c059..ca3b41e3a8e 100644 --- a/mod/forum/classes/task/cron_task.php +++ b/mod/forum/classes/task/cron_task.php @@ -449,7 +449,7 @@ class cron_task extends \core\task\scheduled_task { $subscribedafter = $subscribedafter && ($subscriptiontime[$post->discussion] > $post->created); if ($subscribedafter) { // The user subscribed to the discussion/forum after this post was created. - unset($poststructure[$courseid][$forumid][$discussionid]); + unset($poststructure[$courseid][$forumid][$discussionid][$postid]); continue; } } diff --git a/mod/forum/tests/mail_test.php b/mod/forum/tests/mail_test.php index 8bf818f94ba..9e1afdfceef 100644 --- a/mod/forum/tests/mail_test.php +++ b/mod/forum/tests/mail_test.php @@ -529,6 +529,52 @@ class mod_forum_mail_testcase extends advanced_testcase { $this->send_notifications_and_assert($recipient, [$reply]); } + public function test_optional_with_subscribed_discussion_and_post() { + $this->resetAfterTest(true); + + // Create a course, with a forum. + $course = $this->getDataGenerator()->create_course(); + + $options = array('course' => $course->id, 'forcesubscribe' => FORUM_CHOOSESUBSCRIBE); + $forum = $this->getDataGenerator()->create_module('forum', $options); + + // Create two users enrolled in the course as students. + list($author, $recipient) = $this->helper_create_users($course, 2); + + // Post a discussion to the forum. + list($discussion, $post) = $this->helper_post_to_forum($forum, $author); + $this->helper_update_post_time($post, -90); + + // Have a user reply to the discussion before we subscribed. + $reply = $this->helper_post_to_discussion($forum, $discussion, $author); + $this->helper_update_post_time($reply, -75); + + // Subscribe the 'recipient' user to the discussion. + \mod_forum\subscriptions::subscribe_user_to_discussion($recipient->id, $discussion); + $this->helper_update_subscription_time($recipient, $discussion, -60); + + // Have a user reply to the discussion. + $reply = $this->helper_post_to_discussion($forum, $discussion, $author); + $this->helper_update_post_time($reply, -30); + + // We expect only one user to receive this post. + // The original post won't be received as it was written before the user subscribed. + $expect = [ + (object) [ + 'userid' => $author->id, + 'messages' => 0, + ], + (object) [ + 'userid' => $recipient->id, + 'messages' => 1, + ], + ]; + $this->queue_tasks_and_assert($expect); + + $this->send_notifications_and_assert($author, []); + $this->send_notifications_and_assert($recipient, [$reply]); + } + public function test_automatic_with_subscribed_discussion_in_unsubscribed_forum() { $this->resetAfterTest(true);