From 168d782ff887cb7ced150a1220cf455780c67b06 Mon Sep 17 00:00:00 2001 From: Mark Nelson Date: Fri, 5 Apr 2019 14:48:06 +0800 Subject: [PATCH] MDL-64017 message_email: task to send messages as a digest --- lib/tests/messagelib_test.php | 53 +++++- .../email/classes/output/email/renderer.php | 46 +++++ .../output/email/renderer_textemail.php | 46 +++++ .../email/classes/output/email_digest.php | 136 ++++++++++++++ .../output/email/classes/output/renderer.php | 50 +++++ .../email/classes/task/send_email_task.php | 173 ++++++++++++++++++ message/output/email/db/tasks.php | 38 ++++ .../output/email/lang/en/message_email.php | 5 + .../email/tests/send_email_task_test.php | 128 +++++++++++++ 9 files changed, 666 insertions(+), 9 deletions(-) create mode 100644 message/output/email/classes/output/email/renderer.php create mode 100644 message/output/email/classes/output/email/renderer_textemail.php create mode 100644 message/output/email/classes/output/email_digest.php create mode 100644 message/output/email/classes/output/renderer.php create mode 100644 message/output/email/classes/task/send_email_task.php create mode 100644 message/output/email/db/tasks.php create mode 100644 message/output/email/tests/send_email_task_test.php diff --git a/lib/tests/messagelib_test.php b/lib/tests/messagelib_test.php index 7faf33d546273..bdbf869495b84 100644 --- a/lib/tests/messagelib_test.php +++ b/lib/tests/messagelib_test.php @@ -836,13 +836,30 @@ public function test_message_send_to_conversation_group() { $this->preventResetByRollback(); $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + // Create some users and a conversation between them. $user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1)); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); set_config('allowedemaildomains', 'example.com'); - $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, - [$user1->id, $user2->id, $user3->id], 'Group project discussion'); + + // Create a group in the course. + $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + groups_add_member($group1->id, $user1->id); + groups_add_member($group1->id, $user2->id); + groups_add_member($group1->id, $user3->id); + + $conversation = \core_message\api::create_conversation( + \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, + [$user1->id, $user2->id, $user3->id], + 'Group project discussion', + \core_message\api::MESSAGE_CONVERSATION_ENABLED, + 'core_group', + 'groups', + $group1->id, + context_course::instance($course->id)->id + ); // Generate the message. $message = new \core\message\message(); @@ -868,8 +885,11 @@ public function test_message_send_to_conversation_group() { set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user3); // Now, send a message and verify the email processor are hit. - $sink = $this->redirectEmails(); $messageid = message_send($message); + + $sink = $this->redirectEmails(); + $task = new \message_email\task\send_email_task(); + $task->execute(); $emails = $sink->get_messages(); $this->assertCount(2, $emails); @@ -902,14 +922,29 @@ public function test_send_message_to_conversation_group_with_buffering() { $this->preventResetByRollback(); $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1)); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); set_config('allowedemaildomains', 'example.com'); - // Create a conversation. - $conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, - [$user1->id, $user2->id, $user3->id], 'Group project discussion'); + // Create a group in the course. + $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + groups_add_member($group1->id, $user1->id); + groups_add_member($group1->id, $user2->id); + groups_add_member($group1->id, $user3->id); + + $conversation = \core_message\api::create_conversation( + \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, + [$user1->id, $user2->id, $user3->id], + 'Group project discussion', + \core_message\api::MESSAGE_CONVERSATION_ENABLED, + 'core_group', + 'groups', + $group1->id, + context_course::instance($course->id)->id + ); // Test basic email redirection. $this->assertFileExists("$CFG->dirroot/message/output/email/version.php"); @@ -939,18 +974,18 @@ public function test_send_message_to_conversation_group_with_buffering() { $transaction = $DB->start_delegated_transaction(); $sink = $this->redirectEmails(); - $messageid = message_send($message); + message_send($message); $emails = $sink->get_messages(); $this->assertCount(0, $emails); - $savedmessage = $DB->get_record('messages', array('id' => $messageid), '*', MUST_EXIST); $sink->clear(); $this->assertFalse($DB->record_exists('message_user_actions', array())); - $DB->delete_records('messages', array()); $events = $eventsink->get_events(); $this->assertCount(0, $events); $eventsink->clear(); $transaction->allow_commit(); $events = $eventsink->get_events(); + $task = new \message_email\task\send_email_task(); + $task->execute(); $emails = $sink->get_messages(); $this->assertCount(2, $emails); $this->assertCount(1, $events); diff --git a/message/output/email/classes/output/email/renderer.php b/message/output/email/classes/output/email/renderer.php new file mode 100644 index 0000000000000..8be449185298c --- /dev/null +++ b/message/output/email/classes/output/email/renderer.php @@ -0,0 +1,46 @@ +. + +/** + * Email digest as html renderer. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace message_email\output\email; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Email digest as html renderer. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends \message_email\output\renderer { + + /** + * The template name for this renderer. + * + * @return string + */ + public function get_template_name() { + return 'email_digest_html'; + } +} diff --git a/message/output/email/classes/output/email/renderer_textemail.php b/message/output/email/classes/output/email/renderer_textemail.php new file mode 100644 index 0000000000000..a35e7b96e5a53 --- /dev/null +++ b/message/output/email/classes/output/email/renderer_textemail.php @@ -0,0 +1,46 @@ +. + +/** + * Email digest as text renderer. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace message_email\output\email; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Email digest as text renderer. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer_textemail extends \message_email\output\renderer { + + /** + * The template name for this renderer. + * + * @return string + */ + public function get_template_name() { + return 'email_digest_text'; + } +} diff --git a/message/output/email/classes/output/email_digest.php b/message/output/email/classes/output/email_digest.php new file mode 100644 index 0000000000000..33d34867dd671 --- /dev/null +++ b/message/output/email/classes/output/email_digest.php @@ -0,0 +1,136 @@ +. + +/** + * Email digest renderable. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace message_email\output; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Email digest renderable. + * + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class email_digest implements \renderable, \templatable { + + /** + * @var array The conversations + */ + protected $conversations = array(); + + /** + * @var array The messages + */ + protected $messages = array(); + + /** + * @var \stdClass The user we want to send the digest email to + */ + protected $userto; + + /** + * The email_digest constructor. + * + * @param \stdClass $userto + */ + public function __construct(\stdClass $userto) { + $this->userto = $userto; + } + + /** + * Adds another conversation to this digest. + * + * @param \stdClass $conversation The conversation from the 'message_conversations' table. + */ + public function add_conversation(\stdClass $conversation) { + $this->conversations[$conversation->id] = $conversation; + } + + /** + * Adds another message to this digest, using the conversation id it belongs to as a key. + * + * @param \stdClass $message The message from the 'messages' table. + */ + public function add_message(\stdClass $message) { + $this->messages[$message->conversationid][] = $message; + } + + /** + * Export this data so it can be used as the context for a mustache template. + * + * @param \renderer_base $renderer The render to be used for formatting the email + * @return \stdClass The data ready for use in a mustache template + */ + public function export_for_template(\renderer_base $renderer) { + // Prepare the data we are going to send to the template. + $data = new \stdClass(); + $data->conversations = []; + + // Don't do anything if there are no messages. + foreach ($this->conversations as $conversation) { + $messages = $this->messages[$conversation->id] ?? []; + + if (empty($messages)) { + continue; + } + + $viewallmessageslink = new \moodle_url('/message/index.php', ['convid' => $conversation->id]); + + $conversationformatted = new \stdClass(); + $conversationformatted->groupname = $conversation->name; + $conversationformatted->coursename = $conversation->coursename; + $conversationformatted->numberofunreadmessages = count($messages); + $conversationformatted->messages = []; + $conversationformatted->viewallmessageslink = \html_writer::link($viewallmessageslink, + get_string('emaildigestviewallmessages', 'message_email')); + + // We only display the last 3 messages. + $messages = array_slice($messages, -3, 3, true); + foreach ($messages as $message) { + $user = new \stdClass(); + username_load_fields_from_object($user, $message); + $user->id = $message->useridfrom; + $messageformatted = new \stdClass(); + $messageformatted->userfullname = fullname($user); + $messageformatted->message = message_format_message_text($message); + + // Check if the message was sent today. + $istoday = userdate($message->timecreated, 'Y-m-d') == userdate(time(), 'Y-m-d'); + if ($istoday) { + $timesent = userdate($message->timecreated, get_string('strftimetime24', 'langconfig')); + } else { + $timesent = userdate($message->timecreated, get_string('strftimedatefullshort', 'langconfig')); + } + + $messageformatted->timesent = $timesent; + + $conversationformatted->messages[] = $messageformatted; + } + + $data->conversations[] = $conversationformatted; + } + + return $data; + } +} diff --git a/message/output/email/classes/output/renderer.php b/message/output/email/classes/output/renderer.php new file mode 100644 index 0000000000000..0a0e7ff663fe8 --- /dev/null +++ b/message/output/email/classes/output/renderer.php @@ -0,0 +1,50 @@ +. + +/** + * Contains renderer class. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace message_email\output; + +defined('MOODLE_INTERNAL') || die(); + +use plugin_renderer_base; + +/** + * Renderer class. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends plugin_renderer_base { + + /** + * Formats the email used to send the certificate by the email_certificate_task. + * + * @param email_digest $emaildigest The certificate to email + * @return string + */ + public function render_email_digest(email_digest $emaildigest) { + $data = $emaildigest->export_for_template($this); + return $this->render_from_template('message_email/' . $this->get_template_name(), $data); + } +} diff --git a/message/output/email/classes/task/send_email_task.php b/message/output/email/classes/task/send_email_task.php new file mode 100644 index 0000000000000..33c42386a3d6e --- /dev/null +++ b/message/output/email/classes/task/send_email_task.php @@ -0,0 +1,173 @@ +. + +/** + * Contains the class responsible for sending emails as a digest. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace message_email\task; + +use core\task\scheduled_task; +use moodle_recordset; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class responsible for sending emails as a digest. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class send_email_task extends scheduled_task { + + /** + * @var int $maxid This is the maximum id of the message in 'message_email_messages'. + * We use this so we know what records to process, as more records may be added + * while this task runs. + */ + private $maxid; + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('tasksendemail', 'message_email'); + } + + /** + * Send out emails. + */ + public function execute() { + global $DB, $PAGE; + + // Get the maximum id we are going to use. + // We use this as records may be added to the table while this task runs. + $this->maxid = $DB->get_field_sql("SELECT MAX(id) FROM {message_email_messages}"); + + // We are going to send these emails from 'noreplyaddress'. + $noreplyuser = \core_user::get_noreply_user(); + + // The renderers used for sending emails. + $htmlrenderer = $PAGE->get_renderer('message_email', 'email', 'htmlemail'); + $textrenderer = $PAGE->get_renderer('message_email', 'email', 'textemail'); + + // Keep track of which emails failed to send. + $users = $this->get_unique_users(); + foreach ($users as $user) { + $hascontent = false; + $renderable = new \message_email\output\email_digest($user); + $conversations = $this->get_conversations_for_user($user->id); + foreach ($conversations as $conversation) { + $renderable->add_conversation($conversation); + $messages = $this->get_users_messages_for_conversation($conversation->id, $user->id); + if ($messages->valid()) { + $hascontent = true; + foreach ($messages as $message) { + $renderable->add_message($message); + } + } + $messages->close(); + } + $conversations->close(); + if ($hascontent) { + $subject = get_string('emaildigestsubject', 'message_email'); + $message = $textrenderer->render($renderable); + $messagehtml = $htmlrenderer->render($renderable); + if (email_to_user($user, $noreplyuser, $subject, $message, $messagehtml)) { + $DB->delete_records_select('message_email_messages', 'useridto = ? AND id <= ?', [$user->id, $this->maxid]); + } + } + } + $users->close(); + } + + /** + * Returns an array of users in the given conversation. + * + * @return moodle_recordset A moodle_recordset instance. + */ + private function get_unique_users() : moodle_recordset { + global $DB; + + $subsql = 'SELECT DISTINCT(useridto) as id + FROM {message_email_messages} + WHERE id <= ?'; + + $sql = "SELECT * + FROM {user} u + WHERE id IN ($subsql)"; + + return $DB->get_recordset_sql($sql, [$this->maxid]); + } + + /** + * Returns an array of unique conversations that require processing. + * + * @param int $userid The ID of the user we are sending a digest to. + * @return moodle_recordset A moodle_recordset instance. + */ + private function get_conversations_for_user(int $userid) : moodle_recordset { + global $DB; + + // We shouldn't be joining directly on the group table as group + // conversations may (in the future) be something created that + // isn't related to an actual group in a course. However, for + // now this will have to do before 3.7 code freeze. + // See related MDL-63814. + $sql = "SELECT mc.id, mc.name, c.id as courseid, c.fullname as coursename, g.id as groupid, g.picture, g.hidepicture + FROM {message_conversations} mc + JOIN {groups} g + ON mc.itemid = g.id + JOIN {course} c + ON g.courseid = c.id + JOIN {message_email_messages} mem + ON mem.conversationid = mc.id + WHERE mem.useridto = ? + AND mem.id <= ?"; + + return $DB->get_recordset_sql($sql, [$userid, $this->maxid]); + } + + /** + * Returns the messages to send to a user for a given conversation + * + * @param int $conversationid + * @param int $userid + * @return moodle_recordset A moodle_recordset instance. + */ + protected function get_users_messages_for_conversation(int $conversationid, int $userid) : moodle_recordset { + global $DB; + + $usernamefields = \user_picture::fields('u'); + $sql = "SELECT $usernamefields, m.* + FROM {messages} m + JOIN {user} u + ON u.id = m.useridfrom + JOIN {message_email_messages} mem + ON mem.messageid = m.id + WHERE mem.useridto = ? + AND mem.conversationid = ? + AND mem.id <= ?"; + + return $DB->get_recordset_sql($sql, [$userid, $conversationid, $this->maxid]); + } +} diff --git a/message/output/email/db/tasks.php b/message/output/email/db/tasks.php new file mode 100644 index 0000000000000..8691988afe268 --- /dev/null +++ b/message/output/email/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * This file defines tasks performed by the plugin. + * + * @package message_email + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +// List of tasks. +$tasks = array( + array( + 'classname' => 'message_email\task\send_email_task', + 'blocking' => 0, + 'minute' => 0, + 'hour' => 22, + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ) +); diff --git a/message/output/email/lang/en/message_email.php b/message/output/email/lang/en/message_email.php index 6e27fcd9d182e..e03da8573117e 100644 --- a/message/output/email/lang/en/message_email.php +++ b/message/output/email/lang/en/message_email.php @@ -23,6 +23,9 @@ */ $string['email'] = 'Send email notifications to'; +$string['emaildigestsubject'] = 'Message digest'; +$string['emaildigestunreadmessages'] = 'Unread messages'; +$string['emaildigestviewallmessages'] = 'View all messages'; $string['emailonlyfromnoreplyaddress'] = 'Always send email from the no-reply address?'; $string['ifemailleftempty'] = 'Leave empty to send notifications to {$a}'; $string['pluginname'] = 'Email'; @@ -40,3 +43,5 @@ $string['privacy:metadata:replytoname'] = 'Name of reply to recipient.'; $string['privacy:metadata:subject'] = 'The subject line of the message.'; $string['privacy:metadata:userfrom'] = 'The user sending the message.'; +$string['tasksendemail'] = 'Task responsible for sending messages as a digest.'; + diff --git a/message/output/email/tests/send_email_task_test.php b/message/output/email/tests/send_email_task_test.php new file mode 100644 index 0000000000000..cd59f5dfb1ac1 --- /dev/null +++ b/message/output/email/tests/send_email_task_test.php @@ -0,0 +1,128 @@ +. + +/** + * Tests the send email task. + * + * @package message_email + * @category test + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +require_once($CFG->dirroot . '/message/tests/messagelib_test.php'); + +/** + * Class for testing the send email task. + * + * @package message_email + * @category test + * @copyright 2019 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_message_send_email_task_testcase extends advanced_testcase { + + /** + * Test sending email task. + */ + public function test_sending_email_task() { + global $DB; + + $this->preventResetByRollback(); // Messaging is not compatible with transactions. + + $this->resetAfterTest(); + + // Create a course. + $course = $this->getDataGenerator()->create_course(); + + $user1 = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $user2 = $this->getDataGenerator()->create_and_enrol($course, 'student'); + + // Create two groups in the course. + $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $group2 = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + + groups_add_member($group1->id, $user1->id); + groups_add_member($group2->id, $user1->id); + + groups_add_member($group1->id, $user2->id); + groups_add_member($group2->id, $user2->id); + + $conversation1 = \core_message\api::create_conversation( + \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, + [$user1->id, $user2->id], + 'Group 1', \core_message\api::MESSAGE_CONVERSATION_ENABLED, + 'core_group', + 'groups', + $group1->id, + context_course::instance($course->id)->id + ); + + $conversation2 = \core_message\api::create_conversation( + \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, + [$user1->id, $user2->id], + 'Group 2', + \core_message\api::MESSAGE_CONVERSATION_ENABLED, + 'core_group', + 'groups', + $group2->id, + context_course::instance($course->id)->id + ); + + // Go through each conversation. + if ($conversations = $DB->get_records('message_conversations')) { + foreach ($conversations as $conversation) { + $conversationid = $conversation->id; + + $message = new \core\message\message(); + $message->courseid = 1; + $message->component = 'moodle'; + $message->name = 'instantmessage'; + $message->userfrom = $user1; + $message->convid = $conversationid; + $message->subject = 'message subject'; + $message->fullmessage = 'message body'; + $message->fullmessageformat = FORMAT_MARKDOWN; + $message->fullmessagehtml = '

message body

'; + $message->smallmessage = 'small message'; + $message->notification = '0'; + + message_send($message); + } + } + + $this->assertEquals(2, $DB->count_records('message_email_messages')); + + // Only 1 email is sent as the 2 messages are included in it at a digest. + $sink = $this->redirectEmails(); + $task = new \message_email\task\send_email_task(); + $task->execute(); + $this->assertEquals(1, $sink->count()); + + // Confirm table was emptied after task was run. + $this->assertEquals(0, $DB->count_records('message_email_messages')); + + // Confirm running it again does not send another. + $sink = $this->redirectEmails(); + $task = new \message_email\task\send_email_task(); + $task->execute(); + $this->assertEquals(0, $sink->count()); + } +}