Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.78% |
81 / 82 |
|
80.00% |
4 / 5 |
CRAP | |
0.00% |
0 / 1 |
| detector | |
98.78% |
81 / 82 |
|
80.00% |
4 / 5 |
25 | |
0.00% |
0 / 1 |
| refresh_all | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| refresh_course | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
14 | |||
| students | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
2.00 | |||
| missed | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| grades | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
6 | |||
| 1 | <?php |
| 2 | // This file is part of Moodle - https://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 <https://www.gnu.org/licenses/>. |
| 16 | |
| 17 | namespace local_atrisk\local; |
| 18 | |
| 19 | /** |
| 20 | * Works out which students in a course may be falling behind, and stores them as flags. |
| 21 | * |
| 22 | * A student is flagged for any of three reasons, each set in the plugin settings: |
| 23 | * no visit to the course for a number of days, assignments past their due date and not submitted, |
| 24 | * or a course total under a percentage. Flags are rebuilt from scratch on each refresh, so a flag |
| 25 | * disappears as soon as its reason does. |
| 26 | * |
| 27 | * @package local_atrisk |
| 28 | * @copyright 2026 DevLMS <contact@devlms.com> |
| 29 | * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later |
| 30 | */ |
| 31 | class detector { |
| 32 | /** |
| 33 | * Rebuild flags for every visible course except the front page. |
| 34 | */ |
| 35 | public static function refresh_all(): void { |
| 36 | global $DB; |
| 37 | $ids = $DB->get_fieldset_select('course', 'id', 'id <> :site AND visible = 1', ['site' => SITEID]); |
| 38 | foreach ($ids as $id) { |
| 39 | self::refresh_course((int) $id); |
| 40 | } |
| 41 | // Hidden or deleted courses keep no flags. |
| 42 | $DB->delete_records_select( |
| 43 | 'local_atrisk_flag', |
| 44 | 'courseid NOT IN (SELECT id FROM {course} WHERE visible = 1)' |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * Rebuild the flags for one course. |
| 50 | * |
| 51 | * @param int $courseid |
| 52 | */ |
| 53 | public static function refresh_course(int $courseid): void { |
| 54 | global $DB; |
| 55 | $now = time(); |
| 56 | $inactivedays = max(0, (int) get_config('local_atrisk', 'inactivedays')); |
| 57 | $missedlimit = max(0, (int) get_config('local_atrisk', 'misseddeadlines')); |
| 58 | $gradebelow = max(0, (int) get_config('local_atrisk', 'gradebelow')); |
| 59 | |
| 60 | $students = self::students($courseid); |
| 61 | $flags = []; |
| 62 | if ($students) { |
| 63 | $missed = $missedlimit ? self::missed($courseid, array_keys($students), $now) : []; |
| 64 | $grades = $gradebelow ? self::grades($courseid, array_keys($students)) : []; |
| 65 | foreach ($students as $userid => $s) { |
| 66 | $since = $s->lastaccess ?: $s->enrolled; |
| 67 | $inactive = (int) floor(($now - $since) / DAYSECS); |
| 68 | $m = $missed[$userid] ?? 0; |
| 69 | $g = $grades[$userid] ?? null; |
| 70 | $why = ($inactivedays && $inactive >= $inactivedays) |
| 71 | || ($missedlimit && $m >= $missedlimit) |
| 72 | || ($gradebelow && $g !== null && $g < $gradebelow); |
| 73 | if ($why) { |
| 74 | $flags[] = (object) [ |
| 75 | 'userid' => $userid, |
| 76 | 'courseid' => $courseid, |
| 77 | 'inactive' => $inactive, |
| 78 | 'missed' => $m, |
| 79 | 'grade' => $g, |
| 80 | 'timecreated' => $now, |
| 81 | ]; |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | $tx = $DB->start_delegated_transaction(); |
| 86 | $DB->delete_records('local_atrisk_flag', ['courseid' => $courseid]); |
| 87 | if ($flags) { |
| 88 | $DB->insert_records('local_atrisk_flag', $flags); |
| 89 | } |
| 90 | $tx->allow_commit(); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Active students in the course, with their last visit and when they were enrolled. |
| 95 | * |
| 96 | * Students are users with an active enrolment and a role from the student archetype. |
| 97 | * |
| 98 | * @param int $courseid |
| 99 | * @return array userid => {lastaccess, enrolled} |
| 100 | */ |
| 101 | private static function students(int $courseid): array { |
| 102 | global $DB; |
| 103 | $roles = array_keys(get_archetype_roles('student')); |
| 104 | if (!$roles) { |
| 105 | return []; |
| 106 | } |
| 107 | $context = \context_course::instance($courseid); |
| 108 | [$rolesql, $params] = $DB->get_in_or_equal($roles, SQL_PARAMS_NAMED, 'r'); |
| 109 | $now = time(); |
| 110 | $params += [ |
| 111 | 'courseid' => $courseid, 'courseid2' => $courseid, 'ctx' => $context->id, |
| 112 | 'active' => ENROL_USER_ACTIVE, 'enabled' => ENROL_INSTANCE_ENABLED, |
| 113 | 'now1' => $now, 'now2' => $now, |
| 114 | ]; |
| 115 | $sql = "SELECT u.id, COALESCE(la.timeaccess, 0) AS lastaccess, |
| 116 | MIN(CASE WHEN ue.timestart > 0 THEN ue.timestart ELSE ue.timecreated END) AS enrolled |
| 117 | FROM {user} u |
| 118 | JOIN {user_enrolments} ue ON ue.userid = u.id AND ue.status = :active |
| 119 | JOIN {enrol} e ON e.id = ue.enrolid AND e.courseid = :courseid AND e.status = :enabled |
| 120 | JOIN {role_assignments} ra ON ra.userid = u.id AND ra.contextid = :ctx AND ra.roleid $rolesql |
| 121 | LEFT JOIN {user_lastaccess} la ON la.userid = u.id AND la.courseid = :courseid2 |
| 122 | WHERE u.deleted = 0 AND u.suspended = 0 |
| 123 | AND (ue.timestart = 0 OR ue.timestart <= :now1) |
| 124 | AND (ue.timeend = 0 OR ue.timeend > :now2) |
| 125 | GROUP BY u.id, la.timeaccess"; |
| 126 | return $DB->get_records_sql($sql, $params); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Assignments past their due date (or extension) that each student has not submitted. |
| 131 | * |
| 132 | * @param int $courseid |
| 133 | * @param int[] $userids |
| 134 | * @param int $now |
| 135 | * @return array userid => count |
| 136 | */ |
| 137 | private static function missed(int $courseid, array $userids, int $now): array { |
| 138 | global $DB; |
| 139 | [$usql, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'u'); |
| 140 | $params += ['courseid' => $courseid, 'now1' => $now, 'now2' => $now, 'submitted' => 'submitted', 'modname' => 'assign']; |
| 141 | $sql = "SELECT u.id, COUNT(a.id) AS missed |
| 142 | FROM {user} u |
| 143 | JOIN {assign} a ON a.course = :courseid AND a.duedate > 0 AND a.duedate < :now1 |
| 144 | JOIN {modules} m ON m.name = :modname |
| 145 | JOIN {course_modules} cm ON cm.instance = a.id AND cm.module = m.id |
| 146 | AND cm.visible = 1 AND cm.deletioninprogress = 0 |
| 147 | LEFT JOIN {assign_user_flags} f ON f.assignment = a.id AND f.userid = u.id |
| 148 | LEFT JOIN {assign_submission} s ON s.assignment = a.id AND s.userid = u.id |
| 149 | AND s.latest = 1 AND s.status = :submitted |
| 150 | WHERE u.id $usql |
| 151 | AND s.id IS NULL |
| 152 | AND (f.extensionduedate IS NULL OR f.extensionduedate = 0 OR f.extensionduedate < :now2) |
| 153 | GROUP BY u.id"; |
| 154 | return $DB->get_records_sql_menu($sql, $params); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Course totals as percentages, for students who have one. |
| 159 | * |
| 160 | * @param int $courseid |
| 161 | * @param int[] $userids |
| 162 | * @return array userid => percentage |
| 163 | */ |
| 164 | private static function grades(int $courseid, array $userids): array { |
| 165 | global $CFG, $DB; |
| 166 | require_once($CFG->libdir . '/gradelib.php'); |
| 167 | grade_regrade_final_grades_if_required(get_course($courseid)); |
| 168 | $item = \grade_item::fetch_course_item($courseid); |
| 169 | if (!$item || $item->grademax <= $item->grademin) { |
| 170 | return []; |
| 171 | } |
| 172 | [$usql, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'u'); |
| 173 | $params['itemid'] = $item->id; |
| 174 | // Natural aggregation leaves ungraded items out of each student's own maximum, which it stores |
| 175 | // per student in rawgrademax. Use that when it is set, the item's range otherwise. |
| 176 | $rows = $DB->get_records_select( |
| 177 | 'grade_grades', |
| 178 | "itemid = :itemid AND finalgrade IS NOT NULL AND userid $usql", |
| 179 | $params, |
| 180 | '', |
| 181 | 'userid, finalgrade, rawgrademin, rawgrademax' |
| 182 | ); |
| 183 | $out = []; |
| 184 | foreach ($rows as $r) { |
| 185 | $min = (float) $item->grademin; |
| 186 | $max = (float) $item->grademax; |
| 187 | if ($r->rawgrademax !== null && (float) $r->rawgrademax > (float) $r->rawgrademin) { |
| 188 | $min = (float) $r->rawgrademin; |
| 189 | $max = (float) $r->rawgrademax; |
| 190 | } |
| 191 | $out[$r->userid] = round(($r->finalgrade - $min) / ($max - $min) * 100, 2); |
| 192 | } |
| 193 | return $out; |
| 194 | } |
| 195 | } |