diff --git a/mod/assign/classes/notification_helper.php b/mod/assign/classes/notification_helper.php new file mode 100644 index 00000000000..80c78519f5d --- /dev/null +++ b/mod/assign/classes/notification_helper.php @@ -0,0 +1,270 @@ +. + +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 + * @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); + } +} diff --git a/mod/assign/classes/task/queue_all_assignment_due_soon_notification_tasks.php b/mod/assign/classes/task/queue_all_assignment_due_soon_notification_tasks.php new file mode 100644 index 00000000000..e36067d1569 --- /dev/null +++ b/mod/assign/classes/task/queue_all_assignment_due_soon_notification_tasks.php @@ -0,0 +1,52 @@ +. + +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 + * @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(); + } +} diff --git a/mod/assign/classes/task/queue_assignment_due_soon_notification_tasks_for_users.php b/mod/assign/classes/task/queue_assignment_due_soon_notification_tasks_for_users.php new file mode 100644 index 00000000000..b0fba125671 --- /dev/null +++ b/mod/assign/classes/task/queue_assignment_due_soon_notification_tasks_for_users.php @@ -0,0 +1,48 @@ +. + +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 + * @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); + } + } +} diff --git a/mod/assign/classes/task/send_assignment_due_soon_notification_to_user.php b/mod/assign/classes/task/send_assignment_due_soon_notification_to_user.php new file mode 100644 index 00000000000..80ecd691fc8 --- /dev/null +++ b/mod/assign/classes/task/send_assignment_due_soon_notification_to_user.php @@ -0,0 +1,39 @@ +. + +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 + * @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); + } +} diff --git a/mod/assign/db/messages.php b/mod/assign/db/messages.php index d298e9d2879..1275509e8b9 100644 --- a/mod/assign/db/messages.php +++ b/mod/assign/db/messages.php @@ -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, + ], + ], ); diff --git a/mod/assign/db/tasks.php b/mod/assign/db/tasks.php index d13e143899f..f845e4ed69c 100644 --- a/mod/assign/db/tasks.php +++ b/mod/assign/db/tasks.php @@ -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' => '*', + ], ); diff --git a/mod/assign/lang/en/assign.php b/mod/assign/lang/en/assign.php index 6939ac3edf1..ded62596673 100644 --- a/mod/assign/lang/en/assign.php +++ b/mod/assign/lang/en/assign.php @@ -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'] = '

Hi {$a->firstname},

+

The assignment {$a->assignmentname} in course {$a->coursename} is due soon.

+

Due: {$a->duedate}

+

Go to activity

'; +$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'; diff --git a/mod/assign/tests/notification_helper_test.php b/mod/assign/tests/notification_helper_test.php new file mode 100644 index 00000000000..0d1f58e7dec --- /dev/null +++ b/mod/assign/tests/notification_helper_test.php @@ -0,0 +1,240 @@ +. + +namespace mod_assign; + +/** + * Test class for the assignment notification_helper. + * + * @package mod_assign + * @category test + * @copyright 2024 David Woloszyn + * @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); + } +} diff --git a/mod/assign/version.php b/mod/assign/version.php index acd834fe27e..84dbc646e11 100644 --- a/mod/assign/version.php +++ b/mod/assign/version.php @@ -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.