This commit is contained in:
Jun Pataleta 2024-07-03 13:44:07 +08:00
commit 83184bce49
No known key found for this signature in database
GPG Key ID: F83510526D99E2C7
9 changed files with 675 additions and 3 deletions

View File

@ -0,0 +1,270 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_assign;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/assign/locallib.php');
/**
* Helper for sending assignment related notifications.
*
* @package mod_assign
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class notification_helper {
/**
* @var int Due soon time interval of 48 hours.
*/
private const INTERVAL_DUE_SOON = (DAYSECS * 2);
/**
* @var string Due soon notification type.
*/
public const TYPE_DUE_SOON = 'assign_due_soon';
/**
* Get all assignments that have an approaching due date (includes users and groups with due date overrides).
*
* @return \moodle_recordset Returns the matching assignment records.
*/
public static function get_due_soon_assignments(): \moodle_recordset {
global $DB;
$timenow = self::get_time_now();
$futuretime = self::get_future_time(self::INTERVAL_DUE_SOON);
$sql = "SELECT DISTINCT a.id
FROM {assign} a
JOIN {course_modules} cm ON a.id = cm.instance
JOIN {modules} m ON cm.module = m.id AND m.name = :modulename
LEFT JOIN {assign_overrides} ao ON a.id = ao.assignid
WHERE (a.duedate < :futuretime OR ao.duedate < :ao_futuretime)
AND (a.duedate > :timenow OR ao.duedate > :ao_timenow)";
$params = [
'timenow' => $timenow,
'futuretime' => $futuretime,
'ao_timenow' => $timenow,
'ao_futuretime' => $futuretime,
'modulename' => 'assign',
];
return $DB->get_recordset_sql($sql, $params);
}
/**
* Get all users that have an approaching due date within an assignment.
*
* @param int $assignmentid The assignment id.
* @param string $type The notification type.
* @return array The users after all filtering has been applied.
*/
public static function get_users_within_assignment(int $assignmentid, string $type): array {
// Get assignment data.
$assignmentobj = self::get_assignment_data($assignmentid);
// Get our assignment users.
$users = $assignmentobj->list_participants(0, true);
foreach ($users as $key => $user) {
// Check if the user has submitted already.
if ($assignmentobj->get_user_submission($user->id, false)) {
unset($users[$key]);
continue;
}
// Determine the user's due date with respect to any overrides.
$duedate = $assignmentobj->override_exists($user->id)->duedate ?? $assignmentobj->get_instance()->duedate;
// If the due date has no value, unset this user.
if (empty($duedate)) {
unset($users[$key]);
continue;
}
// Perform some checks depending on the notification type.
$match = [];
switch ($type) {
case self::TYPE_DUE_SOON:
$range = [
'lower' => self::get_time_now(),
'upper' => self::get_future_time(self::INTERVAL_DUE_SOON),
];
if (!self::is_time_within_range($duedate, $range)) {
unset($users[$key]);
break;
}
$match = [
'assignmentid' => $assignmentid,
'duedate' => $duedate,
];
break;
default:
break;
}
// Check if the user has already received this notification.
if (self::has_user_been_sent_a_notification_already($user->id, json_encode($match), $type)) {
unset($users[$key]);
}
}
return $users;
}
/**
* Send the due soon notification to the user.
*
* @param int $assignmentid The assignment id.
* @param int $userid The user id.
*/
public static function send_due_soon_notification_to_user(int $assignmentid, int $userid): void {
// Get assignment data.
$assignmentobj = self::get_assignment_data($assignmentid);
// Check if the due date still within range.
$assignmentobj->update_effective_access($userid);
$duedate = $assignmentobj->get_instance($userid)->duedate;
$range = [
'lower' => self::get_time_now(),
'upper' => self::get_future_time(self::INTERVAL_DUE_SOON),
];
if (!self::is_time_within_range($duedate, $range)) {
return;
}
// Check if the user has submitted already.
if ($assignmentobj->get_user_submission($userid, false)) {
return;
}
// Build the user's notification message.
$user = $assignmentobj->get_participant($userid);
$urlparams = [
'id' => $assignmentobj->get_course_module()->id,
'action' => 'view',
];
$url = new \moodle_url('/mod/assign/view.php', $urlparams);
$stringparams = [
'firstname' => $user->firstname,
'assignmentname' => $assignmentobj->get_instance()->name,
'coursename' => $assignmentobj->get_course()->fullname,
'duedate' => userdate($duedate),
'url' => $url,
];
$messagedata = [
'user' => \core_user::get_user($user->id),
'url' => $url->out(false),
'subject' => get_string('assignmentduesoonsubject', 'mod_assign', $stringparams),
'assignmentname' => $assignmentobj->get_instance()->name,
'html' => get_string('assignmentduesoonhtml', 'mod_assign', $stringparams),
];
$message = new \core\message\message();
$message->component = 'mod_assign';
$message->name = self::TYPE_DUE_SOON;
$message->userfrom = \core_user::get_noreply_user();
$message->userto = $messagedata['user'];
$message->subject = $messagedata['subject'];
$message->fullmessageformat = FORMAT_HTML;
$message->fullmessage = html_to_text($messagedata['html']);
$message->fullmessagehtml = $messagedata['html'];
$message->smallmessage = $messagedata['subject'];
$message->notification = 1;
$message->contexturl = $messagedata['url'];
$message->contexturlname = $messagedata['assignmentname'];
// Use custom data to avoid future notifications being sent again.
$message->customdata = [
'assignmentid' => $assignmentid,
'duedate' => $duedate,
];
message_send($message);
}
/**
* Get the time now.
*
* @return int The time now as a timestamp.
*/
protected static function get_time_now(): int {
return \core\di::get(\core\clock::class)->time();
}
/**
* Get a future time.
*
* @param int $interval Amount of seconds added to the now time.
* @return int The time now value plus the interval.
*/
protected static function get_future_time(int $interval): int {
return self::get_time_now() + $interval;
}
/**
* Check if a time is within the current time now and the future time values.
*
* @param int $time The timestamp to check.
* @param array $range Lower and upper times to check.
* @return boolean
*/
protected static function is_time_within_range(int $time, array $range): bool {
return ($time > $range['lower'] && $time < $range['upper']);
}
/**
* Check if a user has been sent a notification already.
*
* @param int $userid The user id.
* @param string $match The custom data string to match on.
* @param string $type The notification/event type to match.
* @return bool Returns true if already sent.
*/
protected static function has_user_been_sent_a_notification_already(int $userid, string $match, string $type): bool {
global $DB;
$sql = $DB->sql_compare_text('customdata', 255) . " = " . $DB->sql_compare_text(':match', 255) . "
AND useridto = :userid
AND component = :component
AND eventtype = :eventtype";
return $DB->count_records_select('notifications', $sql, [
'userid' => $userid,
'match' => $match,
'component' => 'mod_assign',
'eventtype' => $type,
]);
}
/**
* Get the assignment object, including the course and course module.
*
* @param int $assignmentid The assignment id.
* @return \assign Returns the assign object.
*/
protected static function get_assignment_data(int $assignmentid): \assign {
[$course, $assigncm] = get_course_and_cm_from_instance($assignmentid, 'assign');
$cmcontext = \context_module::instance($assigncm->id);
return new \assign($cmcontext, $assigncm, $course);
}
}

View File

@ -0,0 +1,52 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_assign\task;
use core\task\scheduled_task;
use mod_assign\notification_helper;
/**
* Scheduled task to queue tasks for notifying about assignments with an approaching due date.
*
* @package mod_assign
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class queue_all_assignment_due_soon_notification_tasks extends scheduled_task {
/**
* Return the task name.
*
* @return string The name of the task.
*/
public function get_name(): string {
return get_string('sendnotificationduedatesoon', 'mod_assign');
}
/**
* Execute the task.
*/
public function execute(): void {
$assignments = notification_helper::get_due_soon_assignments();
foreach ($assignments as $assignment) {
$task = new queue_assignment_due_soon_notification_tasks_for_users();
$task->set_custom_data($assignment);
\core\task\manager::queue_adhoc_task($task, true);
}
$assignments->close();
}
}

View File

@ -0,0 +1,48 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_assign\task;
use core\task\adhoc_task;
use mod_assign\notification_helper;
/**
* Ad-hoc task to queue another task for notifying a user about an approaching due date.
*
* @package mod_assign
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class queue_assignment_due_soon_notification_tasks_for_users extends adhoc_task {
/**
* Execute the task.
*/
public function execute(): void {
$assignmentid = $this->get_custom_data()->id;
$type = notification_helper::TYPE_DUE_SOON;
$users = notification_helper::get_users_within_assignment($assignmentid, $type);
foreach ($users as $user) {
$task = new send_assignment_due_soon_notification_to_user();
$task->set_custom_data([
'assignmentid' => $assignmentid,
'userid' => $user->id,
]);
$task->set_userid($user->id);
\core\task\manager::queue_adhoc_task($task, true);
}
}
}

View File

@ -0,0 +1,39 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_assign\task;
use core\task\adhoc_task;
use mod_assign\notification_helper;
/**
* Ad-hoc task to send a notification to a user about an approaching due date.
*
* @package mod_assign
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class send_assignment_due_soon_notification_to_user extends adhoc_task {
/**
* Execute the task.
*/
public function execute(): void {
$assignmentid = $this->get_custom_data()->assignmentid;
$userid = $this->get_custom_data()->userid;
notification_helper::send_due_soon_notification_to_user($assignmentid, $userid);
}
}

View File

@ -31,5 +31,12 @@ $messageproviders = array (
'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED,
],
],
// Assignments with a due date soon.
'assign_due_soon' => [
'defaults' => [
'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED,
'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED,
'airnotifier' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED,
],
],
);

View File

@ -30,5 +30,14 @@ $tasks = array(
'day' => '*',
'month' => '*',
'dayofweek' => '*'
)
),
[
'classname' => '\mod_assign\task\queue_all_assignment_due_soon_notification_tasks',
'blocking' => 0,
'minute' => 'R',
'hour' => '*/2',
'day' => '*',
'month' => '*',
'dayofweek' => '*',
],
);

View File

@ -67,6 +67,11 @@ $string['assign:view'] = 'View assignment';
$string['assign:viewownsubmissionsummary'] = 'View own submission summary';
$string['assignfeedback'] = 'Feedback plugin';
$string['assignfeedbackpluginname'] = 'Feedback plugin';
$string['assignmentduesoonhtml'] = '<p>Hi {$a->firstname},</p>
<p>The assignment <strong>{$a->assignmentname}</strong> in course {$a->coursename} is due soon.</p>
<p><strong>Due: {$a->duedate}</strong></p>
<p><a href="{$a->url}">Go to activity</a></p>';
$string['assignmentduesoonsubject'] = 'Due on {$a->duedate}: {$a->assignmentname}';
$string['assignmentisdue'] = 'Assignment is due';
$string['assignmentmail'] = '{$a->grader} has posted some feedback on your
assignment submission for \'{$a->assignment}\'
@ -375,6 +380,7 @@ $string['maxgrade'] = 'Maximum grade';
$string['maxgrade'] = 'Maximum Grade';
$string['maxperpage'] = 'Maximum assignments per page';
$string['maxperpage_help'] = 'The maximum number of assignments a grader can show in the assignment grading page. This setting is useful in preventing timeouts for courses with a large number of participants.';
$string['messageprovider:assign_due_soon'] = 'Assignment due soon notification';
$string['messageprovider:assign_notification'] = 'Assignment notifications';
$string['modulename'] = 'Assignment';
$string['modulename_help'] = 'The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback.
@ -521,6 +527,7 @@ $string['selectlink'] = 'Select...';
$string['selectuser'] = 'Select {$a}';
$string['sendlatenotifications'] = 'Notify graders about late submissions';
$string['sendlatenotifications_help'] = 'If enabled, graders (usually teachers) receive a message whenever a student submits an assignment late. Message methods are configurable.';
$string['sendnotificationduedatesoon'] = 'Notify user of an approaching assignment due date';
$string['sendsubmissionreceipts'] = 'Send submission receipt to students';
$string['sendsubmissionreceipts_help'] = 'This switch enables submission receipts for students. Students will receive a notification every time they successfully submit an assignment.';
$string['setmarkingallocation'] = 'Set allocated marker';

View File

@ -0,0 +1,240 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_assign;
/**
* Test class for the assignment notification_helper.
*
* @package mod_assign
* @category test
* @copyright 2024 David Woloszyn <david.woloszyn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_assign\notification_helper
*/
final class notification_helper_test extends \advanced_testcase {
/**
* Run all the tasks related to the notifications.
*/
public function run_notification_helper_tasks(): void {
$task = \core\task\manager::get_scheduled_task(\mod_assign\task\queue_all_assignment_due_soon_notification_tasks::class);
$task->execute();
$clock = $this->mock_clock_with_frozen();
$adhoctask = \core\task\manager::get_next_adhoc_task($clock->time());
if ($adhoctask) {
$this->assertInstanceOf(\mod_assign\task\queue_assignment_due_soon_notification_tasks_for_users::class, $adhoctask);
$adhoctask->execute();
\core\task\manager::adhoc_task_complete($adhoctask);
}
$adhoctask = \core\task\manager::get_next_adhoc_task($clock->time());
if ($adhoctask) {
$this->assertInstanceOf(\mod_assign\task\send_assignment_due_soon_notification_to_user::class, $adhoctask);
$adhoctask->execute();
\core\task\manager::adhoc_task_complete($adhoctask);
}
}
/**
* Test getting assignments with a 'duedate' date within the date range.
*/
public function test_get_due_soon_assignments(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$helper = \core\di::get(notification_helper::class);
$clock = $this->mock_clock_with_frozen();
// Create an assignment with a due date < 48 hours.
$course = $generator->create_course();
$generator->create_module('assign', ['course' => $course->id, 'duedate' => $clock->time() + DAYSECS]);
// Check that we have a result returned.
$result = $helper::get_due_soon_assignments();
$this->assertTrue($result->valid());
$result->close();
// Time travel 3 days into the future. We should have no assignments in range.
$clock->bump(DAYSECS * 3);
$result = $helper::get_due_soon_assignments();
$this->assertFalse($result->valid());
$result->close();
}
/**
* Test getting users within an assignment that are within our date range.
*/
public function test_get_users_within_assignment(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$helper = \core\di::get(notification_helper::class);
$clock = $this->mock_clock_with_frozen();
// Create a course and enrol some users.
$course = $generator->create_course();
$user1 = $generator->create_user();
$user2 = $generator->create_user();
$user3 = $generator->create_user();
$user4 = $generator->create_user();
$user5 = $generator->create_user();
$user6 = $generator->create_user();
$generator->enrol_user($user1->id, $course->id, 'student');
$generator->enrol_user($user2->id, $course->id, 'student');
$generator->enrol_user($user3->id, $course->id, 'student');
$generator->enrol_user($user4->id, $course->id, 'student');
$generator->enrol_user($user5->id, $course->id, 'student');
$generator->enrol_user($user6->id, $course->id, 'teacher');
/** @var \mod_assign_generator $assignmentgenerator */
$assignmentgenerator = $generator->get_plugin_generator('mod_assign');
// Create an assignment with a due date < 48 hours.
$duedate = $clock->time() + DAYSECS;
$assignment = $assignmentgenerator->create_instance([
'course' => $course->id,
'duedate' => $duedate,
]);
// User1 will have a user override, giving them an extra 1 hour for 'duedate'.
$userduedate = $duedate + HOURSECS;
$assignmentgenerator->create_override([
'assignid' => $assignment->id,
'userid' => $user1->id,
'duedate' => $userduedate,
]);
// User2 and user3 will have a group override, giving them an extra 2 hours for 'duedate'.
$groupduedate = $duedate + (HOURSECS * 2);
$group = $generator->create_group(['courseid' => $course->id]);
$generator->create_group_member(['groupid' => $group->id, 'userid' => $user2->id]);
$generator->create_group_member(['groupid' => $group->id, 'userid' => $user3->id]);
$assignmentgenerator->create_override([
'assignid' => $assignment->id,
'groupid' => $group->id,
'duedate' => $groupduedate,
]);
// User4 will have a user override of one extra week, excluding them from the results.
$userduedate = $duedate + WEEKSECS;
$assignmentgenerator->create_override([
'assignid' => $assignment->id,
'userid' => $user4->id,
'duedate' => $userduedate,
]);
// User5 will submit the assignment, excluding them from the results.
$DB->insert_record('assign_submission', [
'assignment' => $assignment->id,
'userid' => $user5->id,
'status' => 'submitted',
'timemodified' => $clock->time(),
]);
// There should be 3 users with the teacher excluded.
$users = $helper::get_users_within_assignment($assignment->id, $helper::TYPE_DUE_SOON);
$this->assertCount(3, $users);
}
/**
* Test sending the assignment due soon notification to a user.
*/
public function test_send_notification_to_user(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$helper = \core\di::get(notification_helper::class);
$clock = $this->mock_clock_with_frozen();
// Create a course and enrol a user.
$course = $generator->create_course();
$user1 = $generator->create_user();
$generator->enrol_user($user1->id, $course->id, 'student');
/** @var \mod_assign_generator $assignmentgenerator */
$assignmentgenerator = $generator->get_plugin_generator('mod_assign');
// Create an assignment with a due date < 48 hours.
$duedate = $clock->time() + DAYSECS;
$assignment = $assignmentgenerator->create_instance([
'course' => $course->id,
'duedate' => $duedate,
]);
// Run the tasks.
$this->run_notification_helper_tasks();
// Get the assignment object.
[$course, $assigncm] = get_course_and_cm_from_instance($assignment->id, 'assign');
$cmcontext = \context_module::instance($assigncm->id);
$assignmentobj = new \assign($cmcontext, $assigncm, $course);
$duedate = $assignmentobj->get_instance($user1->id)->duedate;
// Get the notifications that should have been created during the adhoc task.
$notifications = $DB->get_records('notifications', ['useridto' => $user1->id]);
$this->assertCount(1, $notifications);
// Check the subject matches.
$stringparams = [
'duedate' => userdate($duedate),
'assignmentname' => $assignment->name,
'type' => $helper::TYPE_DUE_SOON,
];
$expectedsubject = get_string('assignmentduesoonsubject', 'mod_assign', $stringparams);
$this->assertEquals($expectedsubject, reset($notifications)->subject);
// Run the tasks again.
$this->run_notification_helper_tasks();
// There should still only be one notification because nothing has changed.
$notifications = $DB->get_records('notifications', ['useridto' => $user1->id]);
$this->assertCount(1, $notifications);
// Let's modify the 'duedate' for the assignment (it will still be within the 48 hour range).
$updatedata = new \stdClass();
$updatedata->id = $assignment->id;
$updatedata->duedate = $duedate + HOURSECS;
$DB->update_record('assign', $updatedata);
// Run the tasks again.
$this->run_notification_helper_tasks();
// There should now be two notifications.
$notifications = $DB->get_records('notifications', ['useridto' => $user1->id]);
$this->assertCount(2, $notifications);
// Let's modify the 'duedate' one more time.
$updatedata = new \stdClass();
$updatedata->id = $assignment->id;
$updatedata->duedate = $duedate + (HOURSECS * 2);
$DB->update_record('assign', $updatedata);
// This time, the user will submit the assignment.
$DB->insert_record('assign_submission', [
'assignment' => $assignment->id,
'userid' => $user1->id,
'status' => 'submitted',
'timemodified' => $clock->time(),
]);
// Run the tasks again.
$this->run_notification_helper_tasks();
// No new notification should have been sent.
$notifications = $DB->get_records('notifications', ['useridto' => $user1->id]);
$this->assertCount(2, $notifications);
}
}

View File

@ -25,5 +25,5 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'mod_assign'; // Full name of the plugin (used for diagnostics).
$plugin->version = 2024053100; // The current module version (Date: YYYYMMDDXX).
$plugin->version = 2024070201; // The current module version (Date: YYYYMMDDXX).
$plugin->requires = 2024041600; // Requires this Moodle version.