Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.78% covered (success)
98.78%
81 / 82
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
detector
98.78% covered (success)
98.78%
81 / 82
80.00% covered (warning)
80.00%
4 / 5
25
0.00% covered (danger)
0.00%
0 / 1
 refresh_all
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 refresh_course
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
14
 students
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 missed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 grades
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
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
17namespace 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 */
31class 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}