Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401]

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