Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://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 <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Definition of the grader report class
19
 *
20
 * @package   gradereport_grader
21
 * @copyright 2007 Nicolas Connault
22
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
require_once($CFG->dirroot . '/grade/report/lib.php');
26
require_once($CFG->libdir.'/tablelib.php');
27
 
28
/**
29
 * Class providing an API for the grader report building and displaying.
30
 * @uses grade_report
31
 * @copyright 2007 Nicolas Connault
32
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33
 */
34
class grade_report_grader extends grade_report {
35
    /**
36
     * The final grades.
37
     * @var array $grades
38
     */
39
    public $grades;
40
 
41
    /**
42
     * Contains all the grades for the course - even the ones not displayed in the grade tree.
43
     *
44
     * @var array $allgrades
45
     */
46
    private $allgrades;
47
 
48
    /**
49
     * Contains all grade items expect GRADE_TYPE_NONE.
50
     *
51
     * @var array $allgradeitems
52
     */
53
    private $allgradeitems;
54
 
55
    /**
56
     * Array of errors for bulk grades updating.
57
     * @var array $gradeserror
58
     */
59
    public $gradeserror = array();
60
 
61
    // SQL-RELATED
62
 
63
    /**
64
     * The id of the grade_item by which this report will be sorted.
65
     * @var int $sortitemid
66
     */
67
    public $sortitemid;
68
 
69
    /**
70
     * Sortorder used in the SQL selections.
71
     * @var int $sortorder
72
     */
73
    public $sortorder;
74
 
75
    /**
76
     * An SQL fragment affecting the search for users.
77
     * @var string $userselect
78
     */
79
    public $userselect;
80
 
81
    /**
82
     * The bound params for $userselect
83
     * @var array $userselectparams
84
     */
85
    public $userselectparams = array();
86
 
87
    /**
88
     * List of collapsed categories from user preference
89
     * @var array $collapsed
90
     */
91
    public $collapsed;
92
 
93
    /**
94
     * A count of the rows, used for css classes.
95
     * @var int $rowcount
96
     */
97
    public $rowcount = 0;
98
 
99
    /**
100
     * Capability check caching
101
     * @var boolean $canviewhidden
102
     */
103
    public $canviewhidden;
104
 
105
    /** @var int Maximum number of students that can be shown on one page */
106
    public const MAX_STUDENTS_PER_PAGE = 5000;
107
 
108
    /** @var int[] List of available options on the pagination dropdown */
109
    public const PAGINATION_OPTIONS = [20, 100];
110
 
111
    /**
112
     * Allow category grade overriding
113
     * @var bool $overridecat
114
     */
115
    protected $overridecat;
116
 
117
    /** @var array of objects, or empty array if no records were found. */
118
    protected $users = [];
119
 
120
    /**
121
     * Constructor. Sets local copies of user preferences and initialises grade_tree.
122
     * @param int $courseid
123
     * @param object $gpr grade plugin return tracking object
124
     * @param string $context
125
     * @param int $page The current page being viewed (when report is paged)
126
     * @param int $sortitemid The id of the grade_item by which to sort the table
127
     * @param string $sort Sorting direction
128
     */
129
    public function __construct($courseid, $gpr, $context, $page=null, $sortitemid=null, string $sort = '') {
130
        global $CFG;
131
        parent::__construct($courseid, $gpr, $context, $page);
132
 
133
        $this->canviewhidden = has_capability('moodle/grade:viewhidden', context_course::instance($this->course->id));
134
 
135
        // load collapsed settings for this report
136
        $this->collapsed = static::get_collapsed_preferences($this->course->id);
137
 
138
        if (empty($CFG->enableoutcomes)) {
139
            $nooutcomes = false;
140
        } else {
141
            $nooutcomes = get_user_preferences('grade_report_shownooutcomes');
142
        }
143
 
144
        // if user report preference set or site report setting set use it, otherwise use course or site setting
145
        $switch = $this->get_pref('aggregationposition');
146
        if ($switch == '') {
147
            $switch = grade_get_setting($this->courseid, 'aggregationposition', $CFG->grade_aggregationposition);
148
        }
149
 
150
        // Grab the grade_tree for this course
151
        $this->gtree = new grade_tree($this->courseid, true, $switch, $this->collapsed, $nooutcomes);
152
 
153
        $this->sortitemid = $sortitemid;
154
 
155
        // base url for sorting by first/last name
156
 
157
        $this->baseurl = new moodle_url('index.php', array('id' => $this->courseid));
158
 
159
        $studentsperpage = $this->get_students_per_page();
160
        if (!empty($this->page) && !empty($studentsperpage)) {
161
            $this->baseurl->params(array('perpage' => $studentsperpage, 'page' => $this->page));
162
        }
163
 
164
        $this->pbarurl = new moodle_url('/grade/report/grader/index.php', array('id' => $this->courseid));
165
 
166
        $this->setup_groups();
167
        $this->setup_users();
168
        $this->setup_sortitemid($sort);
169
 
170
        $this->overridecat = (bool)get_config('moodle', 'grade_overridecat');
171
    }
172
 
173
    /**
174
     * Processes the data sent by the form (grades).
175
     * Caller is responsible for all access control checks
176
     * @param array $data form submission (with magic quotes)
177
     * @return array empty array if success, array of warnings if something fails.
178
     */
179
    public function process_data($data) {
180
        global $DB;
181
        $warnings = array();
182
 
183
        $separategroups = false;
184
        $mygroups       = array();
185
        if ($this->groupmode == SEPARATEGROUPS and !has_capability('moodle/site:accessallgroups', $this->context)) {
186
            $separategroups = true;
187
            $mygroups = groups_get_user_groups($this->course->id);
188
            $mygroups = $mygroups[0]; // ignore groupings
189
            // reorder the groups fro better perf below
190
            $current = array_search($this->currentgroup, $mygroups);
191
            if ($current !== false) {
192
                unset($mygroups[$current]);
193
                array_unshift($mygroups, $this->currentgroup);
194
            }
195
        }
196
        $viewfullnames = has_capability('moodle/site:viewfullnames', $this->context);
197
 
198
        // always initialize all arrays
199
        $queue = array();
200
 
201
        $this->load_users();
202
        $this->load_final_grades();
203
 
204
        // Were any changes made?
205
        $changedgrades = false;
206
        $timepageload = clean_param($data->timepageload, PARAM_INT);
207
 
208
        foreach ($data as $varname => $students) {
209
 
210
            $needsupdate = false;
211
 
212
            // Skip, not a grade.
213
            if (strpos($varname, 'grade') === 0) {
214
                $datatype = 'grade';
215
            } else {
216
                continue;
217
            }
218
 
219
            foreach ($students as $userid => $items) {
220
                $userid = clean_param($userid, PARAM_INT);
221
                foreach ($items as $itemid => $postedvalue) {
222
                    $itemid = clean_param($itemid, PARAM_INT);
223
 
224
                    // Was change requested?
225
                    $oldvalue = $this->grades[$userid][$itemid];
226
                    if ($datatype === 'grade') {
227
                        // If there was no grade and there still isn't
228
                        if (is_null($oldvalue->finalgrade) && $postedvalue == -1) {
229
                            // -1 means no grade
230
                            continue;
231
                        }
232
 
233
                        // If the grade item uses a custom scale
234
                        if (!empty($oldvalue->grade_item->scaleid)) {
235
 
236
                            if ((int)$oldvalue->finalgrade === (int)$postedvalue) {
237
                                continue;
238
                            }
239
                        } else {
240
                            // The grade item uses a numeric scale
241
 
242
                            // Format the finalgrade from the DB so that it matches the grade from the client
243
                            if ($postedvalue === format_float($oldvalue->finalgrade, $oldvalue->grade_item->get_decimals())) {
244
                                continue;
245
                            }
246
                        }
247
 
248
                        $changedgrades = true;
249
                    }
250
 
251
                    if (!$gradeitem = grade_item::fetch(array('id'=>$itemid, 'courseid'=>$this->courseid))) {
252
                        throw new \moodle_exception('invalidgradeitemid');
253
                    }
254
 
255
                    // Pre-process grade
256
                    if ($datatype == 'grade') {
257
                        if ($gradeitem->gradetype == GRADE_TYPE_SCALE) {
258
                            if ($postedvalue == -1) { // -1 means no grade
259
                                $finalgrade = null;
260
                            } else {
261
                                $finalgrade = $postedvalue;
262
                            }
263
                        } else {
264
                            $finalgrade = unformat_float($postedvalue);
265
                        }
266
 
267
                        $errorstr = '';
268
                        $skip = false;
269
 
270
                        $dategraded = $oldvalue->get_dategraded();
271
                        if (!empty($dategraded) && $timepageload < $dategraded) {
272
                            // Warn if the grade was updated while we were editing this form.
273
                            $errorstr = 'gradewasmodifiedduringediting';
274
                            $skip = true;
275
                        } else if (!is_null($finalgrade)) {
276
                            // Warn if the grade is out of bounds.
277
                            $bounded = $gradeitem->bounded_grade($finalgrade);
278
                            if ($bounded > $finalgrade) {
279
                                $errorstr = 'lessthanmin';
280
                            } else if ($bounded < $finalgrade) {
281
                                $errorstr = 'morethanmax';
282
                            }
283
                        }
284
 
285
                        if ($errorstr) {
286
                            $userfieldsapi = \core_user\fields::for_name();
287
                            $userfields = 'id, ' . $userfieldsapi->get_sql('', false, '', '', false)->selects;
288
                            $user = $DB->get_record('user', array('id' => $userid), $userfields);
289
                            $gradestr = new stdClass();
290
                            $gradestr->username = fullname($user, $viewfullnames);
291
                            $gradestr->itemname = $gradeitem->get_name();
292
                            $warnings[] = get_string($errorstr, 'grades', $gradestr);
293
                            if ($skip) {
294
                                // Skipping the update of this grade it failed the tests above.
295
                                continue;
296
                            }
297
                        }
298
                    }
299
 
300
                    // group access control
301
                    if ($separategroups) {
302
                        // note: we can not use $this->currentgroup because it would fail badly
303
                        //       when having two browser windows each with different group
304
                        $sharinggroup = false;
305
                        foreach ($mygroups as $groupid) {
306
                            if (groups_is_member($groupid, $userid)) {
307
                                $sharinggroup = true;
308
                                break;
309
                            }
310
                        }
311
                        if (!$sharinggroup) {
312
                            // either group membership changed or somebody is hacking grades of other group
313
                            $warnings[] = get_string('errorsavegrade', 'grades');
314
                            continue;
315
                        }
316
                    }
317
 
318
                    $gradeitem->update_final_grade($userid, $finalgrade, 'gradebook', false,
319
                        FORMAT_MOODLE, null, null, true);
320
                }
321
            }
322
        }
323
 
324
        if ($changedgrades) {
325
            // If a final grade was overriden reload grades so dependent grades like course total will be correct
326
            $this->grades = null;
327
        }
328
 
329
        return $warnings;
330
    }
331
 
332
 
333
    /**
334
     * Setting the sort order, this depends on last state
335
     * all this should be in the new table class that we might need to use
336
     * for displaying grades.
337
 
338
     * @param string $sort sorting direction
339
     */
340
    private function setup_sortitemid(string $sort = '') {
341
 
342
        global $SESSION;
343
 
344
        if (!isset($SESSION->gradeuserreport)) {
345
            $SESSION->gradeuserreport = new stdClass();
346
        }
347
 
348
        if ($this->sortitemid) {
349
            if (!isset($SESSION->gradeuserreport->sort)) {
350
                $this->sortorder = $SESSION->gradeuserreport->sort = 'ASC';
351
            } else if (!$sort) {
352
                // this is the first sort, i.e. by last name
353
                if (!isset($SESSION->gradeuserreport->sortitemid)) {
354
                    $this->sortorder = $SESSION->gradeuserreport->sort = 'ASC';
355
                } else if ($SESSION->gradeuserreport->sortitemid == $this->sortitemid) {
356
                    // same as last sort
357
                    if ($SESSION->gradeuserreport->sort == 'ASC') {
358
                        $this->sortorder = $SESSION->gradeuserreport->sort = 'DESC';
359
                    } else {
360
                        $this->sortorder = $SESSION->gradeuserreport->sort = 'ASC';
361
                    }
362
                } else {
363
                    $this->sortorder = $SESSION->gradeuserreport->sort = 'ASC';
364
                }
365
            }
366
            $SESSION->gradeuserreport->sortitemid = $this->sortitemid;
367
        } else {
368
            // not requesting sort, use last setting (for paging)
369
 
370
            if (isset($SESSION->gradeuserreport->sortitemid)) {
371
                $this->sortitemid = $SESSION->gradeuserreport->sortitemid;
372
            } else {
373
                $this->sortitemid = 'lastname';
374
            }
375
 
376
            if (isset($SESSION->gradeuserreport->sort)) {
377
                $this->sortorder = $SESSION->gradeuserreport->sort;
378
            } else {
379
                $this->sortorder = 'ASC';
380
            }
381
        }
382
 
383
        // If explicit sorting direction exists.
384
        if ($sort) {
385
            $this->sortorder = $sort;
386
            $SESSION->gradeuserreport->sort = $sort;
387
        }
388
    }
389
 
390
    /**
391
     * pulls out the userids of the users to be display, and sorts them
392
     *
393
     * @param bool $allusers If we are getting the users within the report, we want them all irrespective of paging.
394
     */
395
    public function load_users(bool $allusers = false) {
396
        global $CFG, $DB;
397
 
398
        if (!empty($this->users)) {
399
            return;
400
        }
401
        $this->setup_users();
402
 
403
        // Limit to users with a gradeable role.
404
        list($gradebookrolessql, $gradebookrolesparams) = $DB->get_in_or_equal(explode(',', $this->gradebookroles), SQL_PARAMS_NAMED, 'grbr0');
405
 
406
        // Check the status of showing only active enrolments.
407
        $coursecontext = $this->context->get_course_context(true);
408
        $defaultgradeshowactiveenrol = !empty($CFG->grade_report_showonlyactiveenrol);
409
        $showonlyactiveenrol = get_user_preferences('grade_report_showonlyactiveenrol', $defaultgradeshowactiveenrol);
410
        $showonlyactiveenrol = $showonlyactiveenrol || !has_capability('moodle/course:viewsuspendedusers', $coursecontext);
411
 
412
        // Limit to users with an active enrollment.
413
        list($enrolledsql, $enrolledparams) = get_enrolled_sql($this->context, '', 0, $showonlyactiveenrol);
414
 
415
        // Fields we need from the user table.
416
        $userfieldsapi = \core_user\fields::for_identity($this->context)->with_userpic();
417
        $userfieldssql = $userfieldsapi->get_sql('u', true, '', '', false);
418
 
419
        // We want to query both the current context and parent contexts.
420
        list($relatedctxsql, $relatedctxparams) = $DB->get_in_or_equal($this->context->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'relatedctx');
421
 
422
        // If the user has clicked one of the sort asc/desc arrows.
423
        if (is_numeric($this->sortitemid)) {
424
            $params = array_merge(array('gitemid' => $this->sortitemid), $gradebookrolesparams, $this->userwheresql_params,
425
                    $this->groupwheresql_params, $enrolledparams, $relatedctxparams);
426
 
427
            $sortjoin = "LEFT JOIN {grade_grades} g ON g.userid = u.id AND g.itemid = $this->sortitemid";
428
 
429
            if ($this->sortorder == 'ASC') {
430
                $sort = $DB->sql_order_by_null('g.finalgrade');
431
            } else {
432
                $sort = $DB->sql_order_by_null('g.finalgrade', SORT_DESC);
433
            }
434
            $sort .= ", u.idnumber, u.lastname, u.firstname, u.email";
435
        } else {
436
            $sortjoin = '';
437
 
438
            // The default sort will be that provided by the site for users, unless a valid user field is requested,
439
            // the value of which takes precedence.
440
            [$sort] = users_order_by_sql('u', null, $this->context, $userfieldssql->mappings);
441
            if (array_key_exists($this->sortitemid, $userfieldssql->mappings)) {
442
 
443
                // Ensure user sort field doesn't duplicate one of the default sort fields.
444
                $usersortfield = $userfieldssql->mappings[$this->sortitemid];
445
                $defaultsortfields = array_diff(explode(', ', $sort), [$usersortfield]);
446
 
447
                $sort = "{$usersortfield} {$this->sortorder}, " . implode(', ', $defaultsortfields);
448
            }
449
 
450
            $params = array_merge($gradebookrolesparams, $this->userwheresql_params, $this->groupwheresql_params, $enrolledparams, $relatedctxparams);
451
        }
452
        $params = array_merge($userfieldssql->params, $params);
453
        $sql = "SELECT {$userfieldssql->selects}
454
                  FROM {user} u
455
                        {$userfieldssql->joins}
456
                  JOIN ($enrolledsql) je ON je.id = u.id
457
                       $this->groupsql
458
                       $sortjoin
459
                  JOIN (
460
                           SELECT DISTINCT ra.userid
461
                             FROM {role_assignments} ra
462
                            WHERE ra.roleid IN ($this->gradebookroles)
463
                              AND ra.contextid $relatedctxsql
464
                       ) rainner ON rainner.userid = u.id
465
                   AND u.deleted = 0
466
                   $this->userwheresql
467
                   $this->groupwheresql
468
              ORDER BY $sort";
469
        // We never work with unlimited result. Limit the number of records by MAX_STUDENTS_PER_PAGE if no other limit is specified.
470
        $studentsperpage = ($this->get_students_per_page() && !$allusers) ?
471
            $this->get_students_per_page() : static::MAX_STUDENTS_PER_PAGE;
472
        $this->users = $DB->get_records_sql($sql, $params, $studentsperpage * $this->page, $studentsperpage);
473
 
474
        if (empty($this->users)) {
475
            $this->userselect = '';
476
            $this->users = array();
477
            $this->userselectparams = array();
478
        } else {
479
            list($usql, $uparams) = $DB->get_in_or_equal(array_keys($this->users), SQL_PARAMS_NAMED, 'usid0');
480
            $this->userselect = "AND g.userid $usql";
481
            $this->userselectparams = $uparams;
482
 
483
            // First flag everyone as not suspended.
484
            foreach ($this->users as $user) {
485
                $this->users[$user->id]->suspendedenrolment = false;
486
            }
487
 
488
            // If we want to mix both suspended and not suspended users, let's find out who is suspended.
489
            if (!$showonlyactiveenrol) {
490
                $sql = "SELECT ue.userid
491
                          FROM {user_enrolments} ue
492
                          JOIN {enrol} e ON e.id = ue.enrolid
493
                         WHERE ue.userid $usql
494
                               AND ue.status = :uestatus
495
                               AND e.status = :estatus
496
                               AND e.courseid = :courseid
497
                               AND ue.timestart < :now1 AND (ue.timeend = 0 OR ue.timeend > :now2)
498
                      GROUP BY ue.userid";
499
 
500
                $time = time();
501
                $params = array_merge($uparams, array('estatus' => ENROL_INSTANCE_ENABLED, 'uestatus' => ENROL_USER_ACTIVE,
502
                        'courseid' => $coursecontext->instanceid, 'now1' => $time, 'now2' => $time));
503
                $useractiveenrolments = $DB->get_records_sql($sql, $params);
504
 
505
                foreach ($this->users as $user) {
506
                    $this->users[$user->id]->suspendedenrolment = !array_key_exists($user->id, $useractiveenrolments);
507
                }
508
            }
509
        }
510
        return $this->users;
511
    }
512
 
513
    /**
514
     * Load all grade items.
515
     */
516
    protected function get_allgradeitems() {
517
        if (!empty($this->allgradeitems)) {
518
            return $this->allgradeitems;
519
        }
520
        $allgradeitems = grade_item::fetch_all(array('courseid' => $this->courseid));
521
        // But hang on - don't include ones which are set to not show the grade at all.
522
        $this->allgradeitems = array_filter($allgradeitems, function($item) {
523
            return $item->gradetype != GRADE_TYPE_NONE;
524
        });
525
 
526
        return $this->allgradeitems;
527
    }
528
 
529
    /**
530
     * we supply the userids in this query, and get all the grades
531
     * pulls out all the grades, this does not need to worry about paging
532
     */
533
    public function load_final_grades() {
534
        global $CFG, $DB;
535
 
536
        if (!empty($this->grades)) {
537
            return;
538
        }
539
 
540
        if (empty($this->users)) {
541
            return;
542
        }
543
 
544
        // please note that we must fetch all grade_grades fields if we want to construct grade_grade object from it!
545
        $params = array_merge(array('courseid'=>$this->courseid), $this->userselectparams);
546
        $sql = "SELECT g.*
547
                  FROM {grade_items} gi,
548
                       {grade_grades} g
549
                 WHERE g.itemid = gi.id AND gi.courseid = :courseid {$this->userselect}";
550
 
551
        $userids = array_keys($this->users);
552
        $allgradeitems = $this->get_allgradeitems();
553
 
554
        if ($grades = $DB->get_records_sql($sql, $params)) {
555
            foreach ($grades as $graderec) {
556
                $grade = new grade_grade($graderec, false);
557
                if (!empty($allgradeitems[$graderec->itemid])) {
558
                    // Note: Filter out grades which have a grade type of GRADE_TYPE_NONE.
559
                    // Only grades without this type are present in $allgradeitems.
560
                    $this->allgrades[$graderec->userid][$graderec->itemid] = $grade;
561
                }
562
                if (in_array($graderec->userid, $userids) and array_key_exists($graderec->itemid, $this->gtree->get_items())) { // some items may not be present!!
563
                    $this->grades[$graderec->userid][$graderec->itemid] = $grade;
564
                    $this->grades[$graderec->userid][$graderec->itemid]->grade_item = $this->gtree->get_item($graderec->itemid); // db caching
565
                }
566
            }
567
        }
568
 
569
        // prefil grades that do not exist yet
570
        foreach ($userids as $userid) {
571
            foreach ($this->gtree->get_items() as $itemid => $unused) {
572
                if (!isset($this->grades[$userid][$itemid])) {
573
                    $this->grades[$userid][$itemid] = new grade_grade();
574
                    $this->grades[$userid][$itemid]->itemid = $itemid;
575
                    $this->grades[$userid][$itemid]->userid = $userid;
576
                    $this->grades[$userid][$itemid]->grade_item = $this->gtree->get_item($itemid); // db caching
577
 
578
                    $this->allgrades[$userid][$itemid] = $this->grades[$userid][$itemid];
579
                }
580
            }
581
        }
582
 
583
        // Pre fill grades for any remaining items which might be collapsed.
584
        foreach ($userids as $userid) {
585
            foreach ($allgradeitems as $itemid => $gradeitem) {
586
                if (!isset($this->allgrades[$userid][$itemid])) {
587
                    $this->allgrades[$userid][$itemid] = new grade_grade();
588
                    $this->allgrades[$userid][$itemid]->itemid = $itemid;
589
                    $this->allgrades[$userid][$itemid]->userid = $userid;
590
                    $this->allgrades[$userid][$itemid]->grade_item = $gradeitem;
591
                }
592
            }
593
        }
594
    }
595
 
596
    /**
597
     * Gets html toggle
598
     * @deprecated since Moodle 2.4 as it appears not to be used any more.
599
     */
600
    public function get_toggles_html() {
601
        throw new coding_exception('get_toggles_html() can not be used any more');
602
    }
603
 
604
    /**
605
     * Prints html toggle
606
     * @deprecated since 2.4 as it appears not to be used any more.
607
     * @param unknown $type
608
     */
609
    public function print_toggle($type) {
610
        throw new coding_exception('print_toggle() can not be used any more');
611
    }
612
 
613
    /**
614
     * Builds and returns the rows that will make up the left part of the grader report
615
     * This consists of student names and icons, links to user reports and id numbers, as well
616
     * as header cells for these columns. It also includes the fillers required for the
617
     * categories displayed on the right side of the report.
618
     * @param boolean $displayaverages whether to display average rows in the table
619
     * @return array Array of html_table_row objects
620
     */
621
    public function get_left_rows($displayaverages) {
622
        global $CFG, $OUTPUT;
623
 
624
        // Course context to determine how the user details should be displayed.
625
        $coursecontext = context_course::instance($this->courseid);
626
 
627
        $rows = [];
628
 
629
        $showuserimage = $this->get_pref('showuserimage');
630
        $viewfullnames = has_capability('moodle/site:viewfullnames', $this->context);
631
 
632
        $extrafields = \core_user\fields::get_identity_fields($this->context);
633
 
634
        $arrows = $this->get_sort_arrows($extrafields);
635
 
636
        $colspan = 1 + count($extrafields);
637
 
638
        $levels = count($this->gtree->levels) - 1;
639
 
640
        $fillercell = new html_table_cell();
641
        $fillercell->header = true;
642
        $fillercell->attributes['scope'] = 'col';
643
        $fillercell->attributes['class'] = 'cell topleft';
644
        $fillercell->text = html_writer::span(get_string('participants'), 'accesshide');
645
        $fillercell->colspan = $colspan;
646
        $fillercell->rowspan = $levels;
647
        $row = new html_table_row(array($fillercell));
648
        $rows[] = $row;
649
 
650
        for ($i = 1; $i < $levels; $i++) {
651
            $row = new html_table_row();
652
            $rows[] = $row;
653
        }
654
 
655
        $headerrow = new html_table_row();
656
        $headerrow->attributes['class'] = 'heading';
657
 
658
        $studentheader = new html_table_cell();
659
        // The browser's scrollbar may partly cover (in certain operative systems) the content in the student header
660
        // when horizontally scrolling through the table contents (most noticeable when in RTL mode).
661
        // Therefore, add slight padding on the left or right when using RTL mode.
662
        $studentheader->attributes['class'] = "header pl-3";
663
        $studentheader->scope = 'col';
664
        $studentheader->header = true;
665
        $studentheader->id = 'studentheader';
666
        $element = ['type' => 'userfield', 'name' => 'fullname'];
667
        $studentheader->text = $arrows['studentname'] .
668
            $this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr, $this->baseurl);
669
 
670
        $headerrow->cells[] = $studentheader;
671
 
672
        foreach ($extrafields as $field) {
673
            $fieldheader = new html_table_cell();
674
            $fieldheader->attributes['class'] = 'userfield user' . $field;
675
            $fieldheader->attributes['data-col'] = $field;
676
            $fieldheader->scope = 'col';
677
            $fieldheader->header = true;
678
 
679
            $collapsecontext = [
680
                'field' => $field,
681
                'name' => \core_user\fields::get_display_name($field),
682
            ];
683
 
684
            $collapsedicon = $OUTPUT->render_from_template('gradereport_grader/collapse/icon', $collapsecontext);
685
            // Need to wrap the button into a div with our hooking element for user items, gradeitems already have this.
686
            $collapsedicon = html_writer::div($collapsedicon, 'd-none', ['data-collapse' => 'expandbutton']);
687
 
688
            $element = ['type' => 'userfield', 'name' => $field];
689
            $fieldheader->text = $arrows[$field] .
690
                $this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr, $this->baseurl) . $collapsedicon;
691
            $headerrow->cells[] = $fieldheader;
692
        }
693
 
694
        $rows[] = $headerrow;
695
 
696
        $suspendedstring = null;
697
 
698
        $usercount = 0;
699
        foreach ($this->users as $userid => $user) {
700
            $userrow = new html_table_row();
701
            $userrow->id = 'fixed_user_'.$userid;
702
            $userrow->attributes['class'] = ($usercount % 2) ? 'userrow even' : 'userrow odd';
703
 
704
            $usercell = new html_table_cell();
705
            $usercell->attributes['class'] = ($usercount % 2) ? 'header user even' : 'header user odd';
706
            $usercount++;
707
 
708
            $usercell->header = true;
709
            $usercell->scope = 'row';
710
 
711
            if ($showuserimage) {
712
                $usercell->text = $OUTPUT->render(\core_user::get_profile_picture($user, $coursecontext, [
713
                    'link' => false, 'visibletoscreenreaders' => false
714
                ]));
715
            }
716
 
717
            $fullname = fullname($user, $viewfullnames);
718
            $usercell->text = html_writer::link(
719
                \core_user::get_profile_url($user, $coursecontext),
720
                $usercell->text . $fullname,
721
                ['class' => 'username']
722
            );
723
 
724
            if (!empty($user->suspendedenrolment)) {
725
                $usercell->attributes['class'] .= ' usersuspended';
726
 
727
                //may be lots of suspended users so only get the string once
728
                if (empty($suspendedstring)) {
729
                    $suspendedstring = get_string('userenrolmentsuspended', 'grades');
730
                }
731
                $icon = $OUTPUT->pix_icon('i/enrolmentsuspended', $suspendedstring);
732
                $usercell->text .= html_writer::tag('span', $icon, array('class'=>'usersuspendedicon'));
733
            }
734
            // The browser's scrollbar may partly cover (in certain operative systems) the content in the user cells
735
            // when horizontally scrolling through the table contents (most noticeable when in RTL mode).
736
            // Therefore, add slight padding on the left or right when using RTL mode.
737
            $usercell->attributes['class'] .= ' pl-3';
738
            $usercell->text .= $this->gtree->get_cell_action_menu(['userid' => $userid], 'user', $this->gpr);
739
 
740
            $userrow->cells[] = $usercell;
741
 
742
            foreach ($extrafields as $field) {
743
                $fieldcellcontent = s($user->$field);
744
                if ($field === 'country') {
745
                    $countries = get_string_manager()->get_list_of_countries();
746
                    $fieldcellcontent = $countries[$user->$field] ?? $fieldcellcontent;
747
                }
748
 
749
                $fieldcell = new html_table_cell();
750
                $fieldcell->attributes['class'] = 'userfield user' . $field;
751
                $fieldcell->attributes['data-col'] = $field;
752
                $fieldcell->header = false;
753
                $fieldcell->text = html_writer::tag('div', $fieldcellcontent, [
754
                    'data-collapse' => 'content'
755
                ]);
756
 
757
                $userrow->cells[] = $fieldcell;
758
            }
759
 
760
            $userrow->attributes['data-uid'] = $userid;
761
            $rows[] = $userrow;
762
        }
763
 
764
        $rows = $this->get_left_range_row($rows, $colspan);
765
        if ($displayaverages) {
766
            $rows = $this->get_left_avg_row($rows, $colspan, true);
767
            $rows = $this->get_left_avg_row($rows, $colspan);
768
        }
769
 
770
        return $rows;
771
    }
772
 
773
    /**
774
     * Builds and returns the rows that will make up the right part of the grader report
775
     * @param boolean $displayaverages whether to display average rows in the table
776
     * @return array Array of html_table_row objects
777
     */
778
    public function get_right_rows(bool $displayaverages): array {
779
        global $CFG, $USER, $OUTPUT, $DB, $PAGE;
780
 
781
        $rows = [];
782
        $this->rowcount = 0;
783
        $strgrade = get_string('gradenoun');
784
        $this->get_sort_arrows();
785
 
786
        // Get preferences once.
787
        $quickgrading = $this->get_pref('quickgrading');
788
 
789
        // Get strings which are re-used inside the loop.
790
        $strftimedatetimeshort = get_string('strftimedatetimeshort');
791
        $strerror = get_string('error');
792
        $stroverridengrade = get_string('overridden', 'grades');
793
        $strfail = get_string('fail', 'grades');
794
        $strpass = get_string('pass', 'grades');
795
        $viewfullnames = has_capability('moodle/site:viewfullnames', $this->context);
796
 
797
        // Preload scale objects for items with a scaleid and initialize tab indices.
798
        $scaleslist = [];
799
 
800
        foreach ($this->gtree->get_items() as $itemid => $item) {
801
            if (!empty($item->scaleid)) {
802
                $scaleslist[] = $item->scaleid;
803
            }
804
        }
805
 
806
        $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'gradereport_grader', 'scales');
807
        $scalesarray = $cache->get(get_class($this));
808
        if (!$scalesarray) {
809
            $scalesarray = $DB->get_records_list('scale', 'id', $scaleslist);
810
            // Save to cache.
811
            $cache->set(get_class($this), $scalesarray);
812
        }
813
 
814
        foreach ($this->gtree->get_levels() as $row) {
815
            $headingrow = new html_table_row();
816
            $headingrow->attributes['class'] = 'heading_name_row';
817
 
818
            foreach ($row as $element) {
819
                $sortlink = clone($this->baseurl);
820
                if (isset($element['object']->id)) {
821
                    $sortlink->param('sortitemid', $element['object']->id);
822
                }
823
 
824
                $type   = $element['type'];
825
 
826
                if (!empty($element['colspan'])) {
827
                    $colspan = $element['colspan'];
828
                } else {
829
                    $colspan = 1;
830
                }
831
 
832
                if (!empty($element['depth'])) {
833
                    $catlevel = 'catlevel'.$element['depth'];
834
                } else {
835
                    $catlevel = '';
836
                }
837
 
838
                // Element is a filler.
839
                if ($type == 'filler' || $type == 'fillerfirst' || $type == 'fillerlast') {
840
                    $fillercell = new html_table_cell();
841
                    $fillercell->attributes['class'] = $type . ' ' . $catlevel;
842
                    $fillercell->colspan = $colspan;
843
                    $fillercell->text = '&nbsp;';
844
 
845
                    // This is a filler cell; don't use a <th>, it'll confuse screen readers.
846
                    $fillercell->header = false;
847
                    $headingrow->cells[] = $fillercell;
848
                } else if ($type == 'category') {
849
                    // Make sure the grade category has a grade total or at least has child grade items.
850
                    if (grade_tree::can_output_item($element)) {
851
                        // Element is a category.
852
                        $categorycell = new html_table_cell();
853
                        $categorycell->attributes['class'] = 'category ' . $catlevel;
854
                        $categorycell->colspan = $colspan;
855
                        $categorycell->header = true;
856
                        $categorycell->scope = 'col';
857
 
858
                        $statusicons = $this->gtree->set_grade_status_icons($element);
859
                        if ($statusicons) {
860
                            $categorycell->attributes['class'] .= ' statusicons';
861
                        }
862
 
863
                        $context = new stdClass();
864
                        $context->courseheader = $this->get_course_header($element);
865
                        $context->actionmenu = $this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr);
866
                        $context->statusicons = $statusicons;
867
                        $categorycell->text = $OUTPUT->render_from_template('gradereport_grader/categorycell', $context);
868
 
869
                        $headingrow->cells[] = $categorycell;
870
                    }
871
                } else {
872
                    // Element is a grade_item.
873
 
874
                    $arrow = '';
875
                    if ($element['object']->id == $this->sortitemid) {
876
                        if ($this->sortorder == 'ASC') {
877
                            $arrow = $this->get_sort_arrow('down', $sortlink);
878
                        } else {
879
                            $arrow = $this->get_sort_arrow('up', $sortlink);
880
                        }
881
                    }
882
 
883
                    $collapsecontext = [
884
                        'field' => $element['object']->id,
885
                        'name' => $element['object']->get_name(),
886
                    ];
887
                    $collapsedicon = '';
888
                    // We do not want grade category total items to be hidden away as it is controlled by something else.
889
                    if (!$element['object']->is_aggregate_item()) {
890
                        $collapsedicon = $OUTPUT->render_from_template('gradereport_grader/collapse/icon', $collapsecontext);
891
                    }
892
                    $headerlink = grade_helper::get_element_header($element, true,
893
                        true, false, false, true);
894
 
895
                    $itemcell = new html_table_cell();
896
                    $itemcell->attributes['class'] = $type . ' ' . $catlevel .
897
                        ' highlightable'. ' i'. $element['object']->id;
898
                    $itemcell->attributes['data-itemid'] = $element['object']->id;
899
 
900
                    $singleview = $this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr, $this->baseurl);
901
                    $statusicons = $this->gtree->set_grade_status_icons($element);
902
                    if ($statusicons) {
903
                        $itemcell->attributes['class'] .= ' statusicons';
904
                    }
905
 
906
                    $itemcell->attributes['class'] .= $this->get_cell_display_class($element['object']);
907
 
908
                    $itemcell->colspan = $colspan;
909
                    $itemcell->header = true;
910
                    $itemcell->scope = 'col';
911
 
912
                    $context = new stdClass();
913
                    $context->headerlink = $headerlink;
914
                    $context->arrow = $arrow;
915
                    $context->singleview = $singleview;
916
                    $context->statusicons = $statusicons;
917
                    $context->collapsedicon = $collapsedicon;
918
 
919
                    $itemcell->text = $OUTPUT->render_from_template('gradereport_grader/headercell', $context);
920
 
921
                    $headingrow->cells[] = $itemcell;
922
                }
923
            }
924
            $rows[] = $headingrow;
925
        }
926
 
927
        // Get all the grade items if the user can not view hidden grade items.
928
        // It is possible that the user is simply viewing the 'Course total' by switching to the 'Aggregates only' view
929
        // and that this user does not have the ability to view hidden items. In this case we still need to pass all the
930
        // grade items (in case one has been hidden) as the course total shown needs to be adjusted for this particular
931
        // user.
932
        if (!$this->canviewhidden) {
933
            $allgradeitems = $this->get_allgradeitems();
934
        }
935
 
936
        foreach ($this->users as $userid => $user) {
937
 
938
            if ($this->canviewhidden) {
939
                $altered = [];
940
                $unknown = [];
941
            } else {
942
                $usergrades = $this->allgrades[$userid];
943
                $hidingaffected = grade_grade::get_hiding_affected($usergrades, $allgradeitems);
944
                $altered = $hidingaffected['altered'];
945
                $unknown = $hidingaffected['unknowngrades'];
946
                unset($hidingaffected);
947
            }
948
 
949
            $itemrow = new html_table_row();
950
            $itemrow->id = 'user_'.$userid;
951
 
952
            $fullname = fullname($user, $viewfullnames);
953
 
954
            foreach ($this->gtree->items as $itemid => $unused) {
955
                $item =& $this->gtree->items[$itemid];
956
                $grade = $this->grades[$userid][$item->id];
957
 
958
                $itemcell = new html_table_cell();
959
 
960
                $itemcell->id = 'u' . $userid . 'i' . $itemid;
961
                $itemcell->attributes['data-itemid'] = $itemid;
962
                $itemcell->attributes['class'] = 'gradecell';
963
 
964
                // Get the decimal points preference for this item.
965
                $decimalpoints = $item->get_decimals();
966
 
967
                if (array_key_exists($itemid, $unknown)) {
968
                    $gradeval = null;
969
                } else if (array_key_exists($itemid, $altered)) {
970
                    $gradeval = $altered[$itemid];
971
                } else {
972
                    $gradeval = $grade->finalgrade;
973
                }
974
 
975
                $context = new stdClass();
976
 
977
                // MDL-11274: Hide grades in the grader report if the current grader
978
                // doesn't have 'moodle/grade:viewhidden'.
979
                if (!$this->canviewhidden && $grade->is_hidden()) {
980
                    if (!empty($CFG->grade_hiddenasdate) && $grade->get_datesubmitted()
981
                            && !$item->is_category_item() && !$item->is_course_item()) {
982
                        // The problem here is that we do not have the time when grade value was modified,
983
                        // 'timemodified' is general modification date for grade_grades records.
984
                        $context->text = userdate($grade->get_datesubmitted(), $strftimedatetimeshort);
985
                        $context->extraclasses = 'datesubmitted';
986
                    } else {
987
                        $context->text = '-';
988
                    }
989
                    $itemcell->text = $OUTPUT->render_from_template('gradereport_grader/cell', $context);
990
                    $itemrow->cells[] = $itemcell;
991
                    continue;
992
                }
993
 
994
                // Emulate grade element.
995
                $eid = $this->gtree->get_grade_eid($grade);
996
                $element = ['eid' => $eid, 'object' => $grade, 'type' => 'grade'];
997
 
998
                $itemcell->attributes['class'] .= ' grade i' . $itemid;
999
                if ($item->is_category_item()) {
1000
                    $itemcell->attributes['class'] .= ' cat';
1001
                }
1002
                if ($item->is_course_item()) {
1003
                    $itemcell->attributes['class'] .= ' course';
1004
                }
1005
                if ($grade->is_overridden()) {
1006
                    $itemcell->attributes['class'] .= ' overridden';
1007
                    $itemcell->attributes['aria-label'] = $stroverridengrade;
1008
                }
1009
 
1010
                $hidden = '';
1011
                if ($grade->is_hidden()) {
1012
                    $hidden = ' dimmed_text ';
1013
                }
1014
                $gradepass = ' gradefail ';
1015
                $context->gradepassicon = $OUTPUT->pix_icon('i/invalid', $strfail);
1016
                if ($grade->is_passed($item)) {
1017
                    $gradepass = ' gradepass ';
1018
                    $context->gradepassicon = $OUTPUT->pix_icon('i/valid', $strpass);
1019
                } else if (is_null($grade->is_passed($item))) {
1020
                    $gradepass = '';
1021
                    $context->gradepassicon = '';
1022
                }
1023
                $context->statusicons = $this->gtree->set_grade_status_icons($element);
1024
 
1025
                // If in editing mode, we need to print either a text box or a drop down (for scales)
1026
                // grades in item of type grade category or course are not directly editable.
1027
                if ($item->needsupdate) {
1028
                    $context->text = $strerror;
1029
                    $context->extraclasses = 'gradingerror';
1030
                } else if (!empty($USER->editing)) {
1031
                    if ($item->scaleid && !empty($scalesarray[$item->scaleid])) {
1032
                        $itemcell->attributes['class'] .= ' grade_type_scale';
1033
                    } else if ($item->gradetype == GRADE_TYPE_VALUE) {
1034
                        $itemcell->attributes['class'] .= ' grade_type_value';
1035
                    } else if ($item->gradetype == GRADE_TYPE_TEXT) {
1036
                        $itemcell->attributes['class'] .= ' grade_type_text';
1037
                    }
1038
 
1039
                    if ($grade->is_locked()) {
1040
                        $itemcell->attributes['class'] .= ' locked';
1041
                    }
1042
 
1043
                    if ($item->scaleid && !empty($scalesarray[$item->scaleid])) {
1044
                        $context->scale = true;
1045
 
1046
                        $scale = $scalesarray[$item->scaleid];
1047
                        $gradeval = (int)$gradeval; // Scales use only integers.
1048
                        $scales = explode(",", $scale->scale);
1049
                        // Reindex because scale is off 1.
1050
 
1051
                        // MDL-12104 some previous scales might have taken up part of the array
1052
                        // so this needs to be reset.
1053
                        $scaleopt = [];
1054
                        $i = 0;
1055
                        foreach ($scales as $scaleoption) {
1056
                            $i++;
1057
                            $scaleopt[$i] = $scaleoption;
1058
                        }
1059
 
1060
                        if ($quickgrading && $grade->is_editable()) {
1061
                            $context->iseditable = true;
1062
                            if (empty($item->outcomeid)) {
1063
                                $nogradestr = get_string('nograde');
1064
                            } else {
1065
                                $nogradestr = get_string('nooutcome', 'grades');
1066
                            }
1067
                            $attributes = [
1068
                                'id' => 'grade_' . $userid . '_' . $item->id
1069
                            ];
1070
                            $gradelabel = $fullname . ' ' . $item->get_name(true);
1071
 
1072
                            if ($context->statusicons) {
1073
                                $attributes['class'] = 'statusicons';
1074
                            }
1075
 
1076
                            $context->label = html_writer::label(
1077
                                get_string('useractivitygrade', 'gradereport_grader', $gradelabel),
1078
                                $attributes['id'], false, ['class' => 'accesshide']);
1079
                            $context->select = html_writer::select($scaleopt, 'grade['.$userid.']['.$item->id.']',
1080
                                $gradeval, [-1 => $nogradestr], $attributes);
1081
                        } else if (!empty($scale)) {
1082
                            $scales = explode(",", $scale->scale);
1083
 
1084
                            $context->extraclasses = 'gradevalue' . $hidden . $gradepass;
1085
                            // Invalid grade if gradeval < 1.
1086
                            if ($gradeval < 1) {
1087
                                $context->text = '-';
1088
                            } else {
1089
                                // Just in case somebody changes scale.
1090
                                $gradeval = $grade->grade_item->bounded_grade($gradeval);
1091
                                $context->text = $scales[$gradeval - 1];
1092
                            }
1093
                        }
1094
 
1095
                    } else if ($item->gradetype != GRADE_TYPE_TEXT) {
1096
                        // Value type.
1097
                        if ($quickgrading and $grade->is_editable()) {
1098
                            $context->iseditable = true;
1099
 
1100
                            // Set this input field with type="number" if the decimal separator for current language is set to
1101
                            // a period. Other decimal separators may not be recognised by browsers yet which may cause issues
1102
                            // when entering grades.
1103
                            $decsep = get_string('decsep', 'core_langconfig');
1104
                            $context->isnumeric = $decsep === '.';
1105
                            // If we're rendering this as a number field, set min/max attributes, if applicable.
1106
                            if ($context->isnumeric) {
1107
                                $context->minvalue = $item->grademin ?? null;
1108
                                if (empty($CFG->unlimitedgrades)) {
1109
                                    $context->maxvalue = $item->grademax ?? null;
1110
                                }
1111
                            }
1112
 
1113
                            $value = format_float($gradeval, $decimalpoints);
1114
                            $gradelabel = $fullname . ' ' . $item->get_name(true);
1115
 
1116
                            $context->id = 'grade_' . $userid . '_' . $item->id;
1117
                            $context->name = 'grade[' . $userid . '][' . $item->id .']';
1118
                            $context->value = $value;
1119
                            $context->label = get_string('useractivitygrade', 'gradereport_grader', $gradelabel);
1120
                            $context->title = $strgrade;
1121
                            $context->extraclasses = 'form-control';
1122
                            if ($context->statusicons) {
1123
                                $context->extraclasses .= ' statusicons';
1124
                            }
1125
                        } else {
1126
                            $context->extraclasses = 'gradevalue' . $hidden . $gradepass;
1127
                            $context->text = format_float($gradeval, $decimalpoints);
1128
                        }
1129
                    }
1130
 
1131
                } else {
1132
                    // Not editing.
1133
                    $gradedisplaytype = $item->get_displaytype();
1134
 
1135
                    // Letter grades, scales and text grades are left aligned.
1136
                    $textgrade = false;
1137
                    $textgrades = [GRADE_DISPLAY_TYPE_LETTER,
1138
                        GRADE_DISPLAY_TYPE_REAL_LETTER,
1139
                        GRADE_DISPLAY_TYPE_LETTER_REAL,
1140
                        GRADE_DISPLAY_TYPE_LETTER_PERCENTAGE,
1141
                        GRADE_DISPLAY_TYPE_PERCENTAGE_LETTER];
1142
                    if (in_array($gradedisplaytype, $textgrades)) {
1143
                        $textgrade = true;
1144
                    }
1145
 
1146
                    if ($textgrade || ($item->gradetype == GRADE_TYPE_TEXT)) {
1147
                        $itemcell->attributes['class'] .= ' grade_type_text';
1148
                    } else if ($item->scaleid && !empty($scalesarray[$item->scaleid])) {
1149
                        if ($gradedisplaytype == GRADE_DISPLAY_TYPE_PERCENTAGE) {
1150
                            $itemcell->attributes['class'] .= ' grade_type_value';
1151
                        } else {
1152
                            $itemcell->attributes['class'] .= ' grade_type_scale';
1153
                        }
1154
                    } else {
1155
                        $itemcell->attributes['class'] .= ' grade_type_value';
1156
                    }
1157
 
1158
                    if ($item->needsupdate) {
1159
                        $context->text = $strerror;
1160
                        $context->extraclasses = 'gradingerror' . $hidden . $gradepass;
1161
                    } else {
1162
                        // The max and min for an aggregation may be different to the grade_item.
1163
                        if (!is_null($gradeval)) {
1164
                            $item->grademax = $grade->get_grade_max();
1165
                            $item->grademin = $grade->get_grade_min();
1166
                        }
1167
 
1168
                        $context->extraclasses = 'gradevalue ' . $hidden . $gradepass;
1169
                        $context->text = grade_format_gradevalue($gradeval, $item, true,
1170
                            $gradedisplaytype, null);
1171
                    }
1172
                }
1173
 
1174
                if ($item->gradetype == GRADE_TYPE_TEXT && !empty($grade->feedback)) {
1175
                    $context->text = html_writer::span(shorten_text(strip_tags($grade->feedback), 20), '',
1176
                        ['data-action' => 'feedback', 'role' => 'button', 'data-courseid' => $this->courseid]);
1177
                }
1178
 
1179
                if (!$item->needsupdate && !($item->gradetype == GRADE_TYPE_TEXT && empty($USER->editing))) {
1180
                    $context->actionmenu = $this->gtree->get_cell_action_menu($element, 'gradeitem', $this->gpr);
1181
                }
1182
 
1183
                $itemcell->text = $OUTPUT->render_from_template('gradereport_grader/cell', $context);
1184
 
1185
                if (!empty($this->gradeserror[$item->id][$userid])) {
1186
                    $itemcell->text .= $this->gradeserror[$item->id][$userid];
1187
                }
1188
 
1189
                $itemrow->cells[] = $itemcell;
1190
            }
1191
            $rows[] = $itemrow;
1192
        }
1193
 
1194
        if (!empty($USER->editing)) {
1195
            $PAGE->requires->js_call_amd('core_form/changechecker',
1196
                'watchFormById', ['gradereport_grader']);
1197
        }
1198
 
1199
        $rows = $this->get_right_range_row($rows);
1200
        if ($displayaverages && $this->canviewhidden) {
1201
            $showonlyactiveenrol = $this->show_only_active();
1202
 
1203
            if ($this->currentgroup) {
1204
                $ungradedcounts = $this->ungraded_counts(true, true, $showonlyactiveenrol);
1205
                $rows[] = $this->format_averages($ungradedcounts);
1206
            }
1207
 
1208
            $ungradedcounts = $this->ungraded_counts(false, true, $showonlyactiveenrol);
1209
            $rows[] = $this->format_averages($ungradedcounts);
1210
        }
1211
 
1212
        return $rows;
1213
    }
1214
 
1215
    /**
1216
     * Returns a row of grade items averages
1217
     *
1218
     * @param grade_item $gradeitem Grade item.
1219
     * @param array|null $aggr Average value and meancount information.
1220
     * @param bool|null $shownumberofgrades Whether to show number of grades.
1221
     * @return html_table_cell Formatted average cell.
1222
     */
1223
    protected function format_average_cell(grade_item $gradeitem, ?array $aggr = null, ?bool $shownumberofgrades = null): html_table_cell {
1224
        global $OUTPUT;
1225
 
1226
        if ($gradeitem->needsupdate) {
1227
            $avgcell = new html_table_cell();
1228
            $avgcell->attributes['class'] = 'i' . $gradeitem->id;
1229
            $avgcell->text = $OUTPUT->container(get_string('error'), 'gradingerror');
1230
        } else {
1231
            $gradetypeclass = '';
1232
            if ($gradeitem->gradetype == GRADE_TYPE_SCALE) {
1233
                $gradetypeclass = ' grade_type_scale';
1234
            } else if ($gradeitem->gradetype == GRADE_TYPE_VALUE) {
1235
                $gradetypeclass = ' grade_type_value';
1236
            } else if ($gradeitem->gradetype == GRADE_TYPE_TEXT) {
1237
                $gradetypeclass = ' grade_type_text';
1238
            }
1239
 
1240
            if (empty($aggr['average'])) {
1241
                $avgcell = new html_table_cell();
1242
                $avgcell->attributes['class'] = $gradetypeclass . ' i' . $gradeitem->id;
1243
                $avgcell->attributes['data-itemid'] = $gradeitem->id;
1244
                $avgcell->text = html_writer::div('-', '', ['data-collapse' => 'avgrowcell']);
1245
            } else {
1246
                $numberofgrades = '';
1247
                if ($shownumberofgrades) {
1248
                    $numberofgrades = " (" . $aggr['meancount'] . ")";
1249
                }
1250
 
1251
                $avgcell = new html_table_cell();
1252
                $avgcell->attributes['class'] = $gradetypeclass . ' i' . $gradeitem->id;
1253
                $avgcell->attributes['data-itemid'] = $gradeitem->id;
1254
                $avgcell->text = html_writer::div($aggr['average'] . $numberofgrades, '', ['data-collapse' => 'avgrowcell']);
1255
            }
1256
        }
1257
        return $avgcell;
1258
    }
1259
 
1260
    /**
1261
     * Depending on the style of report (fixedstudents vs traditional one-table),
1262
     * arranges the rows of data in one or two tables, and returns the output of
1263
     * these tables in HTML
1264
     * @param boolean $displayaverages whether to display average rows in the table
1265
     * @return string HTML
1266
     */
1267
    public function get_grade_table($displayaverages = false) {
1268
        global $OUTPUT;
1269
        $leftrows = $this->get_left_rows($displayaverages);
1270
        $rightrows = $this->get_right_rows($displayaverages);
1271
 
1272
        $html = '';
1273
 
1274
        $fulltable = new html_table();
1275
        $fulltable->attributes['class'] = 'gradereport-grader-table d-none';
1276
        $fulltable->id = 'user-grades';
1277
        $fulltable->caption = get_string('summarygrader', 'gradereport_grader');
1278
        $fulltable->captionhide = true;
1279
        // We don't want the table to be enclosed within in a .table-responsive div as it is heavily customised.
1280
        $fulltable->responsive = false;
1281
 
1282
        // Extract rows from each side (left and right) and collate them into one row each
1283
        foreach ($leftrows as $key => $row) {
1284
            $row->cells = array_merge($row->cells, $rightrows[$key]->cells);
1285
            $fulltable->data[] = $row;
1286
        }
1287
        $html .= html_writer::table($fulltable);
1288
        return $OUTPUT->container($html, 'gradeparent');
1289
    }
1290
 
1291
    /**
1292
     * Builds and return the row of icons for the left side of the report.
1293
     * It only has one cell that says "Controls"
1294
     * @param array $rows The Array of rows for the left part of the report
1295
     * @param int $colspan The number of columns this cell has to span
1296
     * @return array Array of rows for the left part of the report
1297
     * @deprecated since Moodle 4.2 - The row is not shown anymore - we have actions menu.
1298
     * @todo MDL-77307 This will be deleted in Moodle 4.6.
1299
     */
1300
    public function get_left_icons_row($rows=array(), $colspan=1) {
1301
        global $USER;
1302
 
1303
        debugging('The function get_left_icons_row() is deprecated, please do not use it anymore.',
1304
            DEBUG_DEVELOPER);
1305
 
1306
        if (!empty($USER->editing)) {
1307
            $controlsrow = new html_table_row();
1308
            $controlsrow->attributes['class'] = 'controls';
1309
            $controlscell = new html_table_cell();
1310
            $controlscell->attributes['class'] = 'header controls';
1311
            $controlscell->header = true;
1312
            $controlscell->colspan = $colspan;
1313
            $controlscell->text = get_string('controls', 'grades');
1314
            $controlsrow->cells[] = $controlscell;
1315
 
1316
            $rows[] = $controlsrow;
1317
        }
1318
        return $rows;
1319
    }
1320
 
1321
    /**
1322
     * Builds and return the header for the row of ranges, for the left part of the grader report.
1323
     * @param array $rows The Array of rows for the left part of the report
1324
     * @param int $colspan The number of columns this cell has to span
1325
     * @return array Array of rows for the left part of the report
1326
     */
1327
    public function get_left_range_row($rows=array(), $colspan=1) {
1328
        global $CFG, $USER;
1329
 
1330
        if ($this->get_pref('showranges')) {
1331
            $rangerow = new html_table_row();
1332
            $rangerow->attributes['class'] = 'range r'.$this->rowcount++;
1333
            $rangecell = new html_table_cell();
1334
            $rangecell->attributes['class'] = 'header range';
1335
            $rangecell->colspan = $colspan;
1336
            $rangecell->header = true;
1337
            $rangecell->scope = 'row';
1338
            $rangecell->text = get_string('range', 'grades');
1339
            $rangerow->cells[] = $rangecell;
1340
            $rows[] = $rangerow;
1341
        }
1342
 
1343
        return $rows;
1344
    }
1345
 
1346
    /**
1347
     * Builds and return the headers for the rows of averages, for the left part of the grader report.
1348
     * @param array $rows The Array of rows for the left part of the report
1349
     * @param int $colspan The number of columns this cell has to span
1350
     * @param bool $groupavg If true, returns the row for group averages, otherwise for overall averages
1351
     * @return array Array of rows for the left part of the report
1352
     */
1353
    public function get_left_avg_row($rows=array(), $colspan=1, $groupavg=false) {
1354
        if (!$this->canviewhidden) {
1355
            // totals might be affected by hiding, if user can not see hidden grades the aggregations might be altered
1356
            // better not show them at all if user can not see all hideen grades
1357
            return $rows;
1358
        }
1359
 
1360
        $showaverages = $this->get_pref('showaverages');
1361
        $showaveragesgroup = $this->currentgroup && $showaverages;
1362
        $straveragegroup = get_string('groupavg', 'grades');
1363
 
1364
        if ($groupavg) {
1365
            if ($showaveragesgroup) {
1366
                $groupavgrow = new html_table_row();
1367
                $groupavgrow->attributes['class'] = 'groupavg r'.$this->rowcount++;
1368
                $groupavgcell = new html_table_cell();
1369
                $groupavgcell->attributes['class'] = 'header range';
1370
                $groupavgcell->colspan = $colspan;
1371
                $groupavgcell->header = true;
1372
                $groupavgcell->scope = 'row';
1373
                $groupavgcell->text = $straveragegroup;
1374
                $groupavgrow->cells[] = $groupavgcell;
1375
                $rows[] = $groupavgrow;
1376
            }
1377
        } else {
1378
            $straverage = get_string('overallaverage', 'grades');
1379
 
1380
            if ($showaverages) {
1381
                $avgrow = new html_table_row();
1382
                $avgrow->attributes['class'] = 'avg r'.$this->rowcount++;
1383
                $avgcell = new html_table_cell();
1384
                $avgcell->attributes['class'] = 'header range';
1385
                $avgcell->colspan = $colspan;
1386
                $avgcell->header = true;
1387
                $avgcell->scope = 'row';
1388
                $avgcell->text = $straverage;
1389
                $avgrow->cells[] = $avgcell;
1390
                $rows[] = $avgrow;
1391
            }
1392
        }
1393
 
1394
        return $rows;
1395
    }
1396
 
1397
    /**
1398
     * Builds and return the row of icons when editing is on, for the right part of the grader report.
1399
     * @param array $rows The Array of rows for the right part of the report
1400
     * @return array Array of rows for the right part of the report
1401
     * @deprecated since Moodle 4.2 - The row is not shown anymore - we have actions menu.
1402
     * @todo MDL-77307 This will be deleted in Moodle 4.6.
1403
     */
1404
    public function get_right_icons_row($rows=array()) {
1405
        global $USER;
1406
        debugging('The function get_right_icons_row() is deprecated, please do not use it anymore.',
1407
            DEBUG_DEVELOPER);
1408
 
1409
        if (!empty($USER->editing)) {
1410
            $iconsrow = new html_table_row();
1411
            $iconsrow->attributes['class'] = 'controls';
1412
 
1413
            foreach ($this->gtree->items as $itemid => $unused) {
1414
                // emulate grade element
1415
                $item = $this->gtree->get_item($itemid);
1416
 
1417
                $eid = $this->gtree->get_item_eid($item);
1418
                $element = $this->gtree->locate_element($eid);
1419
                $itemcell = new html_table_cell();
1420
                $itemcell->attributes['class'] = 'controls icons i'.$itemid;
1421
                $itemcell->text = $this->get_icons($element);
1422
                $iconsrow->cells[] = $itemcell;
1423
            }
1424
            $rows[] = $iconsrow;
1425
        }
1426
        return $rows;
1427
    }
1428
 
1429
    /**
1430
     * Builds and return the row of ranges for the right part of the grader report.
1431
     * @param array $rows The Array of rows for the right part of the report
1432
     * @return array Array of rows for the right part of the report
1433
     */
1434
    public function get_right_range_row($rows=array()) {
1435
 
1436
        if ($this->get_pref('showranges')) {
1437
            $rangesdisplaytype   = $this->get_pref('rangesdisplaytype');
1438
            $rangesdecimalpoints = $this->get_pref('rangesdecimalpoints');
1439
            $rangerow = new html_table_row();
1440
            $rangerow->attributes['class'] = 'heading range';
1441
 
1442
            foreach ($this->gtree->items as $itemid => $unused) {
1443
                $item =& $this->gtree->items[$itemid];
1444
                $itemcell = new html_table_cell();
1445
                $itemcell->attributes['class'] .= ' range i'. $itemid;
1446
                $itemcell->attributes['class'] .= $this->get_cell_display_class($item);
1447
 
1448
                $hidden = '';
1449
                if ($item->is_hidden()) {
1450
                    $hidden = ' dimmed_text ';
1451
                }
1452
 
1453
                $formattedrange = $item->get_formatted_range($rangesdisplaytype, $rangesdecimalpoints);
1454
 
1455
                $itemcell->attributes['data-itemid'] = $itemid;
1456
                $itemcell->text = html_writer::div($formattedrange, 'rangevalues' . $hidden,
1457
                    ['data-collapse' => 'rangerowcell']);
1458
                $rangerow->cells[] = $itemcell;
1459
            }
1460
            $rows[] = $rangerow;
1461
        }
1462
        return $rows;
1463
    }
1464
 
1465
    /**
1466
     * @deprecated since Moodle 4.4 - Call calculate_average instead.
1467
     * Builds and return the row of averages for the right part of the grader report.
1468
     * @param array $rows Whether to return only group averages or all averages.
1469
     * @param bool $grouponly Whether to return only group averages or all averages.
1470
     * @return array Array of rows for the right part of the report
1471
     */
1472
    public function get_right_avg_row($rows=array(), $grouponly=false) {
1473
        global $USER, $DB, $OUTPUT, $CFG;
1474
 
1475
        debugging('grader_report_grader::get_right_avg_row() is deprecated.
1476
            Call grade_report::calculate_average() instead.', DEBUG_DEVELOPER);
1477
 
1478
        if (!$this->canviewhidden) {
1479
            // Totals might be affected by hiding, if user can not see hidden grades the aggregations might be altered
1480
            // better not show them at all if user can not see all hidden grades.
1481
            return $rows;
1482
        }
1483
 
1484
        $averagesdisplaytype   = $this->get_pref('averagesdisplaytype');
1485
        $averagesdecimalpoints = $this->get_pref('averagesdecimalpoints');
1486
        $meanselection         = $this->get_pref('meanselection');
1487
        $shownumberofgrades    = $this->get_pref('shownumberofgrades');
1488
 
1489
        if ($grouponly) {
1490
            $showaverages = $this->currentgroup && $this->get_pref('showaverages');
1491
            $groupsql = $this->groupsql;
1492
            $groupwheresql = $this->groupwheresql;
1493
            $groupwheresqlparams = $this->groupwheresql_params;
1494
        } else {
1495
            $showaverages = $this->get_pref('showaverages');
1496
            $groupsql = "";
1497
            $groupwheresql = "";
1498
            $groupwheresqlparams = array();
1499
        }
1500
 
1501
        if ($showaverages) {
1502
            $totalcount = $this->get_numusers($grouponly);
1503
 
1504
            // Limit to users with a gradeable role.
1505
            list($gradebookrolessql, $gradebookrolesparams) = $DB->get_in_or_equal(explode(',', $this->gradebookroles), SQL_PARAMS_NAMED, 'grbr0');
1506
 
1507
            // Limit to users with an active enrollment.
1508
            $coursecontext = $this->context->get_course_context(true);
1509
            $defaultgradeshowactiveenrol = !empty($CFG->grade_report_showonlyactiveenrol);
1510
            $showonlyactiveenrol = get_user_preferences('grade_report_showonlyactiveenrol', $defaultgradeshowactiveenrol);
1511
            $showonlyactiveenrol = $showonlyactiveenrol || !has_capability('moodle/course:viewsuspendedusers', $coursecontext);
1512
            list($enrolledsql, $enrolledparams) = get_enrolled_sql($this->context, '', 0, $showonlyactiveenrol);
1513
 
1514
            // We want to query both the current context and parent contexts.
1515
            list($relatedctxsql, $relatedctxparams) = $DB->get_in_or_equal($this->context->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'relatedctx');
1516
 
1517
            $params = array_merge(array('courseid' => $this->courseid), $gradebookrolesparams, $enrolledparams, $groupwheresqlparams, $relatedctxparams);
1518
 
1519
            // Find sums of all grade items in course.
1520
            $sql = "SELECT g.itemid, SUM(g.finalgrade) AS sum
1521
                      FROM {grade_items} gi
1522
                      JOIN {grade_grades} g ON g.itemid = gi.id
1523
                      JOIN {user} u ON u.id = g.userid
1524
                      JOIN ($enrolledsql) je ON je.id = u.id
1525
                      JOIN (
1526
                               SELECT DISTINCT ra.userid
1527
                                 FROM {role_assignments} ra
1528
                                WHERE ra.roleid $gradebookrolessql
1529
                                  AND ra.contextid $relatedctxsql
1530
                           ) rainner ON rainner.userid = u.id
1531
                      $groupsql
1532
                     WHERE gi.courseid = :courseid
1533
                       AND u.deleted = 0
1534
                       AND g.finalgrade IS NOT NULL
1535
                       $groupwheresql
1536
                     GROUP BY g.itemid";
1537
            $sumarray = array();
1538
            if ($sums = $DB->get_records_sql($sql, $params)) {
1539
                foreach ($sums as $itemid => $csum) {
1540
                    $sumarray[$itemid] = $csum->sum;
1541
                }
1542
            }
1543
 
1544
            // MDL-10875 Empty grades must be evaluated as grademin, NOT always 0
1545
            // This query returns a count of ungraded grades (NULL finalgrade OR no matching record in grade_grades table)
1546
            $sql = "SELECT gi.id, COUNT(DISTINCT u.id) AS count
1547
                      FROM {grade_items} gi
1548
                      CROSS JOIN ($enrolledsql) u
1549
                      JOIN {role_assignments} ra
1550
                           ON ra.userid = u.id
1551
                      LEFT OUTER JOIN {grade_grades} g
1552
                           ON (g.itemid = gi.id AND g.userid = u.id AND g.finalgrade IS NOT NULL)
1553
                      $groupsql
1554
                     WHERE gi.courseid = :courseid
1555
                           AND ra.roleid $gradebookrolessql
1556
                           AND ra.contextid $relatedctxsql
1557
                           AND g.id IS NULL
1558
                           $groupwheresql
1559
                  GROUP BY gi.id";
1560
 
1561
            $ungradedcounts = $DB->get_records_sql($sql, $params);
1562
 
1563
            $avgrow = new html_table_row();
1564
            $avgrow->attributes['class'] = 'avg';
1565
 
1566
            foreach ($this->gtree->items as $itemid => $unused) {
1567
                $item =& $this->gtree->items[$itemid];
1568
 
1569
                if ($item->needsupdate) {
1570
                    $avgcell = new html_table_cell();
1571
                    $avgcell->attributes['class'] = 'i'. $itemid;
1572
                    $avgcell->text = $OUTPUT->container(get_string('error'), 'gradingerror');
1573
                    $avgrow->cells[] = $avgcell;
1574
                    continue;
1575
                }
1576
 
1577
                if (!isset($sumarray[$item->id])) {
1578
                    $sumarray[$item->id] = 0;
1579
                }
1580
 
1581
                if (empty($ungradedcounts[$itemid])) {
1582
                    $ungradedcount = 0;
1583
                } else {
1584
                    $ungradedcount = $ungradedcounts[$itemid]->count;
1585
                }
1586
 
1587
                if ($meanselection == GRADE_REPORT_MEAN_GRADED) {
1588
                    $meancount = $totalcount - $ungradedcount;
1589
                } else { // Bump up the sum by the number of ungraded items * grademin
1590
                    $sumarray[$item->id] += $ungradedcount * $item->grademin;
1591
                    $meancount = $totalcount;
1592
                }
1593
 
1594
                // Determine which display type to use for this average
1595
                if (!empty($USER->editing)) {
1596
                    $displaytype = GRADE_DISPLAY_TYPE_REAL;
1597
 
1598
                } else if ($averagesdisplaytype == GRADE_REPORT_PREFERENCE_INHERIT) { // no ==0 here, please resave the report and user preferences
1599
                    $displaytype = $item->get_displaytype();
1600
 
1601
                } else {
1602
                    $displaytype = $averagesdisplaytype;
1603
                }
1604
 
1605
                // Override grade_item setting if a display preference (not inherit) was set for the averages
1606
                if ($averagesdecimalpoints == GRADE_REPORT_PREFERENCE_INHERIT) {
1607
                    $decimalpoints = $item->get_decimals();
1608
 
1609
                } else {
1610
                    $decimalpoints = $averagesdecimalpoints;
1611
                }
1612
 
1613
                $gradetypeclass = $this->get_cell_display_class($item);
1614
 
1615
                if (!isset($sumarray[$item->id]) || $meancount == 0) {
1616
                    $avgcell = new html_table_cell();
1617
                    $avgcell->attributes['class'] = $gradetypeclass . ' i'. $itemid;
1618
                    $avgcell->attributes['data-itemid'] = $itemid;
1619
                    $avgcell->text = html_writer::div('-', '', ['data-collapse' => 'avgrowcell']);
1620
                    $avgrow->cells[] = $avgcell;
1621
                } else {
1622
                    $sum = $sumarray[$item->id];
1623
                    $avgradeval = $sum/$meancount;
1624
                    $gradehtml = grade_format_gradevalue($avgradeval, $item, true, $displaytype, $decimalpoints);
1625
 
1626
                    $numberofgrades = '';
1627
                    if ($shownumberofgrades) {
1628
                        $numberofgrades = " ($meancount)";
1629
                    }
1630
 
1631
                    $avgcell = new html_table_cell();
1632
                    $avgcell->attributes['class'] = $gradetypeclass . ' i'. $itemid;
1633
                    $avgcell->attributes['data-itemid'] = $itemid;
1634
                    $avgcell->text = html_writer::div($gradehtml.$numberofgrades, '', ['data-collapse' => 'avgrowcell']);
1635
                    $avgrow->cells[] = $avgcell;
1636
                }
1637
            }
1638
            $rows[] = $avgrow;
1639
        }
1640
        return $rows;
1641
    }
1642
 
1643
    /**
1644
     * Given element category, create a collapsible icon and
1645
     * course header.
1646
     *
1647
     * @param array $element
1648
     * @return string HTML
1649
     */
1650
    protected function get_course_header($element) {
1651
        if (in_array($element['object']->id, $this->collapsed['aggregatesonly'])) {
1652
            $showing = get_string('showingaggregatesonly', 'grades');
1653
        } else if (in_array($element['object']->id, $this->collapsed['gradesonly'])) {
1654
            $showing = get_string('showinggradesonly', 'grades');
1655
        } else {
1656
            $showing = get_string('showingfullmode', 'grades');
1657
        }
1658
 
1659
        $name = $element['object']->get_name();
1660
        $nameunescaped = $element['object']->get_name(false);
1661
        $describedbyid = uniqid();
1662
        $courseheader = html_writer::tag('span', $name, [
1663
            'title' => $nameunescaped,
1664
            'class' => 'gradeitemheader',
1665
            'aria-describedby' => $describedbyid
1666
        ]);
1667
        $courseheader .= html_writer::div($showing, 'sr-only', [
1668
            'id' => $describedbyid
1669
        ]);
1670
 
1671
        return $courseheader;
1672
    }
1673
 
1674
    /**
1675
     * Given a grade_category, grade_item or grade_grade, this function
1676
     * figures out the state of the object and builds then returns a div
1677
     * with the icons needed for the grader report.
1678
     *
1679
     * @param array $element
1680
     * @return string HTML
1681
     * @deprecated since Moodle 4.2 - The row is not shown anymore - we have actions menu.
1682
     * @todo MDL-77307 This will be deleted in Moodle 4.6.
1683
     */
1684
    protected function get_icons($element) {
1685
        global $CFG, $USER, $OUTPUT;
1686
        debugging('The function get_icons() is deprecated, please do not use it anymore.',
1687
            DEBUG_DEVELOPER);
1688
 
1689
        if (empty($USER->editing)) {
1690
            return '<div class="grade_icons" />';
1691
        }
1692
 
1693
        // Init all icons
1694
        $editicon = '';
1695
 
1696
        $editable = true;
1697
 
1698
        if ($element['type'] == 'grade') {
1699
            $item = $element['object']->grade_item;
1700
            if ($item->is_course_item() or $item->is_category_item()) {
1701
                $editable = $this->overridecat;
1702
            }
1703
        }
1704
 
1705
        if ($element['type'] != 'categoryitem' && $element['type'] != 'courseitem' && $editable) {
1706
            $editicon = $this->gtree->get_edit_icon($element, $this->gpr);
1707
        }
1708
 
1709
        $editcalculationicon = '';
1710
        $showhideicon        = '';
1711
        $lockunlockicon      = '';
1712
 
1713
        if (has_capability('moodle/grade:manage', $this->context)) {
1714
            $editcalculationicon = $this->gtree->get_calculation_icon($element, $this->gpr);
1715
 
1716
            $showhideicon = $this->gtree->get_hiding_icon($element, $this->gpr);
1717
 
1718
            $lockunlockicon = $this->gtree->get_locking_icon($element, $this->gpr);
1719
        }
1720
 
1721
        $gradeanalysisicon = '';
1722
        if ($element['type'] == 'grade') {
1723
            $gradeanalysisicon .= $this->gtree->get_grade_analysis_icon($element['object']);
1724
        }
1725
 
1726
        return $OUTPUT->container($editicon.$editcalculationicon.$showhideicon.$lockunlockicon.$gradeanalysisicon, 'grade_icons');
1727
    }
1728
 
1729
    /**
1730
     * Given a category element returns collapsing +/- icon if available
1731
     *
1732
     * @deprecated since Moodle 2.9 MDL-46662 - please do not use this function any more.
1733
     */
1734
    protected function get_collapsing_icon($element) {
1735
        throw new coding_exception('get_collapsing_icon() can not be used any more, please use get_course_header() instead.');
1736
    }
1737
 
1738
    /**
1739
     * Processes a single action against a category, grade_item or grade.
1740
     * @param string $target eid ({type}{id}, e.g. c4 for category4)
1741
     * @param string $action Which action to take (edit, delete etc...)
1742
     * @return
1743
     */
1744
    public function process_action($target, $action) {
1745
        return self::do_process_action($target, $action, $this->course->id);
1746
    }
1747
 
1748
    /**
1749
     * From the list of categories that this user prefers to collapse choose ones that belong to the current course.
1750
     *
1751
     * This function serves two purposes.
1752
     * Mainly it helps migrating from user preference style when all courses were stored in one preference.
1753
     * Also it helps to remove the settings for categories that were removed if the array for one course grows too big.
1754
     *
1755
     * @param int $courseid
1756
     * @param array $collapsed
1757
     * @return array
1758
     */
1759
    protected static function filter_collapsed_categories($courseid, $collapsed) {
1760
        global $DB;
1761
        // Ensure we always have an element for aggregatesonly and another for gradesonly, no matter it's empty.
1762
        $collapsed['aggregatesonly'] = $collapsed['aggregatesonly'] ?? [];
1763
        $collapsed['gradesonly'] = $collapsed['gradesonly'] ?? [];
1764
 
1765
        if (empty($collapsed['aggregatesonly']) && empty($collapsed['gradesonly'])) {
1766
            return $collapsed;
1767
        }
1768
        $cats = $DB->get_fieldset_select('grade_categories', 'id', 'courseid = ?', array($courseid));
1769
        $collapsed['aggregatesonly'] = array_values(array_intersect($collapsed['aggregatesonly'], $cats));
1770
        $collapsed['gradesonly'] = array_values(array_intersect($collapsed['gradesonly'], $cats));
1771
        return $collapsed;
1772
    }
1773
 
1774
    /**
1775
     * Returns the list of categories that this user wants to collapse or display aggregatesonly
1776
     *
1777
     * This method also migrates on request from the old format of storing user preferences when they were stored
1778
     * in one preference for all courses causing DB error when trying to insert very big value.
1779
     *
1780
     * @param int $courseid
1781
     * @return array
1782
     */
1783
    protected static function get_collapsed_preferences($courseid) {
1784
        if ($collapsed = get_user_preferences('grade_report_grader_collapsed_categories'.$courseid)) {
1785
            $collapsed = json_decode($collapsed, true);
1786
            // Ensure we always have an element for aggregatesonly and another for gradesonly, no matter it's empty.
1787
            $collapsed['aggregatesonly'] = $collapsed['aggregatesonly'] ?? [];
1788
            $collapsed['gradesonly'] = $collapsed['gradesonly'] ?? [];
1789
            return $collapsed;
1790
        }
1791
 
1792
        // Try looking for old location of user setting that used to store all courses in one serialized user preference.
1793
        $collapsed = ['aggregatesonly' => [], 'gradesonly' => []]; // Use this if old settings are not found.
1794
        $collapsedall = [];
1795
        $oldprefexists = false;
1796
        if (($oldcollapsedpref = get_user_preferences('grade_report_grader_collapsed_categories')) !== null) {
1797
            $oldprefexists = true;
1798
            if ($collapsedall = unserialize_array($oldcollapsedpref)) {
1799
                // Ensure we always have an element for aggregatesonly and another for gradesonly, no matter it's empty.
1800
                $collapsedall['aggregatesonly'] = $collapsedall['aggregatesonly'] ?? [];
1801
                $collapsedall['gradesonly'] = $collapsedall['gradesonly'] ?? [];
1802
                // We found the old-style preference, filter out only categories that belong to this course and update the prefs.
1803
                $collapsed = static::filter_collapsed_categories($courseid, $collapsedall);
1804
                if (!empty($collapsed['aggregatesonly']) || !empty($collapsed['gradesonly'])) {
1805
                    static::set_collapsed_preferences($courseid, $collapsed);
1806
                    $collapsedall['aggregatesonly'] = array_diff($collapsedall['aggregatesonly'], $collapsed['aggregatesonly']);
1807
                    $collapsedall['gradesonly'] = array_diff($collapsedall['gradesonly'], $collapsed['gradesonly']);
1808
                    if (!empty($collapsedall['aggregatesonly']) || !empty($collapsedall['gradesonly'])) {
1809
                        set_user_preference('grade_report_grader_collapsed_categories', serialize($collapsedall));
1810
                    }
1811
                }
1812
            }
1813
        }
1814
 
1815
        // Arrived here, if the old pref exists and it doesn't contain
1816
        // more information, it means that the migration of all the
1817
        // data to new, by course, preferences is completed, so
1818
        // the old one can be safely deleted.
1819
        if ($oldprefexists &&
1820
                empty($collapsedall['aggregatesonly']) &&
1821
                empty($collapsedall['gradesonly'])) {
1822
            unset_user_preference('grade_report_grader_collapsed_categories');
1823
        }
1824
 
1825
        return $collapsed;
1826
    }
1827
 
1828
    /**
1829
     * Sets the list of categories that user wants to see collapsed in user preferences
1830
     *
1831
     * This method may filter or even trim the list if it does not fit in DB field.
1832
     *
1833
     * @param int $courseid
1834
     * @param array $collapsed
1835
     */
1836
    protected static function set_collapsed_preferences($courseid, $collapsed) {
1837
        global $DB;
1838
        // In an unlikely case that the list of collapsed categories for one course is too big for the user preference size,
1839
        // try to filter the list of categories since array may contain categories that were deleted.
1840
        if (strlen(json_encode($collapsed)) >= 1333) {
1841
            $collapsed = static::filter_collapsed_categories($courseid, $collapsed);
1842
        }
1843
 
1844
        // If this did not help, "forget" about some of the collapsed categories. Still better than to loose all information.
1845
        while (strlen(json_encode($collapsed)) >= 1333) {
1846
            if (count($collapsed['aggregatesonly'])) {
1847
                array_pop($collapsed['aggregatesonly']);
1848
            }
1849
            if (count($collapsed['gradesonly'])) {
1850
                array_pop($collapsed['gradesonly']);
1851
            }
1852
        }
1853
 
1854
        if (!empty($collapsed['aggregatesonly']) || !empty($collapsed['gradesonly'])) {
1855
            set_user_preference('grade_report_grader_collapsed_categories'.$courseid, json_encode($collapsed));
1856
        } else {
1857
            unset_user_preference('grade_report_grader_collapsed_categories'.$courseid);
1858
        }
1859
    }
1860
 
1861
    /**
1862
     * Processes a single action against a category, grade_item or grade.
1863
     * @param string $target eid ({type}{id}, e.g. c4 for category4)
1864
     * @param string $action Which action to take (edit, delete etc...)
1865
     * @param int $courseid affected course.
1866
     * @return
1867
     */
1868
    public static function do_process_action($target, $action, $courseid = null) {
1869
        global $DB;
1870
        // TODO: this code should be in some grade_tree static method
1871
        $targettype = substr($target, 0, 2);
1872
        $targetid = substr($target, 2);
1873
        // TODO: end
1874
 
1875
        if ($targettype !== 'cg') {
1876
            // The following code only works with categories.
1877
            return true;
1878
        }
1879
 
1880
        if (!$courseid) {
1881
            debugging('Function grade_report_grader::do_process_action() now requires additional argument courseid',
1882
                DEBUG_DEVELOPER);
1883
            if (!$courseid = $DB->get_field('grade_categories', 'courseid', array('id' => $targetid), IGNORE_MISSING)) {
1884
                return true;
1885
            }
1886
        }
1887
 
1888
        $collapsed = static::get_collapsed_preferences($courseid);
1889
 
1890
        switch ($action) {
1891
            case 'switch_minus': // Add category to array of aggregatesonly
1892
                $key = array_search($targetid, $collapsed['gradesonly']);
1893
                if ($key !== false) {
1894
                    unset($collapsed['gradesonly'][$key]);
1895
                }
1896
                if (!in_array($targetid, $collapsed['aggregatesonly'])) {
1897
                    $collapsed['aggregatesonly'][] = $targetid;
1898
                    static::set_collapsed_preferences($courseid, $collapsed);
1899
                }
1900
                break;
1901
 
1902
            case 'switch_plus': // Remove category from array of aggregatesonly, and add it to array of gradesonly
1903
                $key = array_search($targetid, $collapsed['aggregatesonly']);
1904
                if ($key !== false) {
1905
                    unset($collapsed['aggregatesonly'][$key]);
1906
                }
1907
                if (!in_array($targetid, $collapsed['gradesonly'])) {
1908
                    $collapsed['gradesonly'][] = $targetid;
1909
                }
1910
                static::set_collapsed_preferences($courseid, $collapsed);
1911
                break;
1912
            case 'switch_whole': // Remove the category from the array of collapsed cats
1913
                $key = array_search($targetid, $collapsed['gradesonly']);
1914
                if ($key !== false) {
1915
                    unset($collapsed['gradesonly'][$key]);
1916
                    static::set_collapsed_preferences($courseid, $collapsed);
1917
                }
1918
 
1919
                $key = array_search($targetid, $collapsed['aggregatesonly']);
1920
                if ($key !== false) {
1921
                    unset($collapsed['aggregatesonly'][$key]);
1922
                    static::set_collapsed_preferences($courseid, $collapsed);
1923
                }
1924
                break;
1925
            default:
1926
                break;
1927
        }
1928
 
1929
        return true;
1930
    }
1931
 
1932
    /**
1933
     * Refactored function for generating HTML of sorting links with matching arrows.
1934
     * Returns an array with 'studentname' and 'idnumber' as keys, with HTML ready
1935
     * to inject into a table header cell.
1936
     * @param array $extrafields Array of extra fields being displayed, such as
1937
     *   user idnumber
1938
     * @return array An associative array of HTML sorting links+arrows
1939
     */
1940
    public function get_sort_arrows(array $extrafields = []) {
1941
        global $CFG;
1942
        $arrows = array();
1943
        $sortlink = clone($this->baseurl);
1944
 
1945
        // Sourced from tablelib.php
1946
        // Check the full name display for sortable fields.
1947
        if (has_capability('moodle/site:viewfullnames', $this->context)) {
1948
            $nameformat = $CFG->alternativefullnameformat;
1949
        } else {
1950
            $nameformat = $CFG->fullnamedisplay;
1951
        }
1952
 
1953
        if ($nameformat == 'language') {
1954
            $nameformat = get_string('fullnamedisplay');
1955
        }
1956
 
1957
        $arrows['studentname'] = '';
1958
        $requirednames = order_in_string(\core_user\fields::get_name_fields(), $nameformat);
1959
        if (!empty($requirednames)) {
1960
            foreach ($requirednames as $name) {
1961
                $arrows['studentname'] .= get_string($name);
1962
                if ($this->sortitemid == $name) {
1963
                    $sortlink->param('sortitemid', $name);
1964
                    if ($this->sortorder == 'ASC') {
1965
                        $sorticon = $this->get_sort_arrow('down', $sortlink);
1966
                    } else {
1967
                        $sorticon = $this->get_sort_arrow('up', $sortlink);
1968
                    }
1969
                    $arrows['studentname'] .= $sorticon;
1970
                }
1971
                $arrows['studentname'] .= ' / ';
1972
            }
1973
 
1974
            $arrows['studentname'] = substr($arrows['studentname'], 0, -3);
1975
        }
1976
 
1977
        foreach ($extrafields as $field) {
1978
            $attributes = [
1979
                'data-collapse' => 'content'
1980
            ];
1981
            // With additional user profile fields, we can't grab the name via WS, so conditionally add it to rip out of the DOM.
1982
            if (preg_match(\core_user\fields::PROFILE_FIELD_REGEX, $field)) {
1983
                $attributes['data-collapse-name'] = \core_user\fields::get_display_name($field);
1984
            }
1985
 
1986
            $arrows[$field] = html_writer::span(\core_user\fields::get_display_name($field), '', $attributes);
1987
            if ($field == $this->sortitemid) {
1988
                $sortlink->param('sortitemid', $field);
1989
 
1990
                if ($this->sortorder == 'ASC') {
1991
                    $sorticon = $this->get_sort_arrow('down', $sortlink);
1992
                } else {
1993
                    $sorticon = $this->get_sort_arrow('up', $sortlink);
1994
                }
1995
                $arrows[$field] .= $sorticon;
1996
            }
1997
        }
1998
 
1999
        return $arrows;
2000
    }
2001
 
2002
    /**
2003
     * Returns the maximum number of students to be displayed on each page
2004
     *
2005
     * @return int The maximum number of students to display per page
2006
     */
2007
    public function get_students_per_page(): int {
2008
        // Default to the lowest available option.
2009
        return (int) get_user_preferences('grade_report_studentsperpage', min(static::PAGINATION_OPTIONS));
2010
    }
2011
 
2012
    /**
2013
     * Returns link to change category view mode.
2014
     *
2015
     * @param moodle_url $url Url to grader report page
2016
     * @param string $title Menu item title
2017
     * @param string $action View mode to change to
2018
     * @param bool $active Whether link is active in dropdown
2019
     *
2020
     * @return string|null
2021
     */
2022
    public function get_category_view_mode_link(moodle_url $url, string $title, string $action, bool $active = false): ?string {
2023
        $urlnew = $url;
2024
        $urlnew->param('action', $action);
2025
        $active = $active ? 'true' : 'false';
2026
        return html_writer::link($urlnew, $title,
2027
            ['class' => 'dropdown-item', 'aria-label' => $title, 'aria-current' => $active, 'role' => 'menuitem']);
2028
    }
2029
 
2030
    /**
2031
     * Return the link to allow the field to collapse from the users view.
2032
     *
2033
     * @return string Dropdown menu link that'll trigger the collapsing functionality.
2034
     * @throws coding_exception
2035
     * @throws moodle_exception
2036
     */
2037
    public function get_hide_show_link(): string {
2038
        $link = new moodle_url('#', []);
2039
        return html_writer::link(
2040
            $link->out(false),
2041
            get_string('collapse'),
2042
            ['class' => 'dropdown-item', 'data-hider' => 'hide', 'aria-label' => get_string('collapse'), 'role' => 'menuitem'],
2043
        );
2044
    }
2045
 
2046
    /**
2047
     * Return the base report link with some default sorting applied.
2048
     *
2049
     * @return string
2050
     * @throws moodle_exception
2051
     */
2052
    public function get_default_sortable(): string {
2053
        $sortlink = new moodle_url('/grade/report/grader/index.php', [
2054
            'id' => $this->courseid,
2055
            'sortitemid' => 'firstname',
2056
            'sort' => 'asc'
2057
        ]);
2058
        $this->gpr->add_url_params($sortlink);
2059
        return $sortlink->out(false);
2060
    }
2061
 
2062
    /**
2063
     * Return class used for text alignment.
2064
     *
2065
     * @param grade_item $item Can be grade item or grade
2066
     * @return string class name used for text alignment
2067
     */
2068
    public function get_cell_display_class(grade_item $item): string {
2069
        global $USER;
2070
 
2071
        $gradetypeclass = '';
2072
        if (!empty($USER->editing)) {
2073
            switch ($item->gradetype) {
2074
                case GRADE_TYPE_SCALE:
2075
                    $gradetypeclass = ' grade_type_scale';
2076
                    break;
2077
                case GRADE_TYPE_VALUE:
2078
                    $gradetypeclass = ' grade_type_value';
2079
                    break;
2080
                case GRADE_TYPE_TEXT:
2081
                    $gradetypeclass = ' grade_type_text';
2082
                    break;
2083
            }
2084
        } else {
2085
            $gradedisplaytype = $item->get_displaytype();
2086
 
2087
            // Letter grades, scales and text grades are left aligned.
2088
            $textgrade = false;
2089
            $textgrades = [GRADE_DISPLAY_TYPE_LETTER,
2090
                GRADE_DISPLAY_TYPE_REAL_LETTER,
2091
                GRADE_DISPLAY_TYPE_LETTER_REAL,
2092
                GRADE_DISPLAY_TYPE_LETTER_PERCENTAGE,
2093
                GRADE_DISPLAY_TYPE_PERCENTAGE_LETTER];
2094
            if (in_array($gradedisplaytype, $textgrades)) {
2095
                $textgrade = true;
2096
            }
2097
 
2098
            $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'gradereport_grader', 'scales');
2099
            $scalesarray = $cache->get(get_class($this));
2100
 
2101
            if ($textgrade || ($item->gradetype == GRADE_TYPE_TEXT)) {
2102
                $gradetypeclass = ' grade_type_text';
2103
            } else if ($item->scaleid && !empty($scalesarray[$item->scaleid])) {
2104
                if ($gradedisplaytype == GRADE_DISPLAY_TYPE_PERCENTAGE) {
2105
                    $gradetypeclass = ' grade_type_value';
2106
                } else {
2107
                    $gradetypeclass = ' grade_type_scale';
2108
                }
2109
            } else {
2110
                $gradetypeclass = ' grade_type_value';
2111
            }
2112
        }
2113
        return $gradetypeclass;
2114
    }
2115
}
2116
 
2117
/**
2118
 * Adds report specific context variable
2119
 *
2120
 * @param context_course $context Course context
2121
 * @param int $courseid Course ID
2122
 * @param array  $element An array representing an element in the grade_tree
2123
 * @param grade_plugin_return $gpr A grade_plugin_return object
2124
 * @param string $mode Not used
2125
 * @param stdClass|null $templatecontext Template context
2126
 * @return stdClass|null
2127
 */
2128
function gradereport_grader_get_report_link(context_course $context, int $courseid,
2129
        array $element, grade_plugin_return $gpr, string $mode, ?stdClass $templatecontext): ?stdClass {
2130
 
2131
    static $report = null;
2132
    if (!$report) {
2133
        $report = new grade_report_grader($courseid, $gpr, $context);
2134
    }
2135
 
2136
    if ($mode == 'category') {
2137
        if (!isset($templatecontext)) {
2138
            $templatecontext = new stdClass();
2139
        }
2140
 
2141
        $categoryid = $element['object']->id;
2142
 
2143
        // Load language strings.
2144
        $strswitchminus = get_string('aggregatesonly', 'grades');
2145
        $strswitchplus = get_string('gradesonly', 'grades');
2146
        $strswitchwhole = get_string('fullmode', 'grades');
2147
 
2148
        $url = new moodle_url($gpr->get_return_url(null, ['target' => $element['eid'], 'sesskey' => sesskey()]));
2149
 
2150
        $gradesonly = false;
2151
        $aggregatesonly = false;
2152
        $fullmode = false;
2153
        if (in_array($categoryid, $report->collapsed['gradesonly'])) {
2154
            $gradesonly = true;
2155
        } else if (in_array($categoryid, $report->collapsed['aggregatesonly'])) {
2156
            $aggregatesonly = true;
2157
        } else {
2158
            $fullmode = true;
2159
        }
2160
        $templatecontext->gradesonlyurl =
2161
            $report->get_category_view_mode_link($url, $strswitchplus, 'switch_plus', $gradesonly);
2162
        $templatecontext->aggregatesonlyurl =
2163
            $report->get_category_view_mode_link($url, $strswitchminus, 'switch_minus', $aggregatesonly);
2164
        $templatecontext->fullmodeurl =
2165
            $report->get_category_view_mode_link($url, $strswitchwhole, 'switch_whole', $fullmode);
2166
        return $templatecontext;
2167
    } else if ($mode == 'gradeitem') {
2168
        if (($element['type'] == 'userfield') && ($element['name'] !== 'fullname')) {
2169
            $templatecontext->columncollapse = $report->get_hide_show_link();
2170
            $templatecontext->dataid = $element['name'];
2171
        }
2172
 
2173
        // We do not want grade category total items to be hidden away as it is controlled by something else.
2174
        if (isset($element['object']->id) && !$element['object']->is_aggregate_item()) {
2175
            $templatecontext->columncollapse = $report->get_hide_show_link();
2176
        }
2177
        return $templatecontext;
2178
    }
2179
    return null;
2180
}