Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.77% |
120 / 124 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
| course_page | |
98.36% |
120 / 122 |
|
60.00% |
3 / 5 |
48 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| rows | |
98.31% |
58 / 59 |
|
0.00% |
0 / 1 |
31 | |||
| ungraded_counts | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
5.00 | |||
| latest_rows | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| export_for_template | |
100.00% |
38 / 38 |
|
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 | |
| 17 | namespace local_finalgrades\output; |
| 18 | |
| 19 | use local_finalgrades\local\submission; |
| 20 | use moodle_url; |
| 21 | use renderer_base; |
| 22 | use stdClass; |
| 23 | |
| 24 | defined('MOODLE_INTERNAL') || die(); |
| 25 | |
| 26 | global $CFG; |
| 27 | require_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 | */ |
| 36 | class 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 | } |