Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403]

   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/>.
  16  
  17  /**
  18   * This file defines an adhoc task to send notifications.
  19   *
  20   * @package    mod_forum
  21   * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace mod_forum\task;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  use html_writer;
  30  require_once($CFG->dirroot . '/mod/forum/lib.php');
  31  
  32  /**
  33   * Adhoc task to send moodle forum digests for the specified user.
  34   *
  35   * @package    mod_forum
  36   * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class send_user_digests extends \core\task\adhoc_task {
  40  
  41      // Use the logging trait to get some nice, juicy, logging.
  42      use \core\task\logging_trait;
  43  
  44      /**
  45       * @var \stdClass   A shortcut to $USER.
  46       */
  47      protected $recipient;
  48  
  49      /**
  50       * @var bool[]  Whether the user can view fullnames for each forum.
  51       */
  52      protected $viewfullnames = [];
  53  
  54      /**
  55       * @var bool[]  Whether the user can post in each forum.
  56       */
  57      protected $canpostto = [];
  58  
  59      /**
  60       * @var \stdClass[] Courses with posts them.
  61       */
  62      protected $courses = [];
  63  
  64      /**
  65       * @var \stdClass[] Forums with posts them.
  66       */
  67      protected $forums = [];
  68  
  69      /**
  70       * @var \stdClass[] Discussions with posts them.
  71       */
  72      protected $discussions = [];
  73  
  74      /**
  75       * @var \stdClass[] The posts to be sent.
  76       */
  77      protected $posts = [];
  78  
  79      /**
  80       * @var \stdClass[] The various authors.
  81       */
  82      protected $users = [];
  83  
  84      /**
  85       * @var \stdClass[] A list of any per-forum digest preference that this user holds.
  86       */
  87      protected $forumdigesttypes = [];
  88  
  89      /**
  90       * @var bool    Whether the user has requested HTML or not.
  91       */
  92      protected $allowhtml = true;
  93  
  94      /**
  95       * @var string  The subject of the message.
  96       */
  97      protected $postsubject = '';
  98  
  99      /**
 100       * @var string  The plaintext content of the whole message.
 101       */
 102      protected $notificationtext = '';
 103  
 104      /**
 105       * @var string  The HTML content of the whole message.
 106       */
 107      protected $notificationhtml = '';
 108  
 109      /**
 110       * @var string  The plaintext content for the current discussion being processed.
 111       */
 112      protected $discussiontext = '';
 113  
 114      /**
 115       * @var string  The HTML content for the current discussion being processed.
 116       */
 117      protected $discussionhtml = '';
 118  
 119      /**
 120       * @var int     The number of messages sent in this digest.
 121       */
 122      protected $sentcount = 0;
 123  
 124      /**
 125       * @var \renderer[][] A cache of the different types of renderer, stored both by target (HTML, or Text), and type.
 126       */
 127      protected $renderers = [
 128          'html' => [],
 129          'text' => [],
 130      ];
 131  
 132      /**
 133       * @var int[] A list of post IDs to be marked as read for this user.
 134       */
 135      protected $markpostsasread = [];
 136  
 137      /**
 138       * Send out messages.
 139       */
 140      public function execute() {
 141          $starttime = time();
 142  
 143          $this->recipient = \core_user::get_user($this->get_userid());
 144          $this->log_start("Sending forum digests for {$this->recipient->username} ({$this->recipient->id})");
 145  
 146          if (empty($this->recipient->mailformat) || $this->recipient->mailformat != 1) {
 147              // This user does not want to receive HTML.
 148              $this->allowhtml = false;
 149          }
 150  
 151          // Fetch all of the data we need to mail these posts.
 152          $this->prepare_data($starttime);
 153  
 154          if (empty($this->posts) || empty($this->discussions) || empty($this->forums)) {
 155              $this->log_finish("No messages found to send.");
 156              return;
 157          }
 158  
 159          // Add the message headers.
 160          $this->add_message_header();
 161  
 162          foreach ($this->discussions as $discussion) {
 163              // Raise the time limit for each discussion.
 164              \core_php_time_limit::raise(120);
 165  
 166              // Grab the data pertaining to this discussion.
 167              $forum = $this->forums[$discussion->forum];
 168              $course = $this->courses[$forum->course];
 169              $cm = get_fast_modinfo($course)->instances['forum'][$forum->id];
 170              $modcontext = \context_module::instance($cm->id);
 171              $coursecontext = \context_course::instance($course->id);
 172  
 173              if (empty($this->posts[$discussion->id])) {
 174                  // Somehow there are no posts.
 175                  // This should not happen but better safe than sorry.
 176                  continue;
 177              }
 178  
 179              if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
 180                  // The course is hidden and the user does not have access to it.
 181                  // Permissions may have changed since it was queued.
 182                  continue;
 183              }
 184  
 185              if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) {
 186                  // User cannot see this discussion.
 187                  // Permissions may have changed since it was queued.
 188                  continue;
 189              }
 190  
 191              if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussion->id, $cm)) {
 192                  // The user does not subscribe to this forum as a whole, or to this specific discussion.
 193                  continue;
 194              }
 195  
 196              // Fetch additional values relating to this forum.
 197              if (!isset($this->canpostto[$discussion->id])) {
 198                  $this->canpostto[$discussion->id] = forum_user_can_post(
 199                          $forum, $discussion, $this->recipient, $cm, $course, $modcontext);
 200              }
 201  
 202              if (!isset($this->viewfullnames[$forum->id])) {
 203                  $this->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext, $this->recipient->id);
 204              }
 205  
 206              // Set the discussion storage values.
 207              $discussionpostcount = 0;
 208              $this->discussiontext = '';
 209              $this->discussionhtml = '';
 210  
 211              // Add the header for this discussion.
 212              $this->add_discussion_header($discussion, $forum, $course);
 213              $this->log_start("Adding messages in discussion {$discussion->id} (forum {$forum->id})", 1);
 214  
 215              // Add all posts in this forum.
 216              foreach ($this->posts[$discussion->id] as $post) {
 217                  $author = $this->get_post_author($post->userid, $course, $forum, $cm, $modcontext);
 218                  if (empty($author)) {
 219                      // Unable to find the author. Skip to avoid errors.
 220                      continue;
 221                  }
 222  
 223                  if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) {
 224                      // User cannot see this post.
 225                      // Permissions may have changed since it was queued.
 226                      continue;
 227                  }
 228  
 229                  $this->add_post_body($author, $post, $discussion, $forum, $cm, $course);
 230                  $discussionpostcount++;
 231              }
 232  
 233              // Add the forum footer.
 234              $this->add_discussion_footer($discussion, $forum, $course);
 235  
 236              // Add the data for this discussion to the notification body.
 237              if ($discussionpostcount) {
 238                  $this->sentcount += $discussionpostcount;
 239                  $this->notificationtext .= $this->discussiontext;
 240                  $this->notificationhtml .= $this->discussionhtml;
 241                  $this->log_finish("Added {$discussionpostcount} messages to discussion {$discussion->id}", 1);
 242              } else {
 243                  $this->log_finish("No messages found in discussion {$discussion->id} - skipped.", 1);
 244              }
 245          }
 246  
 247          if ($this->sentcount) {
 248              // This digest has at least one post and should therefore be sent.
 249              if ($this->send_mail()) {
 250                  $this->log_finish("Digest sent with {$this->sentcount} messages.");
 251                  if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) {
 252                      forum_tp_mark_posts_read($this->recipient, $this->markpostsasread);
 253                  }
 254              } else {
 255                  $this->log_finish("Issue sending digest. Skipping.");
 256              }
 257          } else {
 258              $this->log_finish("No messages found to send.");
 259          }
 260  
 261          // We have finishied all digest emails, update $CFG->digestmailtimelast.
 262          set_config('digestmailtimelast', $starttime);
 263      }
 264  
 265      /**
 266       * Prepare the data for this run.
 267       *
 268       * Note: This will also remove posts from the queue.
 269       *
 270       * @param   int     $timenow
 271       */
 272      protected function prepare_data(int $timenow) {
 273          global $DB;
 274  
 275          $sql = "SELECT p.*, f.id AS forum, f.course
 276                    FROM {forum_queue} q
 277              INNER JOIN {forum_posts} p ON p.id = q.postid
 278              INNER JOIN {forum_discussions} d ON d.id = p.discussion
 279              INNER JOIN {forum} f ON f.id = d.forum
 280                   WHERE q.userid = :userid
 281                     AND q.timemodified < :timemodified
 282                ORDER BY d.id, q.timemodified ASC";
 283  
 284          $queueparams = [
 285                  'userid' => $this->recipient->id,
 286                  'timemodified' => $timenow,
 287              ];
 288  
 289          $posts = $DB->get_recordset_sql($sql, $queueparams);
 290          $discussionids = [];
 291          $forumids = [];
 292          $courseids = [];
 293          $userids = [];
 294          foreach ($posts as $post) {
 295              $discussionids[] = $post->discussion;
 296              $forumids[] = $post->forum;
 297              $courseids[] = $post->course;
 298              $userids[] = $post->userid;
 299              unset($post->forum);
 300              if (!isset($this->posts[$post->discussion])) {
 301                  $this->posts[$post->discussion] = [];
 302              }
 303              $this->posts[$post->discussion][$post->id] = $post;
 304          }
 305          $posts->close();
 306  
 307          if (empty($discussionids)) {
 308              // All posts have been removed since the task was queued.
 309              $this->empty_queue($this->recipient->id, $timenow);
 310              return;
 311          }
 312  
 313          list($in, $params) = $DB->get_in_or_equal($discussionids);
 314          $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params);
 315  
 316          list($in, $params) = $DB->get_in_or_equal($forumids);
 317          $this->forums = $DB->get_records_select('forum', "id {$in}", $params);
 318  
 319          list($in, $params) = $DB->get_in_or_equal($courseids);
 320          $this->courses = $DB->get_records_select('course', "id $in", $params);
 321  
 322          list($in, $params) = $DB->get_in_or_equal($userids);
 323          $this->users = $DB->get_records_select('user', "id $in", $params);
 324  
 325          $this->fill_digest_cache();
 326  
 327          $this->empty_queue($this->recipient->id, $timenow);
 328      }
 329  
 330      /**
 331       * Empty the queue of posts for this user.
 332       *
 333       * @param int $userid user id which queue elements are going to be removed.
 334       * @param int $timemodified up time limit of the queue elements to be removed.
 335       */
 336      protected function empty_queue(int $userid, int $timemodified) : void {
 337          global $DB;
 338  
 339          $DB->delete_records_select('forum_queue', "userid = :userid AND timemodified < :timemodified", [
 340                  'userid' => $userid,
 341                  'timemodified' => $timemodified,
 342              ]);
 343      }
 344  
 345      /**
 346       * Fill the cron digest cache.
 347       */
 348      protected function fill_digest_cache() {
 349          global $DB;
 350  
 351          $this->forumdigesttypes = $DB->get_records_menu('forum_digests', [
 352                  'userid' => $this->recipient->id,
 353              ], '', 'forum, maildigest');
 354      }
 355  
 356      /**
 357       * Fetch and initialise the post author.
 358       *
 359       * @param   int         $userid The id of the user to fetch
 360       * @param   \stdClass   $course
 361       * @param   \stdClass   $forum
 362       * @param   \stdClass   $cm
 363       * @param   \context    $context
 364       * @return  \stdClass
 365       */
 366      protected function get_post_author($userid, $course, $forum, $cm, $context) {
 367          if (!isset($this->users[$userid])) {
 368              // This user no longer exists.
 369              return false;
 370          }
 371  
 372          $user = $this->users[$userid];
 373  
 374          if (!isset($user->groups)) {
 375              // Initialise the groups list.
 376              $user->groups = [];
 377          }
 378  
 379          if (!isset($user->groups[$forum->id])) {
 380              $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid);
 381          }
 382  
 383          // Clone the user object to prevent leaks between messages.
 384          return (object) (array) $user;
 385      }
 386  
 387      /**
 388       * Add the header to this message.
 389       */
 390      protected function add_message_header() {
 391          $site = get_site();
 392  
 393          // Set the subject of the message.
 394          $this->postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true));
 395  
 396          // And the content of the header in body.
 397          $headerdata = (object) [
 398              'sitename' => format_string($site->fullname, true),
 399              'userprefs' => (new \moodle_url('/user/forum.php', [
 400                      'id' => $this->recipient->id,
 401                      'course' => $site->id,
 402                  ]))->out(false),
 403              ];
 404  
 405          $this->notificationtext .= get_string('digestmailheader', 'forum', $headerdata) . "\n";
 406  
 407          if ($this->allowhtml) {
 408              $headerdata->userprefs = html_writer::link($headerdata->userprefs, get_string('digestmailprefs', 'forum'), [
 409                      'target' => '_blank',
 410                  ]);
 411  
 412              $this->notificationhtml .= html_writer::tag('p', get_string('digestmailheader', 'forum', $headerdata));
 413              $this->notificationhtml .= html_writer::empty_tag('br');
 414              $this->notificationhtml .= html_writer::empty_tag('hr', [
 415                      'size' => 1,
 416                      'noshade' => 'noshade',
 417                  ]);
 418          }
 419      }
 420  
 421      /**
 422       * Add the header for this discussion.
 423       *
 424       * @param   \stdClass   $discussion The discussion to add the footer for
 425       * @param   \stdClass   $forum The forum that the discussion belongs to
 426       * @param   \stdClass   $course The course that the forum belongs to
 427       */
 428      protected function add_discussion_header($discussion, $forum, $course) {
 429          global $CFG;
 430  
 431          $shortname = format_string($course->shortname, true, [
 432                  'context' => \context_course::instance($course->id),
 433              ]);
 434  
 435          $strforums = get_string('forums', 'forum');
 436  
 437          $this->discussiontext .= "\n=====================================================================\n\n";
 438          $this->discussiontext .= "$shortname -> $strforums -> " . format_string($forum->name, true);
 439          if ($discussion->name != $forum->name) {
 440              $this->discussiontext  .= " -> " . format_string($discussion->name, true);
 441          }
 442          $this->discussiontext .= "\n";
 443          $this->discussiontext .= new \moodle_url('/mod/forum/discuss.php', [
 444                  'd' => $discussion->id,
 445              ]);
 446          $this->discussiontext .= "\n";
 447  
 448          if ($this->allowhtml) {
 449              $this->discussionhtml .= "<p><font face=\"sans-serif\">".
 450                  "<a target=\"_blank\" href=\"$CFG->wwwroot/course/view.php?id=$course->id\">$shortname</a> -> ".
 451                  "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/index.php?id=$course->id\">$strforums</a> -> ".
 452                  "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/view.php?f=$forum->id\">" .
 453                          format_string($forum->name, true)."</a>";
 454              if ($discussion->name == $forum->name) {
 455                  $this->discussionhtml .= "</font></p>";
 456              } else {
 457                  $this->discussionhtml .=
 458                          " -> <a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/discuss.php?d=$discussion->id\">" .
 459                          format_string($discussion->name, true)."</a></font></p>";
 460              }
 461              $this->discussionhtml .= '<p>';
 462          }
 463  
 464      }
 465  
 466      /**
 467       * Add the body of this post.
 468       *
 469       * @param   \stdClass   $author The author of the post
 470       * @param   \stdClass   $post The post being sent
 471       * @param   \stdClass   $discussion The discussion that the post is in
 472       * @param   \stdClass   $forum The forum that the discussion belongs to
 473       * @param   \cminfo     $cm The cminfo object for the forum
 474       * @param   \stdClass   $course The course that the forum belongs to
 475       */
 476      protected function add_post_body($author, $post, $discussion, $forum, $cm, $course) {
 477          global $CFG;
 478  
 479          $canreply = $this->canpostto[$discussion->id];
 480  
 481          $data = new \mod_forum\output\forum_post_email(
 482              $course,
 483              $cm,
 484              $forum,
 485              $discussion,
 486              $post,
 487              $author,
 488              $this->recipient,
 489              $canreply
 490          );
 491  
 492          // Override the viewfullnames value.
 493          $data->viewfullnames = $this->viewfullnames[$forum->id];
 494  
 495          // Determine the type of digest being sent.
 496          $maildigest = $this->get_maildigest($forum->id);
 497  
 498          $textrenderer = $this->get_renderer($maildigest);
 499          $this->discussiontext .= $textrenderer->render($data);
 500          $this->discussiontext .= "\n";
 501          if ($this->allowhtml) {
 502              $htmlrenderer = $this->get_renderer($maildigest, true);
 503              $this->discussionhtml .= $htmlrenderer->render($data);
 504              $this->log("Adding post {$post->id} in format {$maildigest} with HTML", 2);
 505          } else {
 506              $this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2);
 507          }
 508  
 509          if ($maildigest == 1 && !$CFG->forum_usermarksread) {
 510              // Create an array of postid's for this user to mark as read.
 511              $this->markpostsasread[] = $post->id;
 512          }
 513  
 514      }
 515  
 516      /**
 517       * Add the footer for this discussion.
 518       *
 519       * @param   \stdClass   $discussion The discussion to add the footer for
 520       */
 521      protected function add_discussion_footer($discussion) {
 522          global $CFG;
 523  
 524          if ($this->allowhtml) {
 525              $footerlinks = [];
 526  
 527              $forum = $this->forums[$discussion->forum];
 528              if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
 529                  // This forum is force subscribed. The user cannot unsubscribe.
 530                  $footerlinks[] = get_string("everyoneissubscribed", "forum");
 531              } else {
 532                  $footerlinks[] = "<a href=\"$CFG->wwwroot/mod/forum/subscribe.php?id=$forum->id\">" .
 533                      get_string("unsubscribe", "forum") . "</a>";
 534              }
 535              $footerlinks[] = "<a href='{$CFG->wwwroot}/mod/forum/index.php?id={$forum->course}'>" .
 536                      get_string("digestmailpost", "forum") . '</a>';
 537  
 538              $this->discussionhtml .= "\n<div class='mdl-right'><font size=\"1\">" .
 539                      implode('&nbsp;', $footerlinks) . '</font></div>';
 540              $this->discussionhtml .= '<hr size="1" noshade="noshade" /></p>';
 541          }
 542      }
 543  
 544      /**
 545       * Get the forum digest type for the specified forum, failing back to
 546       * the default setting for the current user if not specified.
 547       *
 548       * @param   int     $forumid
 549       * @return  int
 550       */
 551      protected function get_maildigest($forumid) {
 552          $maildigest = -1;
 553  
 554          if (isset($this->forumdigesttypes[$forumid])) {
 555              $maildigest = $this->forumdigesttypes[$forumid];
 556          }
 557  
 558          if ($maildigest === -1 && !empty($this->recipient->maildigest)) {
 559              $maildigest = $this->recipient->maildigest;
 560          }
 561  
 562          if ($maildigest === -1) {
 563              // There is no maildigest type right now.
 564              $maildigest = 1;
 565          }
 566  
 567          return $maildigest;
 568      }
 569  
 570      /**
 571       * Send the composed message to the user.
 572       */
 573      protected function send_mail() {
 574          // Headers to help prevent auto-responders.
 575          $userfrom = \core_user::get_noreply_user();
 576          $userfrom->customheaders = array(
 577              "Precedence: Bulk",
 578              'X-Auto-Response-Suppress: All',
 579              'Auto-Submitted: auto-generated',
 580          );
 581  
 582          $eventdata = new \core\message\message();
 583          $eventdata->courseid = SITEID;
 584          $eventdata->component = 'mod_forum';
 585          $eventdata->name = 'digests';
 586          $eventdata->userfrom = $userfrom;
 587          $eventdata->userto = $this->recipient;
 588          $eventdata->subject = $this->postsubject;
 589          $eventdata->fullmessage = $this->notificationtext;
 590          $eventdata->fullmessageformat = FORMAT_PLAIN;
 591          $eventdata->fullmessagehtml = $this->notificationhtml;
 592          $eventdata->notification = 1;
 593          $eventdata->smallmessage = get_string('smallmessagedigest', 'forum', $this->sentcount);
 594  
 595          return message_send($eventdata);
 596      }
 597  
 598      /**
 599       * Helper to fetch the required renderer, instantiating as required.
 600       *
 601       * @param   int     $maildigest The type of mail digest being sent
 602       * @param   bool    $html Whether to fetch the HTML renderer
 603       * @return  \core_renderer
 604       */
 605      protected function get_renderer($maildigest, $html = false) {
 606          global $PAGE;
 607  
 608          $type = $maildigest == 2 ? 'emaildigestbasic' : 'emaildigestfull';
 609          $target = $html ? 'htmlemail' : 'textemail';
 610  
 611          if (!isset($this->renderers[$target][$type])) {
 612              $this->renderers[$target][$type] = $PAGE->get_renderer('mod_forum', $type, $target);
 613          }
 614  
 615          return $this->renderers[$target][$type];
 616      }
 617  }