MDL-46881 core: Allow adhoc tasks to be rescheduled
[moodle.git] / mod / forum / classes / task / cron_task.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * A scheduled task for forum cron.
19  *
20  * @package    mod_forum
21  * @copyright  2014 Dan Poltawski <dan@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 namespace mod_forum\task;
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->dirroot . '/mod/forum/lib.php');
30 /**
31  * The main scheduled task for the forum.
32  *
33  * @package    mod_forum
34  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
35  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36  */
37 class cron_task extends \core\task\scheduled_task {
39     // Use the logging trait to get some nice, juicy, logging.
40     use \core\task\logging_trait;
42     /**
43      * @var The list of courses which contain posts to be sent.
44      */
45     protected $courses = [];
47     /**
48      * @var The list of forums which contain posts to be sent.
49      */
50     protected $forums = [];
52     /**
53      * @var The list of discussions which contain posts to be sent.
54      */
55     protected $discussions = [];
57     /**
58      * @var The list of posts to be sent.
59      */
60     protected $posts = [];
62     /**
63      * @var The list of post authors.
64      */
65     protected $users = [];
67     /**
68      * @var The list of subscribed users.
69      */
70     protected $subscribedusers = [];
72     /**
73      * @var The list of digest users.
74      */
75     protected $digestusers = [];
77     /**
78      * @var The list of adhoc data for sending.
79      */
80     protected $adhocdata = [];
82     /**
83      * Get a descriptive name for this task (shown to admins).
84      *
85      * @return string
86      */
87     public function get_name() {
88         return get_string('crontask', 'mod_forum');
89     }
91     /**
92      * Execute the scheduled task.
93      */
94     public function execute() {
95         global $CFG, $DB;
97         $timenow = time();
99         // Delete any really old posts in the digest queue.
100         $weekago = $timenow - (7 * 24 * 3600);
101         $this->log_start("Removing old digest records from 7 days ago.");
102         $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago));
103         $this->log_finish("Removed all old digest records.");
105         $endtime   = $timenow - $CFG->maxeditingtime;
106         $starttime = $endtime - (2 * DAYSECS);
107         $this->log_start("Fetching unmailed posts.");
108         if (!$posts = $this->get_unmailed_posts($starttime, $endtime, $timenow)) {
109             $this->log_finish("No posts found.", 1);
110             return false;
111         }
112         $this->log_finish("Done");
114         // Process post data and turn into adhoc tasks.
115         $this->process_post_data($posts);
117         // Mark posts as read.
118         list($in, $params) = $DB->get_in_or_equal(array_keys($posts));
119         $DB->set_field_select('forum_posts', 'mailed', 1, "id {$in}", $params);
120     }
122     /**
123      * Process all posts and convert to appropriated hoc tasks.
124      *
125      * @param   \stdClass[] $posts
126      */
127     protected function process_post_data($posts) {
128         $discussionids = [];
129         $forumids = [];
130         $courseids = [];
132         $this->log_start("Processing post information");
134         $start = microtime(true);
135         foreach ($posts as $id => $post) {
136             $discussionids[$post->discussion] = true;
137             $forumids[$post->forum] = true;
138             $courseids[$post->course] = true;
139             $this->add_data_for_post($post);
140             $this->posts[$id] = $post;
141         }
142         $this->log_finish(sprintf("Processed %s posts", count($this->posts)));
144         if (empty($this->posts)) {
145             $this->log("No posts found. Returning early.");
146             return;
147         }
149         // Please note, this order is intentional.
150         // The forum cache makes use of the course.
151         $this->log_start("Filling caches");
153         $start = microtime(true);
154         $this->log_start("Filling course cache", 1);
155         $this->fill_course_cache(array_keys($courseids));
156         $this->log_finish("Done", 1);
158         $this->log_start("Filling forum cache", 1);
159         $this->fill_forum_cache(array_keys($forumids));
160         $this->log_finish("Done", 1);
162         $this->log_start("Filling discussion cache", 1);
163         $this->fill_discussion_cache(array_keys($discussionids));
164         $this->log_finish("Done", 1);
166         $this->log_start("Filling user subscription cache", 1);
167         $this->fill_user_subscription_cache();
168         $this->log_finish("Done", 1);
170         $this->log_start("Filling digest cache", 1);
171         $this->fill_digest_cache();
172         $this->log_finish("Done", 1);
174         $this->log_finish("All caches filled");
176         $this->log_start("Queueing user tasks.");
177         $this->queue_user_tasks();
178         $this->log_finish("All tasks queued.");
179     }
181     /**
182      * Fill the course cache.
183      *
184      * @param   int[]       $courseids
185      */
186     protected function fill_course_cache($courseids) {
187         global $DB;
189         list($in, $params) = $DB->get_in_or_equal($courseids);
190         $this->courses = $DB->get_records_select('course', "id $in", $params);
191     }
193     /**
194      * Fill the forum cache.
195      *
196      * @param   int[]       $forumids
197      */
198     protected function fill_forum_cache($forumids) {
199         global $DB;
201         $requiredfields = [
202                 'id',
203                 'course',
204                 'forcesubscribe',
205                 'type',
206             ];
207         list($in, $params) = $DB->get_in_or_equal($forumids);
208         $this->forums = $DB->get_records_select('forum', "id $in", $params, '', implode(', ', $requiredfields));
209         foreach ($this->forums as $id => $forum) {
210             \mod_forum\subscriptions::fill_subscription_cache($id);
211             \mod_forum\subscriptions::fill_discussion_subscription_cache($id);
212         }
213     }
215     /**
216      * Fill the discussion cache.
217      *
218      * @param   int[]       $discussionids
219      */
220     protected function fill_discussion_cache($discussionids) {
221         global $DB;
223         if (empty($discussionids)) {
224             $this->discussion = [];
225         } else {
227             $requiredfields = [
228                     'id',
229                     'groupid',
230                     'firstpost',
231                     'timestart',
232                     'timeend',
233                 ];
235             list($in, $params) = $DB->get_in_or_equal($discussionids);
236             $this->discussions = $DB->get_records_select(
237                     'forum_discussions', "id $in", $params, '', implode(', ', $requiredfields));
238         }
239     }
241     /**
242      * Fill the cache of user digest preferences.
243      */
244     protected function fill_digest_cache() {
245         global $DB;
247         if (empty($this->users)) {
248             return;
249         }
250         // Get the list of forum subscriptions for per-user per-forum maildigest settings.
251         list($in, $params) = $DB->get_in_or_equal(array_keys($this->users));
252         $digestspreferences = $DB->get_recordset_select(
253                 'forum_digests', "userid $in", $params, '', 'id, userid, forum, maildigest');
254         foreach ($digestspreferences as $digestpreference) {
255             if (!isset($this->digestusers[$digestpreference->forum])) {
256                 $this->digestusers[$digestpreference->forum] = [];
257             }
258             $this->digestusers[$digestpreference->forum][$digestpreference->userid] = $digestpreference->maildigest;
259         }
260         $digestspreferences->close();
261     }
263     /**
264      * Add dsta for the current forum post to the structure of adhoc data.
265      *
266      * @param   \stdClass   $post
267      */
268     protected function add_data_for_post($post) {
269         if (!isset($this->adhocdata[$post->course])) {
270             $this->adhocdata[$post->course] = [];
271         }
273         if (!isset($this->adhocdata[$post->course][$post->forum])) {
274             $this->adhocdata[$post->course][$post->forum] = [];
275         }
277         if (!isset($this->adhocdata[$post->course][$post->forum][$post->discussion])) {
278             $this->adhocdata[$post->course][$post->forum][$post->discussion] = [];
279         }
281         $this->adhocdata[$post->course][$post->forum][$post->discussion][$post->id] = $post->id;
282     }
284     /**
285      * Fill the cache of user subscriptions.
286      */
287     protected function fill_user_subscription_cache() {
288         foreach ($this->forums as $forum) {
289             $cm = get_fast_modinfo($this->courses[$forum->course])->instances['forum'][$forum->id];
290             $modcontext = \context_module::instance($cm->id);
292             $this->subscribedusers[$forum->id] = [];
293             if ($users = \mod_forum\subscriptions::fetch_subscribed_users($forum, 0, $modcontext, 'u.id, u.maildigest', true)) {
294                 foreach ($users as $user) {
295                     // This user is subscribed to this forum.
296                     $this->subscribedusers[$forum->id][$user->id] = $user->id;
297                     if (!isset($this->users[$user->id])) {
298                         // Store minimal user info.
299                         $this->users[$user->id] = $user;
300                     }
301                 }
302                 // Release memory.
303                 unset($users);
304             }
305         }
306     }
308     /**
309      * Queue the user tasks.
310      */
311     protected function queue_user_tasks() {
312         global $CFG, $DB;
314         $timenow = time();
315         $sitetimezone = \core_date::get_server_timezone();
316         $counts = [
317             'digests' => 0,
318             'individuals' => 0,
319             'users' => 0,
320             'ignored' => 0,
321             'messages' => 0,
322         ];
323         $this->log("Processing " . count($this->users) . " users", 1);
324         foreach ($this->users as $user) {
325             $usercounts = [
326                 'digests' => 0,
327                 'messages' => 0,
328             ];
330             $send = false;
331             // Setup this user so that the capabilities are cached, and environment matches receiving user.
332             cron_setup_user($user);
334             list($individualpostdata, $digestpostdata) = $this->fetch_posts_for_user($user);
336             if (!empty($digestpostdata)) {
337                 // Insert all of the records for the digest.
338                 $DB->insert_records('forum_queue', $digestpostdata);
339                 $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600);
341                 $task = new \mod_forum\task\send_user_digests();
342                 $task->set_userid($user->id);
343                 $task->set_component('mod_forum');
344                 $task->set_next_run_time($digesttime);
345                 \core\task\manager::reschedule_or_queue_adhoc_task($task);
346                 $usercounts['digests']++;
347                 $send = true;
348             }
350             if (!empty($individualpostdata)) {
351                 $usercounts['messages'] += count($individualpostdata);
353                 $task = new \mod_forum\task\send_user_notifications();
354                 $task->set_userid($user->id);
355                 $task->set_custom_data($individualpostdata);
356                 $task->set_component('mod_forum');
357                 \core\task\manager::queue_adhoc_task($task);
358                 $counts['individuals']++;
359                 $send = true;
360             }
362             if ($send) {
363                 $counts['users']++;
364                 $counts['messages'] += $usercounts['messages'];
365                 $counts['digests'] += $usercounts['digests'];
366             } else {
367                 $counts['ignored']++;
368             }
370             $this->log(sprintf("Queued %d digests and %d messages for %s",
371                     $usercounts['digests'],
372                     $usercounts['messages'],
373                     $user->id
374                 ), 2);
375         }
376         $this->log(
377             sprintf(
378                 "Queued %d digests, and %d individual tasks for %d post mails. " .
379                 "Unique users: %d (%d ignored)",
380                 $counts['digests'],
381                 $counts['individuals'],
382                 $counts['messages'],
383                 $counts['users'],
384                 $counts['ignored']
385             ), 1);
386     }
388     /**
389      * Fetch posts for this user.
390      *
391      * @param   \stdClass   $user The user to fetch posts for.
392      */
393     protected function fetch_posts_for_user($user) {
394         // We maintain a mapping of user groups for each forum.
395         $usergroups = [];
396         $digeststructure = [];
398         $poststructure = $this->adhocdata;
399         $poststosend = [];
400         foreach ($poststructure as $courseid => $forumids) {
401             $course = $this->courses[$courseid];
402             foreach ($forumids as $forumid => $discussionids) {
403                 $forum = $this->forums[$forumid];
404                 $maildigest = forum_get_user_maildigest_bulk($this->digestusers, $user, $forumid);
406                 if (!isset($this->subscribedusers[$forumid][$user->id])) {
407                     // This user has no subscription of any kind to this forum.
408                     // Do not send them any posts at all.
409                     unset($poststructure[$courseid][$forumid]);
410                     continue;
411                 }
413                 $subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $user->id);
415                 $cm = get_fast_modinfo($course)->instances['forum'][$forumid];
416                 foreach ($discussionids as $discussionid => $postids) {
417                     $discussion = $this->discussions[$discussionid];
418                     if (!\mod_forum\subscriptions::is_subscribed($user->id, $forum, $discussionid, $cm)) {
419                         // The user does not subscribe to this forum as a whole, or to this specific discussion.
420                         unset($poststructure[$courseid][$forumid][$discussionid]);
421                         continue;
422                     }
424                     if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) {
425                         // This discussion has a groupmode set (SEPARATEGROUPS or VISIBLEGROUPS).
426                         // Check whether the user can view it based on their groups.
427                         if (!isset($usergroups[$forum->id])) {
428                             $usergroups[$forum->id] = groups_get_all_groups($courseid, $user->id, $cm->groupingid);
429                         }
431                         if (!isset($usergroups[$forum->id][$discussion->groupid])) {
432                             // This user is not a member of this group, or the group no longer exists.
434                             $modcontext = \context_module::instance($cm->id);
435                             if (!has_capability('moodle/site:accessallgroups', $modcontext, $user)) {
436                                 // This user does not have the accessallgroups and is not a member of the group.
437                                 // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS.
438                                 unset($poststructure[$courseid][$forumid][$discussionid]);
439                                 continue;
440                             }
441                         }
442                     }
444                     foreach ($postids as $postid) {
445                         $post = $this->posts[$postid];
446                         if ($subscriptiontime) {
447                             // Skip posts if the user subscribed to the discussion after it was created.
448                             $subscribedafter = isset($subscriptiontime[$post->discussion]);
449                             $subscribedafter = $subscribedafter && ($subscriptiontime[$post->discussion] > $post->created);
450                             if ($subscribedafter) {
451                                 // The user subscribed to the discussion/forum after this post was created.
452                                 unset($poststructure[$courseid][$forumid][$discussionid]);
453                                 continue;
454                             }
455                         }
457                         if ($maildigest > 0) {
458                             // This user wants the mails to be in digest form.
459                             $digeststructure[] = (object) [
460                                 'userid' => $user->id,
461                                 'discussionid' => $discussion->id,
462                                 'postid' => $post->id,
463                                 'timemodified' => $post->created,
464                             ];
465                             unset($poststructure[$courseid][$forumid][$discussionid][$postid]);
466                             continue;
467                         } else {
468                             // Add this post to the list of postids to be sent.
469                             $poststosend[] = $postid;
470                         }
471                     }
472                 }
474                 if (empty($poststructure[$courseid][$forumid])) {
475                     // This user is not subscribed to any discussions in this forum at all.
476                     unset($poststructure[$courseid][$forumid]);
477                     continue;
478                 }
479             }
480             if (empty($poststructure[$courseid])) {
481                 // This user is not subscribed to any forums in this course.
482                 unset($poststructure[$courseid]);
483             }
484         }
486         return [$poststosend, $digeststructure];
487     }
489     /**
490      * Returns a list of all new posts that have not been mailed yet
491      *
492      * @param int $starttime posts created after this time
493      * @param int $endtime posts created before this
494      * @param int $now used for timed discussions only
495      * @return array
496      */
497     protected function get_unmailed_posts($starttime, $endtime, $now = null) {
498         global $CFG, $DB;
500         $params = array();
501         $params['mailed'] = FORUM_MAILED_PENDING;
502         $params['ptimestart'] = $starttime;
503         $params['ptimeend'] = $endtime;
504         $params['mailnow'] = 1;
506         if (!empty($CFG->forum_enabletimedposts)) {
507             if (empty($now)) {
508                 $now = time();
509             }
510             $selectsql = "AND (p.created >= :ptimestart OR d.timestart >= :pptimestart)";
511             $params['pptimestart'] = $starttime;
512             $timedsql = "AND (d.timestart < :dtimestart AND (d.timeend = 0 OR d.timeend > :dtimeend))";
513             $params['dtimestart'] = $now;
514             $params['dtimeend'] = $now;
515         } else {
516             $timedsql = "";
517             $selectsql = "AND p.created >= :ptimestart";
518         }
520         return $DB->get_records_sql(
521                "SELECT
522                     p.id,
523                     p.discussion,
524                     d.forum,
525                     d.course,
526                     p.created,
527                     p.parent,
528                     p.userid
529                   FROM {forum_posts} p
530                   JOIN {forum_discussions} d ON d.id = p.discussion
531                  WHERE p.mailed = :mailed
532                 $selectsql
533                    AND (p.created < :ptimeend OR p.mailnow = :mailnow)
534                 $timedsql
535                  ORDER BY p.modified ASC",
536              $params);
537     }