Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.83% covered (success)
96.83%
214 / 221
80.95% covered (warning)
80.95%
17 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
double_marking
96.83% covered (success)
96.83%
214 / 221
80.95% covered (warning)
80.95%
17 / 21
101
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
2
 for_cmid
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 assign
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tolerance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 max_grade
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unavailable_reason
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 configure
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 disable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete_for_cmid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 markers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 students
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allocate
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 share_out
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
11
 record_mark
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 agree
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 save_agreed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 release
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
10
 rows
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
23
 for_marker
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
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_doublemark\local;
18
19use moodle_exception;
20
21/**
22 * Double marking on one assignment: settings, the two markers of each student, their marks,
23 * the agreed mark and its release to the assignment.
24 *
25 * Marks live in this plugin's tables until they are released, then go through
26 * assign::save_grade() like a grade typed by the lead, so the gradebook, marking
27 * workflow and feedback plugins behave as usual.
28 *
29 * @package    local_doublemark
30 * @copyright  2026 DevLMS <contact@devlms.com>
31 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32 */
33class double_marking {
34    /** A student without two markers yet. */
35    public const UNALLOCATED = 'unallocated';
36    /** The lead set the agreed mark. */
37    public const RESOLVED = 'resolved';
38    /** The agreed mark is in the assignment. */
39    public const RELEASED = 'released';
40
41    /** @var int Course module id. */
42    public readonly int $cmid;
43
44    /** @var \stdClass|null Settings row, null when double marking is off. */
45    private ?\stdClass $config;
46
47    /**
48     * Use for_cmid().
49     *
50     * @param \assign $assign
51     */
52    private function __construct(
53        /** @var \assign The assignment. */
54        private readonly \assign $assign,
55    ) {
56        global $DB;
57        $this->cmid = (int) $assign->get_course_module()->id;
58        $this->config = $DB->get_record('local_doublemark_config', ['cmid' => $this->cmid]) ?: null;
59    }
60
61    /**
62     * Double marking for an assignment.
63     *
64     * @param int $cmid
65     * @return self
66     */
67    public static function for_cmid(int $cmid): self {
68        global $CFG;
69        require_once($CFG->dirroot . '/mod/assign/locallib.php');
70        [$course, $cm] = get_course_and_cm_from_cmid($cmid, 'assign');
71        return new self(new \assign(\context_module::instance($cm->id), $cm, $course));
72    }
73
74    /**
75     * The assignment.
76     *
77     * @return \assign
78     */
79    public function assign(): \assign {
80        return $this->assign;
81    }
82
83    /**
84     * Whether double marking is on for this assignment.
85     *
86     * @return bool
87     */
88    public function is_enabled(): bool {
89        return $this->config !== null;
90    }
91
92    /**
93     * Largest gap that still counts as agreement.
94     *
95     * @return float
96     */
97    public function tolerance(): float {
98        return (float) ($this->config->tolerance ?? 0);
99    }
100
101    /**
102     * How agreeing marks become the agreed mark.
103     *
104     * @return string One of reconciler::RULES.
105     */
106    public function rule(): string {
107        return $this->config->agreerule ?? reconciler::AVERAGE;
108    }
109
110    /**
111     * Highest grade of the assignment.
112     *
113     * @return float
114     */
115    public function max_grade(): float {
116        return (float) $this->assign->get_instance()->grade;
117    }
118
119    /**
120     * Why this assignment cannot use double marking, as a string key, or null when it can.
121     *
122     * @return string|null
123     */
124    public function unavailable_reason(): ?string {
125        $instance = $this->assign->get_instance();
126        if ((float) $instance->grade <= 0) {
127            return 'unavailable:scale';
128        }
129        if (!empty($instance->teamsubmission)) {
130            return 'unavailable:team';
131        }
132        if (get_grading_manager($this->assign->get_context(), 'mod_assign', 'submissions')->get_active_method()) {
133            return 'unavailable:advancedgrading';
134        }
135        return null;
136    }
137
138    /**
139     * Turn double marking on, or change its settings.
140     *
141     * @param float $tolerance
142     * @param string $rule
143     */
144    public function configure(float $tolerance, string $rule): void {
145        global $DB, $USER;
146        if ($reason = $this->unavailable_reason()) {
147            throw new moodle_exception($reason, 'local_doublemark');
148        }
149        if ($tolerance < 0 || $tolerance > $this->max_grade()) {
150            throw new moodle_exception('error:badtolerance', 'local_doublemark');
151        }
152        if (!in_array($rule, reconciler::RULES, true)) {
153            throw new \coding_exception("Unknown agreement rule '$rule'");
154        }
155        $row = (object) ['cmid' => $this->cmid, 'tolerance' => $tolerance, 'agreerule' => $rule,
156            'usermodified' => (int) $USER->id, 'timemodified' => time()];
157        if ($this->config) {
158            $row->id = $this->config->id;
159            $DB->update_record('local_doublemark_config', $row);
160        } else {
161            $row->id = $DB->insert_record('local_doublemark_config', $row);
162        }
163        $this->config = $row;
164    }
165
166    /**
167     * Turn double marking off. Allocations and marks stay, so turning it back on loses nothing.
168     */
169    public function disable(): void {
170        global $DB;
171        $DB->delete_records('local_doublemark_config', ['cmid' => $this->cmid]);
172        $this->config = null;
173    }
174
175    /**
176     * Remove everything this plugin stored for a course module.
177     *
178     * @param int $cmid
179     */
180    public static function delete_for_cmid(int $cmid): void {
181        global $DB;
182        foreach (['config', 'alloc', 'mark', 'agreed'] as $table) {
183            $DB->delete_records("local_doublemark_$table", ['cmid' => $cmid]);
184        }
185    }
186
187    /**
188     * People who can be markers: everyone with local/doublemark:mark on the assignment.
189     *
190     * @return \stdClass[] User records keyed by id, sorted by name.
191     */
192    public function markers(): array {
193        $fields = 'u.id' . \core_user\fields::for_name()->get_sql('u')->selects;
194        return get_users_by_capability($this->assign->get_context(), 'local/doublemark:mark', $fields, 'u.lastname, u.firstname');
195    }
196
197    /**
198     * Students of the assignment, as the assignment itself lists them.
199     *
200     * @return \stdClass[] Keyed by user id.
201     */
202    private function students(): array {
203        return $this->assign->list_participants(0, false);
204    }
205
206    /**
207     * Set the two markers of a student.
208     *
209     * @param int $userid Student.
210     * @param int $marker1
211     * @param int $marker2
212     */
213    public function allocate(int $userid, int $marker1, int $marker2): void {
214        global $DB;
215        if (!isset($this->students()[$userid])) {
216            throw new moodle_exception('error:notstudent', 'local_doublemark');
217        }
218        $markers = $this->markers();
219        if (!isset($markers[$marker1]) || !isset($markers[$marker2])) {
220            throw new moodle_exception('error:notmarker', 'local_doublemark');
221        }
222        if ($marker1 === $marker2) {
223            throw new moodle_exception('error:samemarker', 'local_doublemark');
224        }
225        $row = (object) ['cmid' => $this->cmid, 'userid' => $userid, 'marker1' => $marker1, 'marker2' => $marker2,
226            'timemodified' => time()];
227        if ($id = $DB->get_field('local_doublemark_alloc', 'id', ['cmid' => $this->cmid, 'userid' => $userid])) {
228            $row->id = $id;
229            $DB->update_record('local_doublemark_alloc', $row);
230        } else {
231            $DB->insert_record('local_doublemark_alloc', $row);
232        }
233    }
234
235    /**
236     * Give every student without two markers a pair, choosing the least loaded markers first.
237     *
238     * @param int[] $markerids At least two people who can mark.
239     * @return int How many students got markers.
240     */
241    public function share_out(array $markerids): int {
242        global $DB;
243        $markerids = array_values(array_unique(array_map('intval', $markerids)));
244        $markers = $this->markers();
245        if (count($markerids) < 2) {
246            throw new moodle_exception('error:fewmarkers', 'local_doublemark');
247        }
248        foreach ($markerids as $id) {
249            if (!isset($markers[$id])) {
250                throw new moodle_exception('error:notmarker', 'local_doublemark');
251            }
252        }
253        $allocs = $DB->get_records('local_doublemark_alloc', ['cmid' => $this->cmid], '', 'userid, marker1, marker2');
254        $load = array_fill_keys($markerids, 0);
255        foreach ($allocs as $a) {
256            foreach ([(int) $a->marker1, (int) $a->marker2] as $m) {
257                if (isset($load[$m])) {
258                    $load[$m]++;
259                }
260            }
261        }
262        $count = 0;
263        foreach (array_keys($this->students()) as $userid) {
264            $a = $allocs[$userid] ?? null;
265            if ($a && $a->marker1 && $a->marker2) {
266                continue;
267            }
268            // Least loaded first; ties keep the order the lead gave.
269            $order = $markerids;
270            usort($order, fn($x, $y) => [$load[$x], array_search($x, $markerids)] <=> [$load[$y], array_search($y, $markerids)]);
271            [$first, $second] = $order;
272            $this->allocate((int) $userid, $first, $second);
273            $load[$first]++;
274            $load[$second]++;
275            $count++;
276        }
277        return $count;
278    }
279
280    /**
281     * Record the current user's mark for a student they are allocated to.
282     *
283     * @param int $userid Student.
284     * @param float $grade
285     * @param string $comment Private, for the other marker and the lead.
286     */
287    public function record_mark(int $userid, float $grade, string $comment): void {
288        global $DB, $USER;
289        require_capability('local/doublemark:mark', $this->assign->get_context());
290        $alloc = $DB->get_record('local_doublemark_alloc', ['cmid' => $this->cmid, 'userid' => $userid]);
291        $position = match (true) {
292            $alloc && (int) $alloc->marker1 === (int) $USER->id => 1,
293            $alloc && (int) $alloc->marker2 === (int) $USER->id => 2,
294            default => 0,
295        };
296        if (!$position) {
297            throw new moodle_exception('error:notallocated', 'local_doublemark');
298        }
299        if (
300            $DB->record_exists_select(
301                'local_doublemark_agreed',
302                'cmid = ? AND userid = ? AND timereleased > 0',
303                [$this->cmid, $userid]
304            )
305        ) {
306            throw new moodle_exception('error:released', 'local_doublemark');
307        }
308        if ($grade < 0 || $grade > $this->max_grade()) {
309            throw new moodle_exception('error:range', 'local_doublemark', '', format_float($this->max_grade(), -1));
310        }
311        $now = time();
312        $existing = $DB->get_record('local_doublemark_mark', ['cmid' => $this->cmid, 'userid' => $userid, 'position' => $position]);
313        $row = (object) ['cmid' => $this->cmid, 'userid' => $userid, 'position' => $position, 'markerid' => (int) $USER->id,
314            'grade' => $grade, 'comment' => $comment, 'timemodified' => $now];
315        if ($existing) {
316            $row->id = $existing->id;
317            $DB->update_record('local_doublemark_mark', $row);
318        } else {
319            $row->timecreated = $now;
320            $DB->insert_record('local_doublemark_mark', $row);
321        }
322        \local_doublemark\event\mark_recorded::create([
323            'context' => $this->assign->get_context(),
324            'relateduserid' => $userid,
325            'other' => ['position' => $position],
326        ])->trigger();
327    }
328
329    /**
330     * The lead sets the agreed mark of a student whose two marks are in.
331     *
332     * @param int $userid Student.
333     * @param float $grade
334     * @param string $note Why, for staff.
335     * @param string $feedback For the student, sent with the grade.
336     */
337    public function agree(int $userid, float $grade, string $note, string $feedback): void {
338        global $USER;
339        require_capability('local/doublemark:manage', $this->assign->get_context());
340        $row = $this->rows()[$userid] ?? null;
341        if (!$row || !in_array($row->status, [reconciler::AGREED, reconciler::RECONCILE, self::RESOLVED], true)) {
342            throw new moodle_exception('error:notmarked', 'local_doublemark');
343        }
344        if ($grade < 0 || $grade > $this->max_grade()) {
345            throw new moodle_exception('error:range', 'local_doublemark', '', format_float($this->max_grade(), -1));
346        }
347        $this->save_agreed($userid, ['grade' => $grade, 'note' => $note, 'feedback' => $feedback, 'source' => 'lead',
348            'agreedby' => (int) $USER->id, 'timeagreed' => time()]);
349        \local_doublemark\event\mark_agreed::create([
350            'context' => $this->assign->get_context(),
351            'relateduserid' => $userid,
352        ])->trigger();
353    }
354
355    /**
356     * Insert or update the agreed row of a student.
357     *
358     * @param int $userid
359     * @param array $fields
360     */
361    private function save_agreed(int $userid, array $fields): void {
362        global $DB;
363        $existing = $DB->get_record('local_doublemark_agreed', ['cmid' => $this->cmid, 'userid' => $userid]);
364        $row = (object) (['cmid' => $this->cmid, 'userid' => $userid] + $fields);
365        if ($existing) {
366            $row->id = $existing->id;
367            $DB->update_record('local_doublemark_agreed', $row);
368        } else {
369            $DB->insert_record('local_doublemark_agreed', $row);
370        }
371    }
372
373    /**
374     * Write every agreed mark that is not in the assignment yet to the assignment.
375     *
376     * @return int How many marks were released.
377     */
378    public function release(): int {
379        global $USER;
380        require_capability('local/doublemark:manage', $this->assign->get_context());
381        $instance = $this->assign->get_instance();
382        $comments = $this->assign->get_feedback_plugin_by_type('comments');
383        $count = 0;
384        foreach ($this->rows() as $userid => $row) {
385            if (!in_array($row->status, [reconciler::AGREED, self::RESOLVED], true)) {
386                continue;
387            }
388            $data = (object) ['grade' => $row->agreed, 'attemptnumber' => -1,
389                'sendstudentnotifications' => (bool) $instance->sendstudentnotifications];
390            if (!empty($instance->markingworkflow)) {
391                $data->workflowstate = ASSIGN_MARKING_WORKFLOW_STATE_RELEASED;
392            }
393            if ($comments && $comments->is_enabled()) {
394                // The comments plugin reads its editor on every save; keep what is there when there is no new feedback.
395                $grade = $this->assign->get_user_grade($userid, false);
396                $text = $row->feedback !== '' ? format_text($row->feedback, FORMAT_PLAIN)
397                    : ($grade ? (string) $comments->get_editor_text('comments', $grade->id) : '');
398                $data->assignfeedbackcomments_editor = ['text' => $text, 'format' => FORMAT_HTML];
399            }
400            $this->assign->save_grade($userid, $data);
401            $fields = ['timereleased' => time(), 'releasedby' => (int) $USER->id];
402            if ($row->status === reconciler::AGREED) {
403                $fields += ['grade' => $row->agreed, 'source' => 'rule', 'agreedby' => 0, 'timeagreed' => time()];
404            }
405            $this->save_agreed($userid, $fields);
406            $count++;
407        }
408        if ($count) {
409            \local_doublemark\event\marks_released::create([
410                'context' => $this->assign->get_context(),
411                'other' => ['count' => $count],
412            ])->trigger();
413        }
414        return $count;
415    }
416
417    /**
418     * Every student with markers, marks, status and agreed mark.
419     *
420     * @return \stdClass[] Keyed by student id, in the assignment's participant order.
421     */
422    public function rows(): array {
423        global $DB;
424        $params = ['cmid' => $this->cmid];
425        $allocs = $DB->get_records('local_doublemark_alloc', $params, '', 'userid, marker1, marker2');
426        $agreed = $DB->get_records('local_doublemark_agreed', $params, '', 'userid, grade, feedback, note, source, timereleased');
427        $marks = [];
428        foreach ($DB->get_records('local_doublemark_mark', $params) as $m) {
429            $marks[$m->userid][(int) $m->position] = (object) ['grade' => (float) $m->grade, 'comment' => (string) $m->comment,
430                'markerid' => (int) $m->markerid, 'timemodified' => (int) $m->timemodified];
431        }
432        $graded = $DB->get_fieldset_select(
433            'assign_grades',
434            'DISTINCT userid',
435            'assignment = ? AND grade >= 0',
436            [$this->assign->get_instance()->id]
437        );
438        $graded = array_flip(array_map('intval', $graded));
439        $anonymous = $this->assign->is_blind_marking();
440
441        $rows = [];
442        foreach ($this->students() as $userid => $user) {
443            $userid = (int) $userid;
444            $a = $allocs[$userid] ?? null;
445            $ag = $agreed[$userid] ?? null;
446            $mark1 = $marks[$userid][1] ?? null;
447            $mark2 = $marks[$userid][2] ?? null;
448            $released = $ag && (int) $ag->timereleased > 0;
449            [$status, $ruleagreed] = reconciler::resolve($mark1?->grade, $mark2?->grade, $this->tolerance(), $this->rule());
450            if ($released) {
451                $status = self::RELEASED;
452            } else if (!$a || !$a->marker1 || !$a->marker2) {
453                $status = self::UNALLOCATED;
454            } else if ($ag && $ag->source === 'lead' && $status !== reconciler::MARKING) {
455                $status = self::RESOLVED;
456            }
457            $rows[$userid] = (object) [
458                'userid' => $userid,
459                'user' => $user,
460                'anonymous' => $anonymous,
461                'name' => $anonymous
462                    ? get_string('participant', 'assign') . ' ' . $this->assign->get_uniqueid_for_user($userid)
463                    : fullname($user),
464                'marker1' => $a ? (int) $a->marker1 : 0,
465                'marker2' => $a ? (int) $a->marker2 : 0,
466                'mark1' => $mark1,
467                'mark2' => $mark2,
468                'gap' => $mark1 && $mark2 ? round(abs($mark1->grade - $mark2->grade), 2) : null,
469                'status' => $status,
470                'agreed' => $ag && ($released || $status === self::RESOLVED) ? (float) $ag->grade : $ruleagreed,
471                'source' => $ag ? $ag->source : null,
472                'feedback' => $ag ? (string) $ag->feedback : '',
473                'note' => $ag ? (string) $ag->note : '',
474                // A grade typed in the assignment itself, outside double marking.
475                'gradedoutside' => isset($graded[$userid]) && !$released,
476            ];
477        }
478        return $rows;
479    }
480
481    /**
482     * The students one marker marks, with their own mark and, once both are in, the other one.
483     *
484     * @param int $markerid
485     * @return \stdClass[] Keyed by student id.
486     */
487    public function for_marker(int $markerid): array {
488        $mine = [];
489        foreach ($this->rows() as $userid => $row) {
490            $position = match ($markerid) {
491                $row->marker1 => 1,
492                $row->marker2 => 2,
493                default => 0,
494            };
495            if (!$position) {
496                continue;
497            }
498            $both = $row->mark1 && $row->mark2;
499            $mine[$userid] = (object) [
500                'row' => $row,
501                'position' => $position,
502                'own' => $position === 1 ? $row->mark1 : $row->mark2,
503                'other' => $both ? ($position === 1 ? $row->mark2 : $row->mark1) : null,
504            ];
505        }
506        return $mine;
507    }
508}