Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.00% covered (success)
97.00%
97 / 100
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
submission
98.98% covered (success)
98.98%
97 / 98
90.00% covered (success)
90.00%
9 / 10
25
0.00% covered (danger)
0.00%
0 / 1
 students
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 total
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 current
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 submit
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 withdraw
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 approve
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 return_grade
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 unlock
100.00% covered (success)
100.00%
5 / 5
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_finalgrades\local;
18
19use moodle_exception;
20use stdClass;
21
22defined('MOODLE_INTERNAL') || die();
23
24global $CFG;
25require_once($CFG->libdir . '/gradelib.php');
26
27/**
28 * Submitting, withdrawing, approving and returning a course's final grades.
29 *
30 * A submission stores what the teacher saw (course total, percentage, letter, ID numbers) and locks the
31 * student's course total in the gradebook, so later item changes cannot move it. Withdrawn and returned rows
32 * stay as history; the current submission is the latest submitted or approved row.
33 *
34 * @package    local_finalgrades
35 * @copyright  2026 DevLMS <contact@devlms.com>
36 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 */
38class submission {
39    /** Submitted by the teacher. */
40    public const SUBMITTED = 'submitted';
41    /** Approved, when approval is required. */
42    public const APPROVED = 'approved';
43    /** Sent back to the teacher by an approver. */
44    public const RETURNED = 'returned';
45    /** Taken back by the teacher. */
46    public const WITHDRAWN = 'withdrawn';
47    /** Statuses that hold the gradebook total. */
48    public const CURRENT = [self::SUBMITTED, self::APPROVED];
49
50    /**
51     * The course's students: active enrolments in a gradebook role, by last name.
52     *
53     * @param int $courseid
54     * @return stdClass[] User records keyed by id.
55     */
56    public static function students(int $courseid): array {
57        global $CFG, $DB;
58        $context = \context_course::instance($courseid);
59        $roles = array_filter(explode(',', (string) $CFG->gradebookroles));
60        if (!$roles) {
61            return [];
62        }
63        [$in, $params] = $DB->get_in_or_equal(array_map('intval', $roles), SQL_PARAMS_NAMED, 'role');
64        [$enrolsql, $enrolparams] = get_enrolled_sql($context, '', 0, true);
65        $names = implode(', ', array_map(fn($f) => "u.$f", \core_user\fields::get_name_fields()));
66        $sql = "SELECT u.id, u.idnumber, u.email, u.username, $names
67                  FROM {user} u
68                  JOIN ($enrolsql) je ON je.id = u.id
69                 WHERE u.deleted = 0
70                   AND EXISTS (SELECT 1 FROM {role_assignments} ra
71                                WHERE ra.userid = u.id AND ra.contextid = :contextid AND ra.roleid $in)
72              ORDER BY u.lastname, u.firstname, u.id";
73        return $DB->get_records_sql($sql, $params + $enrolparams + ['contextid' => $context->id]);
74    }
75
76    /**
77     * The course total grade of a student, refreshed.
78     *
79     * @param int $courseid
80     * @param int $userid
81     * @return \grade_grade|null Null when the course has no total yet.
82     */
83    public static function total(int $courseid, int $userid): ?\grade_grade {
84        $item = \grade_item::fetch_course_item($courseid);
85        $grade = \grade_grade::fetch(['itemid' => $item->id, 'userid' => $userid]);
86        if (!$grade) {
87            return null;
88        }
89        $grade->grade_item = $item;
90        return $grade;
91    }
92
93    /**
94     * The student's current submission: the latest one that is submitted or approved.
95     *
96     * @param int $courseid
97     * @param int $userid
98     * @return stdClass|null
99     */
100    public static function current(int $courseid, int $userid): ?stdClass {
101        global $DB;
102        [$in, $params] = $DB->get_in_or_equal(self::CURRENT, SQL_PARAMS_NAMED);
103        $rows = $DB->get_records_select(
104            'local_finalgrades_sub',
105            "courseid = :courseid AND userid = :userid AND status $in",
106            $params + ['courseid' => $courseid, 'userid' => $userid],
107            'id DESC',
108            '*',
109            0,
110            1
111        );
112        return $rows ? reset($rows) : null;
113    }
114
115    /**
116     * Submit the course totals of these students and lock them in the gradebook.
117     *
118     * Students without a course total, already submitted, or not students of the course are skipped.
119     *
120     * @param stdClass $course
121     * @param int[] $userids
122     * @param int $by The teacher.
123     * @return int How many grades were submitted.
124     */
125    public static function submit(stdClass $course, array $userids, int $by): int {
126        global $DB;
127        grade_regrade_final_grades($course->id);
128        $students = self::students($course->id);
129        $count = 0;
130        foreach (array_unique(array_map('intval', $userids)) as $userid) {
131            if (!isset($students[$userid]) || self::current($course->id, $userid)) {
132                continue;
133            }
134            $grade = self::total($course->id, $userid);
135            if (!$grade || $grade->finalgrade === null) {
136                continue;
137            }
138            $value = (float) $grade->finalgrade;
139            $id = $DB->insert_record('local_finalgrades_sub', (object) [
140                'courseid' => $course->id,
141                'userid' => $userid,
142                'grade' => $value,
143                'percentage' => grade_format_gradevalue($value, $grade->grade_item, true, GRADE_DISPLAY_TYPE_PERCENTAGE),
144                'letter' => grade_format_gradevalue($value, $grade->grade_item, true, GRADE_DISPLAY_TYPE_LETTER),
145                'useridnumber' => $students[$userid]->idnumber,
146                'courseidnumber' => $course->idnumber,
147                'status' => self::SUBMITTED,
148                'submittedby' => $by,
149                'timesubmitted' => time(),
150            ]);
151            $grade->set_locked(1, false, false);
152            self::log('grade_submitted', $course->id, $id, $userid);
153            $count++;
154        }
155        return $count;
156    }
157
158    /**
159     * Take back a submitted grade: unlock the total so it follows the gradebook again.
160     *
161     * @param stdClass $course
162     * @param int $userid
163     * @param string $reason Required, kept with the history.
164     * @param int $by
165     */
166    public static function withdraw(stdClass $course, int $userid, string $reason, int $by): void {
167        $row = self::close($course->id, $userid, self::WITHDRAWN, $reason, $by);
168        self::log('grade_withdrawn', $course->id, $row->id, $userid, ['reason' => $row->reason]);
169    }
170
171    /**
172     * Approve submitted grades. Approved grades keep the gradebook total locked.
173     *
174     * @param stdClass $course
175     * @param int[] $userids
176     * @param int $by The approver.
177     * @return int How many grades were approved.
178     */
179    public static function approve(stdClass $course, array $userids, int $by): int {
180        global $DB;
181        $count = 0;
182        foreach (array_unique(array_map('intval', $userids)) as $userid) {
183            $row = self::current($course->id, $userid);
184            if (!$row || $row->status !== self::SUBMITTED) {
185                continue;
186            }
187            $DB->update_record('local_finalgrades_sub', (object) [
188                'id' => $row->id,
189                'status' => self::APPROVED,
190                'reviewedby' => $by,
191                'timereviewed' => time(),
192            ]);
193            self::log('grade_approved', $course->id, $row->id, $userid);
194            $count++;
195        }
196        return $count;
197    }
198
199    /**
200     * Send a submitted grade back to the teacher with a reason, and unlock the total.
201     *
202     * @param stdClass $course
203     * @param int $userid
204     * @param string $reason
205     * @param int $by The approver.
206     */
207    public static function return_grade(stdClass $course, int $userid, string $reason, int $by): void {
208        $row = self::close($course->id, $userid, self::RETURNED, $reason, $by);
209        self::log('grade_returned', $course->id, $row->id, $userid, ['reason' => $row->reason]);
210    }
211
212    /**
213     * Trigger one of this plugin's events about a student's grade.
214     *
215     * @param string $name Event class name.
216     * @param int $courseid
217     * @param int $rowid
218     * @param int $userid The student.
219     * @param array $other
220     */
221    protected static function log(string $name, int $courseid, int $rowid, int $userid, array $other = []): void {
222        $class = '\\local_finalgrades\\event\\' . $name;
223        $class::create([
224            'context' => \context_course::instance($courseid),
225            'objectid' => $rowid,
226            'relateduserid' => $userid,
227            'other' => $other,
228        ])->trigger();
229    }
230
231    /**
232     * Close the current submission with a status and a reason, and unlock the total.
233     *
234     * @param int $courseid
235     * @param int $userid
236     * @param string $status
237     * @param string $reason
238     * @param int $by
239     * @return stdClass The closed row.
240     */
241    protected static function close(int $courseid, int $userid, string $status, string $reason, int $by): stdClass {
242        global $DB;
243        $row = self::current($courseid, $userid);
244        if (!$row) {
245            throw new moodle_exception('notsubmitted', 'local_finalgrades');
246        }
247        $reason = trim($reason);
248        if ($reason === '') {
249            throw new moodle_exception('reasonrequired', 'local_finalgrades');
250        }
251        $row->status = $status;
252        $row->reason = $reason;
253        $row->reviewedby = $by;
254        $row->timereviewed = time();
255        $DB->update_record('local_finalgrades_sub', $row);
256        self::unlock($courseid, $userid);
257        return $row;
258    }
259
260    /**
261     * Unlock a student's course total and let the gradebook recalculate it.
262     *
263     * @param int $courseid
264     * @param int $userid
265     */
266    protected static function unlock(int $courseid, int $userid): void {
267        $grade = self::total($courseid, $userid);
268        if ($grade && $grade->is_locked()) {
269            $grade->set_locked(0, false, false);
270            $grade->grade_item->force_regrading();
271            grade_regrade_final_grades($courseid);
272        }
273    }
274}