<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A scheduled task for forum cron.
*
* @package mod_forum
* @copyright 2014 Dan Poltawski <dan@moodle.com>
* @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 <andrew@nicols.co.uk>
* @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).
*
* @return string
*/
public function get_name() {
return get_string('crontask', 'mod_forum');
}
/**
* Execute the scheduled task.
*/
public function execute() {
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) {
$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_finish("All caches filled");
$this->log_start("Queueing user tasks.");
$this->queue_user_tasks();
$this->log_finish("All tasks queued.");
}
/**
* 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 = [];
> $this->discussions = [];
} 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);
}
}
}
/**
* Queue the user tasks.
*/
protected function queue_user_tasks() {
global $CFG, $DB;
$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);
$servermidnight = usergetmidnight($timenow, $sitetimezone);
$digesttime = $servermidnight + ($CFG->digestmailtime * 3600);
if ($digesttime < $timenow) {
// Digest time is in the past. Get a new time for tomorrow.
$servermidnight = usergetmidnight($timenow + DAYSECS, $sitetimezone);
$digesttime = $servermidnight + ($CFG->digestmailtime * 3600);
}
$task = new \mod_forum\task\send_user_digests();
$task->set_userid($user->id);
$task->set_component('mod_forum');
$task->set_custom_data(['servermidnight' => $servermidnight]);
$task->set_next_run_time($digesttime);
\core\task\manager::reschedule_or_queue_adhoc_task($task);
$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][$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);
}
}