Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.77% covered (success)
96.77%
120 / 124
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
course_page
98.36% covered (success)
98.36%
120 / 122
60.00% covered (warning)
60.00%
3 / 5
48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rows
98.31% covered (success)
98.31%
58 / 59
0.00% covered (danger)
0.00%
0 / 1
31
 ungraded_counts
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 latest_rows
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 export_for_template
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
9
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\output;
18
19use local_finalgrades\local\submission;
20use moodle_url;
21use renderer_base;
22use stdClass;
23
24defined('MOODLE_INTERNAL') || die();
25
26global $CFG;
27require_once($CFG->libdir . '/gradelib.php');
28
29/**
30 * The course's final grades page: every student's course total and where their grade is.
31 *
32 * @package    local_finalgrades
33 * @copyright  2026 DevLMS <contact@devlms.com>
34 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35 */
36class course_page implements \renderable, \templatable {
37    /** Badge classes per status. */
38    public const STATUSES = ['missing', 'open', 'returned', 'waiting', 'submitted', 'approved', 'sent'];
39
40    /** Which tile each status counts under. */
41    public const TILES = [
42        'missing' => 'todo',
43        'open' => 'todo',
44        'returned' => 'todo',
45        'waiting' => 'waiting',
46        'submitted' => 'ready',
47        'approved' => 'ready',
48        'sent' => 'sent',
49    ];
50
51    /** @var stdClass */
52    protected $course;
53    /** @var string */
54    protected $filter;
55
56    /**
57     * Constructor.
58     *
59     * @param stdClass $course
60     * @param string $filter all or a tile name.
61     */
62    public function __construct(stdClass $course, string $filter = 'all') {
63        $this->course = $course;
64        $this->filter = $filter;
65    }
66
67    /**
68     * Every student with their status, unfiltered.
69     *
70     * @param renderer_base $output
71     * @return array[]
72     */
73    public function rows(renderer_base $output): array {
74        global $DB;
75        $context = \context_course::instance($this->course->id);
76        $cansubmit = has_capability('local/finalgrades:submit', $context);
77        $canapprove = has_capability('local/finalgrades:approve', $context);
78        $approval = (bool) get_config('local_finalgrades', 'requireapproval');
79
80        grade_regrade_final_grades($this->course->id);
81        $item = \grade_item::fetch_course_item($this->course->id);
82        $students = submission::students($this->course->id);
83        if (!$students) {
84            return [];
85        }
86        $totals = $DB->get_records_menu('grade_grades', ['itemid' => $item->id], '', 'userid, finalgrade');
87        $latest = $this->latest_rows();
88        $ungraded = $this->ungraded_counts(array_keys($students));
89        $picturefields = \core_user\fields::for_userpic()->get_sql('', false, '', '', false)->selects;
90        $pictures = $DB->get_records_list('user', 'id', array_keys($students), '', $picturefields);
91
92        $rows = [];
93        foreach ($students as $userid => $student) {
94            $row = $latest[$userid] ?? null;
95            $current = $row && in_array($row->status, submission::CURRENT, true) ? $row : null;
96            $live = isset($totals[$userid]) && $totals[$userid] !== null ? (float) $totals[$userid] : null;
97            $value = $current ? (float) $current->grade : $live;
98
99            if ($current) {
100                $status = $current->exportid ? 'sent' : ($current->status === submission::APPROVED
101                    ? 'approved' : ($approval ? 'waiting' : 'submitted'));
102            } else if ($value === null) {
103                $status = 'missing';
104            } else {
105                $status = $row && $row->status === submission::RETURNED ? 'returned' : 'open';
106            }
107            $reason = !$current && $row ? (string) $row->reason : '';
108            $back = ['id' => $this->course->id, 'userid' => $userid];
109            $rows[] = [
110                'userid' => $userid,
111                'name' => fullname($student),
112                'lastname' => $student->lastname,
113                'picture' => isset($pictures[$userid])
114                    ? $output->user_picture($pictures[$userid], ['size' => 35, 'link' => false, 'alttext' => false])
115                    : '',
116                'idnumber' => $student->idnumber,
117                'noidnumber' => trim((string) $student->idnumber) === '',
118                'total' => $value === null ? '' : grade_format_gradevalue($value, $item, true, GRADE_DISPLAY_TYPE_REAL),
119                'percentage' => $current ? $current->percentage
120                    : ($value === null ? '' : grade_format_gradevalue($value, $item, true, GRADE_DISPLAY_TYPE_PERCENTAGE)),
121                'letter' => $current ? $current->letter
122                    : ($value === null ? '' : grade_format_gradevalue($value, $item, true, GRADE_DISPLAY_TYPE_LETTER)),
123                'status' => $status,
124                'statuslabel' => get_string('status:' . $status, 'local_finalgrades'),
125                'badge' => 'local-finalgrades-badge is-' . $status,
126                'ungraded' => $ungraded[$userid] ?? 0,
127                'ungradedlabel' => empty($ungraded[$userid]) || $current
128                    ? '' : get_string('ungraded', 'local_finalgrades', $ungraded[$userid]),
129                'tile' => self::TILES[$status],
130                'reason' => $reason,
131                'reasonlabel' => $reason === '' ? '' : get_string('reason:' . $row->status, 'local_finalgrades', $reason),
132                'cansubmit' => $cansubmit && in_array($status, ['open', 'returned'], true),
133                'canapprove' => $canapprove && $status === 'waiting',
134                'canwithdraw' => $cansubmit && $current !== null,
135                'canreturn' => $canapprove && $approval && $current !== null,
136                'withdrawurl' => (new moodle_url('/local/finalgrades/reason.php', $back + ['action' => 'withdraw']))->out(false),
137                'returnurl' => (new moodle_url('/local/finalgrades/reason.php', $back + ['action' => 'return']))->out(false),
138            ];
139        }
140        return $rows;
141    }
142
143    /**
144     * How many graded items of the course each student has no grade in yet.
145     *
146     * Moodle leaves empty grades out of the course total by default, so a total can look final while an exam is
147     * still unmarked.
148     *
149     * @param int[] $userids
150     * @return int[] Keyed by user id, only students with at least one.
151     */
152    protected function ungraded_counts(array $userids): array {
153        global $DB;
154        $items = $DB->get_fieldset_select(
155            'grade_items',
156            'id',
157            "courseid = :courseid AND itemtype NOT IN ('course', 'category') AND gradetype <> :none",
158            ['courseid' => $this->course->id, 'none' => GRADE_TYPE_NONE]
159        );
160        if (!$items || !$userids) {
161            return [];
162        }
163        [$itemin, $params] = $DB->get_in_or_equal($items, SQL_PARAMS_NAMED, 'item');
164        [$userin, $userparams] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'user');
165        $graded = $DB->get_records_sql_menu("SELECT userid, COUNT(1) FROM {grade_grades}
166            WHERE itemid $itemin AND userid $userin AND finalgrade IS NOT NULL GROUP BY userid", $params + $userparams);
167        $counts = [];
168        foreach ($userids as $userid) {
169            $missing = count($items) - (int) ($graded[$userid] ?? 0);
170            if ($missing > 0) {
171                $counts[$userid] = $missing;
172            }
173        }
174        return $counts;
175    }
176
177    /**
178     * The latest submission row of each student in the course.
179     *
180     * @return stdClass[] Keyed by user id.
181     */
182    protected function latest_rows(): array {
183        global $DB;
184        $rows = $DB->get_records('local_finalgrades_sub', ['courseid' => $this->course->id], 'id ASC');
185        $latest = [];
186        foreach ($rows as $row) {
187            $latest[$row->userid] = $row;
188        }
189        return $latest;
190    }
191
192    /**
193     * Data for the template.
194     *
195     * @param renderer_base $output
196     * @return stdClass
197     */
198    public function export_for_template(renderer_base $output): stdClass {
199        $context = \context_course::instance($this->course->id);
200        $approval = (bool) get_config('local_finalgrades', 'requireapproval');
201        $rows = $this->rows($output);
202
203        $names = $approval ? ['todo', 'waiting', 'ready', 'sent'] : ['todo', 'ready', 'sent'];
204        $filter = in_array($this->filter, $names, true) ? $this->filter : 'all';
205        $counts = array_count_values(array_column($rows, 'tile'));
206        $tiles = [];
207        foreach ($names as $name) {
208            $tiles[] = [
209                'name' => $name,
210                'label' => get_string('tile:' . $name, 'local_finalgrades'),
211                'count' => $counts[$name] ?? 0,
212                'active' => $filter === $name,
213                'url' => (new moodle_url('/local/finalgrades/index.php', [
214                    'id' => $this->course->id, 'filter' => $filter === $name ? 'all' : $name,
215                ]))->out(false),
216            ];
217        }
218        $shown = $filter === 'all' ? $rows : array_values(array_filter($rows, fn($r) => $r['tile'] === $filter));
219        $submitcount = count(array_filter($rows, fn($r) => $r['cansubmit']));
220        $approvecount = count(array_filter($rows, fn($r) => $r['canapprove']));
221
222        return (object) [
223            'courseid' => $this->course->id,
224            'tiles' => $tiles,
225            'rows' => $shown,
226            'hasrows' => (bool) $shown,
227            'emptymessage' => get_string($rows ? 'nostudentsinfilter' : 'nostudents', 'local_finalgrades'),
228            'requireapproval' => $approval,
229            'cansubmit' => has_capability('local/finalgrades:submit', $context),
230            'canapprove' => $approval && has_capability('local/finalgrades:approve', $context),
231            'submitcount' => $submitcount,
232            'approvecount' => $approvecount,
233            'hasselectable' => $submitcount + $approvecount > 0,
234            'nocourseidnumber' => trim((string) $this->course->idnumber) === '',
235            'intro' => get_string($approval ? 'intro:approval' : 'intro', 'local_finalgrades'),
236            'actionurl' => (new moodle_url('/local/finalgrades/index.php'))->out(false),
237            'filter' => $filter,
238            'sesskey' => sesskey(),
239        ];
240    }
241}