Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.86% covered (success)
98.86%
87 / 88
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
resetter
98.86% covered (success)
98.86%
87 / 88
66.67% covered (warning)
66.67%
2 / 3
26
0.00% covered (danger)
0.00%
0 / 1
 reset
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
10
 snapshot
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
13
 snapshot_scorm
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
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 cm_info;
20use grade_grade;
21use grade_item;
22use moodle_exception;
23
24/**
25 * Resets one learner's progress in a course so they can certify again, after archiving it.
26 *
27 * Clearing completion alone is not enough: Moodle recomputes activity completion from the
28 * module's own data (grades, quiz attempts, SCORM tracks) the next time the learner opens the
29 * activity, and the old completion comes straight back. So the reset removes the data completion
30 * is computed from, through each module's own API, after copying it into the history table.
31 *
32 * Covered: activity and course completion, "viewed" records, gradebook grades, quiz attempts and
33 * SCORM attempts. Assignment submissions are not reset yet.
34 *
35 * @package    local_recertify
36 * @copyright  2026 DevLMS <contact@devlms.com>
37 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38 */
39final class resetter {
40    /**
41     * Archive and reset a learner who completed the course.
42     *
43     * @param int $courseid
44     * @param int $userid
45     * @return int The history row id.
46     */
47    public static function reset(int $courseid, int $userid): int {
48        global $CFG, $DB;
49        require_once($CFG->libdir . '/gradelib.php');
50        require_once($CFG->libdir . '/completionlib.php');
51        require_once($CFG->dirroot . '/mod/quiz/locallib.php');
52        require_once($CFG->dirroot . '/mod/scorm/locallib.php');
53
54        $timecompleted = (int) $DB->get_field(
55            'course_completions',
56            'timecompleted',
57            ['userid' => $userid, 'course' => $courseid]
58        );
59        if (!$timecompleted) {
60            throw new moodle_exception('error:notcompleted', 'local_recertify');
61        }
62        $modinfo = get_fast_modinfo($courseid);
63        [$archive, $finalgrade] = self::snapshot($courseid, $userid, $modinfo);
64
65        $transaction = $DB->start_delegated_transaction();
66        $historyid = (int) $DB->insert_record('local_recertify_history', (object) [
67            'courseid' => $courseid,
68            'userid' => $userid,
69            'timecompleted' => $timecompleted,
70            'timeexpired' => time(),
71            'finalgrade' => $finalgrade,
72            'archive' => json_encode($archive),
73        ]);
74
75        foreach ($modinfo->get_instances_of('quiz') as $cm) {
76            $quiz = $DB->get_record('quiz', ['id' => $cm->instance], '*', MUST_EXIST);
77            $quiz->cmid = $cm->id;
78            foreach ($DB->get_records('quiz_attempts', ['quiz' => $quiz->id, 'userid' => $userid]) as $attempt) {
79                quiz_delete_attempt($attempt, $quiz);
80            }
81        }
82        foreach ($modinfo->get_instances_of('scorm') as $cm) {
83            $scorm = $DB->get_record('scorm', ['id' => $cm->instance], '*', MUST_EXIST);
84            foreach ($DB->get_records('scorm_attempt', ['scormid' => $scorm->id, 'userid' => $userid]) as $attempt) {
85                scorm_delete_attempt($userid, $scorm, $attempt);
86            }
87        }
88        // Every grade in the course, the course total included, through the grade API so the gradebook
89        // history records who removed it.
90        foreach (grade_item::fetch_all(['courseid' => $courseid]) ?: [] as $item) {
91            if ($grade = grade_grade::fetch(['itemid' => $item->id, 'userid' => $userid])) {
92                $grade->delete('local_recertify');
93            }
94            if ($item->is_course_item()) {
95                $item->force_regrading();
96            }
97        }
98
99        $cmsql = 'coursemoduleid IN (SELECT id FROM {course_modules} WHERE course = :courseid) AND userid = :userid';
100        $params = ['courseid' => $courseid, 'userid' => $userid];
101        $DB->delete_records_select('course_modules_completion', $cmsql, $params);
102        $DB->delete_records_select('course_modules_viewed', $cmsql, $params);
103        $DB->delete_records('course_completion_crit_compl', ['course' => $courseid, 'userid' => $userid]);
104        $DB->delete_records('course_completions', ['course' => $courseid, 'userid' => $userid]);
105        $transaction->allow_commit();
106
107        \cache::make('core', 'completion')->delete($userid . '_' . $courseid);
108        \cache::make('core', 'coursecompletion')->delete($userid . '_' . $courseid);
109        return $historyid;
110    }
111
112    /**
113     * Copy everything the reset removes.
114     *
115     * @param int $courseid
116     * @param int $userid
117     * @param \course_modinfo $modinfo
118     * @return array [archive, course total or null]
119     */
120    private static function snapshot(int $courseid, int $userid, \course_modinfo $modinfo): array {
121        global $DB;
122        $archive = ['completion' => [], 'grades' => [], 'quiz' => [], 'scorm' => []];
123        $rows = $DB->get_records_sql(
124            'SELECT cmc.coursemoduleid, cmc.completionstate
125               FROM {course_modules_completion} cmc
126               JOIN {course_modules} cm ON cm.id = cmc.coursemoduleid
127              WHERE cm.course = ? AND cmc.userid = ?',
128            [$courseid, $userid]
129        );
130        foreach ($rows as $row) {
131            $archive['completion'][$row->coursemoduleid] = (int) $row->completionstate;
132        }
133
134        $finalgrade = null;
135        foreach (grade_item::fetch_all(['courseid' => $courseid]) ?: [] as $item) {
136            $grade = grade_grade::fetch(['itemid' => $item->id, 'userid' => $userid]);
137            if (!$grade || $grade->finalgrade === null) {
138                continue;
139            }
140            if ($item->is_course_item()) {
141                $finalgrade = (float) $grade->finalgrade;
142            } else if ($item->itemtype === 'mod' && isset($modinfo->instances[$item->itemmodule][$item->iteminstance])) {
143                $cm = $modinfo->instances[$item->itemmodule][$item->iteminstance];
144                $archive['grades'][$cm->id] = (float) $grade->finalgrade;
145            }
146        }
147
148        foreach ($modinfo->get_instances_of('quiz') as $cm) {
149            $attempts = $DB->get_records('quiz_attempts', ['quiz' => $cm->instance, 'userid' => $userid], 'attempt');
150            foreach ($attempts as $attempt) {
151                $archive['quiz'][$cm->id][] = [
152                    'attempt' => (int) $attempt->attempt,
153                    'state' => $attempt->state,
154                    'sumgrades' => $attempt->sumgrades === null ? null : (float) $attempt->sumgrades,
155                    'timestart' => (int) $attempt->timestart,
156                    'timefinish' => (int) $attempt->timefinish,
157                ];
158            }
159        }
160        foreach ($modinfo->get_instances_of('scorm') as $cm) {
161            self::snapshot_scorm($cm, $userid, $archive);
162        }
163        return [$archive, $finalgrade];
164    }
165
166    /**
167     * Copy a learner's SCORM tracking values, by attempt and by SCO.
168     *
169     * @param cm_info $cm
170     * @param int $userid
171     * @param array $archive
172     */
173    private static function snapshot_scorm(cm_info $cm, int $userid, array &$archive): void {
174        global $DB;
175        foreach ($DB->get_records('scorm_attempt', ['scormid' => $cm->instance, 'userid' => $userid], 'attempt') as $attempt) {
176            $scos = [];
177            $values = $DB->get_recordset_sql(
178                'SELECT v.id, v.scoid, e.element, v.value
179                   FROM {scorm_scoes_value} v
180                   JOIN {scorm_element} e ON e.id = v.elementid
181                  WHERE v.attemptid = ?',
182                [$attempt->id]
183            );
184            foreach ($values as $value) {
185                $scos[$value->scoid][$value->element] = $value->value;
186            }
187            $values->close();
188            $archive['scorm'][$cm->id][] = ['attempt' => (int) $attempt->attempt, 'scos' => $scos];
189        }
190    }
191}