Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
schedule
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
5 / 5
18
100.00% covered (success)
100.00%
1 / 1
 due_resets
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 due_reminders
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 mark_reminded
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 status
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 completions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
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_recertify\local;
18
19use context_course;
20use stdClass;
21
22/**
23 * Works out who expires, who needs a reminder, and where each learner stands.
24 *
25 * A certification expires a fixed number of days after the course was completed. Only learners
26 * with an active enrolment are reminded or reset.
27 *
28 * @package    local_recertify
29 * @copyright  2026 DevLMS <contact@devlms.com>
30 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
31 */
32final class schedule {
33    /** @var int Seconds in a day. */
34    public const DAY = 86400;
35
36    /**
37     * Learners whose certification has expired by $now.
38     *
39     * @param stdClass $cfg Course settings.
40     * @param int $now
41     * @return int[] User ids.
42     */
43    public static function due_resets(stdClass $cfg, int $now): array {
44        $due = [];
45        foreach (self::completions($cfg) as $row) {
46            if ((int) $row->timecompleted + $cfg->perioddays * self::DAY <= $now) {
47                $due[] = (int) $row->userid;
48            }
49        }
50        return $due;
51    }
52
53    /**
54     * Reminders to send now. Each learner gets the nearest reminder that applies, so a learner
55     * already inside 7 days never gets the 30 day one late.
56     *
57     * @param stdClass $cfg
58     * @param int $now
59     * @return stdClass[] userid, timecompleted, daysbefore, expires
60     */
61    public static function due_reminders(stdClass $cfg, int $now): array {
62        global $DB;
63        $days = settings::reminder_days($cfg);
64        $due = [];
65        foreach (self::completions($cfg) as $row) {
66            $expires = (int) $row->timecompleted + $cfg->perioddays * self::DAY;
67            if ($expires <= $now) {
68                continue;
69            }
70            $applies = array_filter($days, fn($d) => $expires - $d * self::DAY <= $now);
71            if (!$applies) {
72                continue;
73            }
74            $nearest = min($applies);
75            $sent = $DB->record_exists('local_recertify_notice', ['courseid' => $cfg->courseid, 'userid' => $row->userid,
76                'timecompleted' => $row->timecompleted, 'daysbefore' => $nearest]);
77            if (!$sent) {
78                $due[] = (object) ['userid' => (int) $row->userid, 'timecompleted' => (int) $row->timecompleted,
79                    'daysbefore' => $nearest, 'expires' => $expires];
80            }
81        }
82        return $due;
83    }
84
85    /**
86     * Record a reminder as sent, along with every earlier one it replaces.
87     *
88     * @param stdClass $cfg
89     * @param stdClass $reminder From due_reminders().
90     * @param int $now
91     */
92    public static function mark_reminded(stdClass $cfg, stdClass $reminder, int $now): void {
93        global $DB;
94        foreach (settings::reminder_days($cfg) as $days) {
95            $key = ['courseid' => $cfg->courseid, 'userid' => $reminder->userid,
96                'timecompleted' => $reminder->timecompleted, 'daysbefore' => $days];
97            if ($days >= $reminder->daysbefore && !$DB->record_exists('local_recertify_notice', $key)) {
98                $DB->insert_record('local_recertify_notice', (object) ($key + ['timesent' => $now]));
99            }
100        }
101    }
102
103    /**
104     * Where a learner stands: valid, expiring (inside the first reminder), expired (reset, not yet
105     * completed again) or none.
106     *
107     * @param stdClass $cfg
108     * @param int $userid
109     * @param int $now
110     * @return stdClass state, timecompleted, expires, certifications (past ones on record)
111     */
112    public static function status(stdClass $cfg, int $userid, int $now): stdClass {
113        global $DB;
114        $past = $DB->count_records('local_recertify_history', ['courseid' => $cfg->courseid, 'userid' => $userid]);
115        $timecompleted = (int) $DB->get_field(
116            'course_completions',
117            'timecompleted',
118            ['course' => $cfg->courseid, 'userid' => $userid]
119        );
120        if ($timecompleted) {
121            $expires = $timecompleted + $cfg->perioddays * self::DAY;
122            $window = (settings::reminder_days($cfg)[0] ?? 30) * self::DAY;
123            $state = $expires <= $now ? 'expired' : ($expires - $window <= $now ? 'expiring' : 'valid');
124            return (object) ['state' => $state, 'timecompleted' => $timecompleted, 'expires' => $expires,
125                'certifications' => $past];
126        }
127        return (object) ['state' => $past ? 'expired' : 'none', 'timecompleted' => 0, 'expires' => 0,
128            'certifications' => $past];
129    }
130
131    /**
132     * Course completions of learners with an active enrolment, oldest first.
133     *
134     * @param stdClass $cfg
135     * @return stdClass[] userid, timecompleted
136     */
137    private static function completions(stdClass $cfg): array {
138        global $DB;
139        $context = context_course::instance($cfg->courseid);
140        $rows = $DB->get_records_select(
141            'course_completions',
142            'course = ? AND timecompleted > 0',
143            [$cfg->courseid],
144            'timecompleted ASC, id ASC',
145            'id, userid, timecompleted'
146        );
147        return array_values(array_filter($rows, fn($r) => is_enrolled($context, $r->userid, '', true)));
148    }
149}