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 a class to represent a grade category
19
 *
20
 * @package   core_grades
21
 * @copyright 2006 Nicolas Connault
22
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
defined('MOODLE_INTERNAL') || die();
26
 
27
require_once(__DIR__ . '/grade_object.php');
28
 
29
/**
30
 * grade_category is an object mapped to DB table {prefix}grade_categories
31
 *
32
 * @package   core_grades
33
 * @category  grade
34
 * @copyright 2007 Nicolas Connault
35
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36
 */
37
class grade_category extends grade_object {
38
    /**
39
     * The DB table.
40
     * @var string $table
41
     */
42
    public $table = 'grade_categories';
43
 
44
    /**
45
     * Array of required table fields, must start with 'id'.
46
     * @var array $required_fields
47
     */
48
    public $required_fields = array('id', 'courseid', 'parent', 'depth', 'path', 'fullname', 'aggregation',
49
                                 'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes',
50
                                 'timecreated', 'timemodified', 'hidden');
51
 
52
    /**
53
     * The course this category belongs to.
54
     * @var int $courseid
55
     */
56
    public $courseid;
57
 
58
    /**
59
     * The category this category belongs to (optional).
60
     * @var int $parent
61
     */
62
    public $parent;
63
 
64
    /**
65
     * The grade_category object referenced by $this->parent (PK).
66
     * @var grade_category $parent_category
67
     */
68
    public $parent_category;
69
 
70
    /**
71
     * The number of parents this category has.
72
     * @var int $depth
73
     */
74
    public $depth = 0;
75
 
76
    /**
77
     * Shows the hierarchical path for this category as /1/2/3/ (like course_categories), the last number being
78
     * this category's autoincrement ID number.
79
     * @var string $path
80
     */
81
    public $path;
82
 
83
    /**
84
     * The name of this category.
85
     * @var string $fullname
86
     */
87
    public $fullname;
88
 
89
    /**
90
     * A constant pointing to one of the predefined aggregation strategies (none, mean, median, sum etc) .
91
     * @var int $aggregation
92
     */
93
    public $aggregation = GRADE_AGGREGATE_SUM;
94
 
95
    /**
96
     * Keep only the X highest items.
97
     * @var int $keephigh
98
     */
99
    public $keephigh = 0;
100
 
101
    /**
102
     * Drop the X lowest items.
103
     * @var int $droplow
104
     */
105
    public $droplow = 0;
106
 
107
    /**
108
     * Aggregate only graded items
109
     * @var int $aggregateonlygraded
110
     */
111
    public $aggregateonlygraded = 0;
112
 
113
    /**
114
     * Aggregate outcomes together with normal items
115
     * @var int $aggregateoutcomes
116
     */
117
    public $aggregateoutcomes = 0;
118
 
119
    /**
120
     * Array of grade_items or grade_categories nested exactly 1 level below this category
121
     * @var array $children
122
     */
123
    public $children;
124
 
125
    /**
126
     * A hierarchical array of all children below this category. This is stored separately from
127
     * $children because it is more memory-intensive and may not be used as often.
128
     * @var array $all_children
129
     */
130
    public $all_children;
131
 
132
    /**
133
     * An associated grade_item object, with itemtype=category, used to calculate and cache a set of grade values
134
     * for this category.
135
     * @var grade_item $grade_item
136
     */
137
    public $grade_item;
138
 
139
    /**
140
     * Temporary sortorder for speedup of children resorting
141
     * @var int $sortorder
142
     */
143
    public $sortorder;
144
 
145
    /**
146
     * List of options which can be "forced" from site settings.
147
     * @var array $forceable
148
     */
149
    public $forceable = array('aggregation', 'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes');
150
 
151
    /**
152
     * String representing the aggregation coefficient. Variable is used as cache.
153
     * @var string $coefstring
154
     */
155
    public $coefstring = null;
156
 
157
    /**
158
     * Static variable storing the result from {@link self::can_apply_limit_rules}.
159
     * @var bool
160
     */
161
    protected $canapplylimitrules;
162
 
163
    /**
164
     * e.g. 'category', 'course' and 'mod', 'blocks', 'import', etc...
165
     * @var string $itemtype
166
     */
167
    public $itemtype;
168
 
169
    /**
170
     * Builds this category's path string based on its parents (if any) and its own id number.
171
     * This is typically done just before inserting this object in the DB for the first time,
172
     * or when a new parent is added or changed. It is a recursive function: once the calling
173
     * object no longer has a parent, the path is complete.
174
     *
175
     * @param grade_category $grade_category A Grade_Category object
176
     * @return string The category's path string
177
     */
178
    public static function build_path($grade_category) {
179
        global $DB;
180
 
181
        if (empty($grade_category->parent)) {
182
            return '/'.$grade_category->id.'/';
183
 
184
        } else {
185
            $parent = $DB->get_record('grade_categories', array('id' => $grade_category->parent));
186
            return grade_category::build_path($parent).$grade_category->id.'/';
187
        }
188
    }
189
 
190
    /**
191
     * Finds and returns a grade_category instance based on params.
192
     *
193
     * @param array $params associative arrays varname=>value
194
     * @return grade_category The retrieved grade_category instance or false if none found.
195
     */
196
    public static function fetch($params) {
197
        if ($records = self::retrieve_record_set($params)) {
198
            return reset($records);
199
        }
200
 
201
        $record = grade_object::fetch_helper('grade_categories', 'grade_category', $params);
202
 
203
        // We store it as an array to keep a key => result set interface in the cache, grade_object::fetch_helper is
204
        // managing exceptions. We return only the first element though.
205
        $records = false;
206
        if ($record) {
207
            $records = array($record->id => $record);
208
        }
209
 
210
        self::set_record_set($params, $records);
211
 
212
        return $record;
213
    }
214
 
215
    /**
216
     * Finds and returns all grade_category instances based on params.
217
     *
218
     * @param array $params associative arrays varname=>value
219
     * @return array array of grade_category insatnces or false if none found.
220
     */
221
    public static function fetch_all($params) {
222
        if ($records = self::retrieve_record_set($params)) {
223
            return $records;
224
        }
225
 
226
        $records = grade_object::fetch_all_helper('grade_categories', 'grade_category', $params);
227
        self::set_record_set($params, $records);
228
 
229
        return $records;
230
    }
231
 
232
    /**
233
     * In addition to update() as defined in grade_object, call force_regrading of parent categories, if applicable.
234
     *
235
     * @param string $source from where was the object updated (mod/forum, manual, etc.)
236
     * @param bool $isbulkupdate If bulk grade update is happening.
237
     * @return bool success
238
     */
239
    public function update($source = null, $isbulkupdate = false) {
240
        // load the grade item or create a new one
241
        $this->load_grade_item();
242
 
243
        // force recalculation of path;
244
        if (empty($this->path)) {
245
            $this->path  = grade_category::build_path($this);
246
            $this->depth = substr_count($this->path, '/') - 1;
247
            $updatechildren = true;
248
 
249
        } else {
250
            $updatechildren = false;
251
        }
252
 
253
        $this->apply_forced_settings();
254
 
255
        // these are exclusive
256
        if ($this->droplow > 0) {
257
            $this->keephigh = 0;
258
 
259
        } else if ($this->keephigh > 0) {
260
            $this->droplow = 0;
261
        }
262
 
263
        // Recalculate grades if needed
264
        if ($this->qualifies_for_regrading()) {
265
            $this->force_regrading();
266
        }
267
 
268
        $this->timemodified = time();
269
 
270
        $result = parent::update($source);
271
 
272
        // now update paths in all child categories
273
        if ($result and $updatechildren) {
274
 
275
            if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
276
 
277
                foreach ($children as $child) {
278
                    $child->path  = null;
279
                    $child->depth = 0;
280
                    $child->update($source);
281
                }
282
            }
283
        }
284
 
285
        return $result;
286
    }
287
 
288
    /**
289
     * If parent::delete() is successful, send force_regrading message to parent category.
290
     *
291
     * @param string $source from where was the object deleted (mod/forum, manual, etc.)
292
     * @return bool success
293
     */
294
    public function delete($source=null) {
295
        global $DB;
296
 
297
        try {
298
            $transaction = $DB->start_delegated_transaction();
299
            $grade_item = $this->load_grade_item();
300
 
301
            if ($this->is_course_category()) {
302
 
303
                if ($categories = self::fetch_all(['courseid' => $this->courseid])) {
304
 
305
                    foreach ($categories as $category) {
306
 
307
                        if ($category->id == $this->id) {
308
                            continue; // Do not delete course category yet.
309
                        }
310
                        $category->delete($source);
311
                    }
312
                }
313
 
314
                if ($items = grade_item::fetch_all(['courseid' => $this->courseid])) {
315
 
316
                    foreach ($items as $item) {
317
 
318
                        if ($item->id == $grade_item->id) {
319
                            continue; // Do not delete course item yet.
320
                        }
321
                        $item->delete($source);
322
                    }
323
                }
324
 
325
            } else {
326
                $this->force_regrading();
327
 
328
                $parent = $this->load_parent_category();
329
 
330
                // Update children's categoryid/parent field first.
331
                if ($children = grade_item::fetch_all(['categoryid' => $this->id])) {
332
                    foreach ($children as $child) {
333
                        $child->set_parent($parent->id);
334
                    }
335
                }
336
 
337
                if ($children = self::fetch_all(['parent' => $this->id])) {
338
                    foreach ($children as $child) {
339
                        $child->set_parent($parent->id);
340
                    }
341
                }
342
            }
343
 
344
            // First delete the attached grade item and grades.
345
            $grade_item->delete($source);
346
 
347
            // Delete category itself.
348
            $success = parent::delete($source);
349
 
350
            $transaction->allow_commit();
351
        } catch (Exception $e) {
352
            $transaction->rollback($e);
353
        }
354
        return $success;
355
    }
356
 
357
    /**
358
     * In addition to the normal insert() defined in grade_object, this method sets the depth
359
     * and path for this object, and update the record accordingly.
360
     *
361
     * We do this here instead of in the constructor as they both need to know the record's
362
     * ID number, which only gets created at insertion time.
363
     * This method also creates an associated grade_item if this wasn't done during construction.
364
     *
365
     * @param string $source from where was the object inserted (mod/forum, manual, etc.)
366
     * @param bool $isbulkupdate If bulk grade update is happening.
367
     * @return int PK ID if successful, false otherwise
368
     */
369
    public function insert($source = null, $isbulkupdate = false) {
370
 
371
        if (empty($this->courseid)) {
372
            throw new \moodle_exception('cannotinsertgrade');
373
        }
374
 
375
        if (empty($this->parent)) {
376
            $course_category = grade_category::fetch_course_category($this->courseid);
377
            $this->parent = $course_category->id;
378
        }
379
 
380
        $this->path = null;
381
 
382
        $this->timecreated = $this->timemodified = time();
383
 
384
        if (!parent::insert($source)) {
385
            debugging("Could not insert this category: " . print_r($this, true));
386
            return false;
387
        }
388
 
389
        $this->force_regrading();
390
 
391
        // build path and depth
392
        $this->update($source);
393
 
394
        return $this->id;
395
    }
396
 
397
    /**
398
     * Internal function - used only from fetch_course_category()
399
     * Normal insert() can not be used for course category
400
     *
401
     * @param int $courseid The course ID
402
     * @return int The ID of the new course category
403
     */
404
    public function insert_course_category($courseid) {
405
        $this->courseid    = $courseid;
406
        $this->fullname    = '?';
407
        $this->path        = null;
408
        $this->parent      = null;
409
        $this->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
410
 
411
        $this->apply_default_settings();
412
        $this->apply_forced_settings();
413
 
414
        $this->timecreated = $this->timemodified = time();
415
 
416
        if (!parent::insert('system')) {
417
            debugging("Could not insert this category: " . print_r($this, true));
418
            return false;
419
        }
420
 
421
        // build path and depth
422
        $this->update('system');
423
 
424
        return $this->id;
425
    }
426
 
427
    /**
428
     * Compares the values held by this object with those of the matching record in DB, and returns
429
     * whether or not these differences are sufficient to justify an update of all parent objects.
430
     * This assumes that this object has an ID number and a matching record in DB. If not, it will return false.
431
     *
432
     * @return bool
433
     */
434
    public function qualifies_for_regrading() {
435
        if (empty($this->id)) {
436
            debugging("Can not regrade non existing category");
437
            return false;
438
        }
439
 
440
        $db_item = grade_category::fetch(array('id'=>$this->id));
441
 
442
        $aggregationdiff = $db_item->aggregation         != $this->aggregation;
443
        $keephighdiff    = $db_item->keephigh            != $this->keephigh;
444
        $droplowdiff     = $db_item->droplow             != $this->droplow;
445
        $aggonlygrddiff  = $db_item->aggregateonlygraded != $this->aggregateonlygraded;
446
        $aggoutcomesdiff = $db_item->aggregateoutcomes   != $this->aggregateoutcomes;
447
 
448
        return ($aggregationdiff || $keephighdiff || $droplowdiff || $aggonlygrddiff || $aggoutcomesdiff);
449
    }
450
 
451
    /**
452
     * Marks this grade categories' associated grade item as needing regrading
453
     */
454
    public function force_regrading() {
455
        $grade_item = $this->load_grade_item();
456
        $grade_item->force_regrading();
457
    }
458
 
459
    /**
460
     * Something that should be called before we start regrading the whole course.
461
     *
462
     * @return void
463
     */
464
    public function pre_regrade_final_grades() {
465
        $this->auto_update_weights();
466
        $this->auto_update_max();
467
    }
468
 
469
    /**
470
     * Generates and saves final grades in associated category grade item.
471
     * These immediate children must already have their own final grades.
472
     * The category's aggregation method is used to generate final grades.
473
     *
474
     * Please note that category grade is either calculated or aggregated, not both at the same time.
475
     *
476
     * This method must be used ONLY from grade_item::regrade_final_grades(),
477
     * because the calculation must be done in correct order!
478
     *
479
     * Steps to follow:
480
     *  1. Get final grades from immediate children
481
     *  3. Aggregate these grades
482
     *  4. Save them in final grades of associated category grade item
483
     *
484
     * @param int $userid The user ID if final grade generation should be limited to a single user
485
     * @param \core\progress\base|null $progress Optional progress indicator
486
     * @return bool
487
     */
488
    public function generate_grades($userid=null, ?\core\progress\base $progress = null) {
489
        global $CFG, $DB;
490
 
491
        $this->load_grade_item();
492
 
493
        if ($this->grade_item->is_locked()) {
494
            return true; // no need to recalculate locked items
495
        }
496
 
497
        // find grade items of immediate children (category or grade items) and force site settings
498
        $depends_on = $this->grade_item->depends_on();
499
 
500
        if (empty($depends_on)) {
501
            $items = false;
502
 
503
        } else {
504
            list($usql, $params) = $DB->get_in_or_equal($depends_on);
505
            $sql = "SELECT *
506
                      FROM {grade_items}
507
                     WHERE id $usql";
508
            $items = $DB->get_records_sql($sql, $params);
509
            foreach ($items as $id => $item) {
510
                $items[$id] = new grade_item($item, false);
511
            }
512
        }
513
 
514
        $grade_inst = new grade_grade();
515
        $fields = 'g.'.implode(',g.', $grade_inst->required_fields);
516
 
517
        // where to look for final grades - include grade of this item too, we will store the results there
518
        $gis = array_merge($depends_on, array($this->grade_item->id));
519
        list($usql, $params) = $DB->get_in_or_equal($gis);
520
 
521
        if ($userid) {
522
            $usersql = "AND g.userid=?";
523
            $params[] = $userid;
524
 
525
        } else {
526
            $usersql = "";
527
        }
528
 
529
        $sql = "SELECT $fields
530
                  FROM {grade_grades} g, {grade_items} gi
531
                 WHERE gi.id = g.itemid AND gi.id $usql $usersql
532
              ORDER BY g.userid";
533
 
534
        // group the results by userid and aggregate the grades for this user
535
        $rs = $DB->get_recordset_sql($sql, $params);
536
        if ($rs->valid()) {
537
            $prevuser = 0;
538
            $grade_values = array();
539
            $excluded     = array();
540
            $oldgrade     = null;
541
            $grademaxoverrides = array();
542
            $grademinoverrides = array();
543
 
544
            foreach ($rs as $used) {
545
                $grade = new grade_grade($used, false);
546
                if (isset($items[$grade->itemid])) {
547
                    // Prevent grade item to be fetched from DB.
548
                    $grade->grade_item =& $items[$grade->itemid];
549
                } else if ($grade->itemid == $this->grade_item->id) {
550
                    // This grade's grade item is not in $items.
551
                    $grade->grade_item =& $this->grade_item;
552
                }
553
                if ($grade->userid != $prevuser) {
554
                    $this->aggregate_grades($prevuser,
555
                                            $items,
556
                                            $grade_values,
557
                                            $oldgrade,
558
                                            $excluded,
559
                                            $grademinoverrides,
560
                                            $grademaxoverrides);
561
                    $prevuser = $grade->userid;
562
                    $grade_values = array();
563
                    $excluded     = array();
564
                    $oldgrade     = null;
565
                    $grademaxoverrides = array();
566
                    $grademinoverrides = array();
567
                }
568
                $grade_values[$grade->itemid] = $grade->finalgrade;
569
                $grademaxoverrides[$grade->itemid] = $grade->get_grade_max();
570
                $grademinoverrides[$grade->itemid] = $grade->get_grade_min();
571
 
572
                if ($grade->excluded) {
573
                    $excluded[] = $grade->itemid;
574
                }
575
 
576
                if ($this->grade_item->id == $grade->itemid) {
577
                    $oldgrade = $grade;
578
                }
579
 
580
                if ($progress) {
581
                    // Incrementing the progress by nothing causes it to send an update (once per second)
582
                    // to the web browser so as to prevent the connection timing out.
583
                    $progress->increment_progress(0);
584
                }
585
            }
586
            $this->aggregate_grades($prevuser,
587
                                    $items,
588
                                    $grade_values,
589
                                    $oldgrade,
590
                                    $excluded,
591
                                    $grademinoverrides,
592
                                    $grademaxoverrides);//the last one
593
        }
594
        $rs->close();
595
 
596
        return true;
597
    }
598
 
599
    /**
600
     * Internal function for grade category grade aggregation
601
     *
602
     * @param int    $userid The User ID
603
     * @param array  $items Grade items
604
     * @param array  $grade_values Array of grade values
605
     * @param object $oldgrade Old grade
606
     * @param array  $excluded Excluded
607
     * @param array  $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
608
     * @param array  $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
609
     */
610
    private function aggregate_grades($userid,
611
                                      $items,
612
                                      $grade_values,
613
                                      $oldgrade,
614
                                      $excluded,
615
                                      $grademinoverrides,
616
                                      $grademaxoverrides) {
617
        global $CFG, $DB;
618
 
619
        // Remember these so we can set flags on them to describe how they were used in the aggregation.
620
        $novalue = array();
621
        $dropped = array();
622
        $extracredit = array();
623
        $usedweights = array();
624
 
625
        if (empty($userid)) {
626
            //ignore first call
627
            return;
628
        }
629
 
630
        if ($oldgrade) {
631
            $oldfinalgrade = $oldgrade->finalgrade;
632
            $grade = new grade_grade($oldgrade, false);
633
            $grade->grade_item =& $this->grade_item;
634
 
635
        } else {
636
            // insert final grade - it will be needed later anyway
637
            $grade = new grade_grade(array('itemid'=>$this->grade_item->id, 'userid'=>$userid), false);
638
            $grade->grade_item =& $this->grade_item;
639
            $grade->insert('system');
640
            $oldfinalgrade = null;
641
        }
642
 
643
        // no need to recalculate locked or overridden grades
644
        if ($grade->is_locked() or $grade->is_overridden()) {
645
            return;
646
        }
647
 
648
        // can not use own final category grade in calculation
649
        unset($grade_values[$this->grade_item->id]);
650
 
651
        // Make sure a grade_grade exists for every grade_item.
652
        // We need to do this so we can set the aggregationstatus
653
        // with a set_field call instead of checking if each one exists and creating/updating.
654
        if (!empty($items)) {
655
            list($ggsql, $params) = $DB->get_in_or_equal(array_keys($items), SQL_PARAMS_NAMED, 'g');
656
 
657
 
658
            $params['userid'] = $userid;
659
            $sql = "SELECT itemid
660
                      FROM {grade_grades}
661
                     WHERE itemid $ggsql AND userid = :userid";
662
            $existingitems = $DB->get_records_sql($sql, $params);
663
 
664
            $notexisting = array_diff(array_keys($items), array_keys($existingitems));
665
            foreach ($notexisting as $itemid) {
666
                $gradeitem = $items[$itemid];
667
                $gradegrade = new grade_grade(array('itemid' => $itemid,
668
                                                    'userid' => $userid,
669
                                                    'rawgrademin' => $gradeitem->grademin,
670
                                                    'rawgrademax' => $gradeitem->grademax), false);
671
                $gradegrade->grade_item = $gradeitem;
672
                $gradegrade->insert('system');
673
            }
674
        }
675
 
676
        // if no grades calculation possible or grading not allowed clear final grade
677
        if (empty($grade_values) or empty($items) or ($this->grade_item->gradetype != GRADE_TYPE_VALUE and $this->grade_item->gradetype != GRADE_TYPE_SCALE)) {
678
            $grade->finalgrade = null;
679
 
680
            if (!is_null($oldfinalgrade)) {
681
                $grade->timemodified = time();
682
                $success = $grade->update('aggregation');
683
 
684
                // If successful trigger a user_graded event.
685
                if ($success) {
686
                    \core\event\user_graded::create_from_grade($grade, \core\event\base::USER_OTHER)->trigger();
687
                }
688
            }
689
            $dropped = $grade_values;
690
            $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
691
            return;
692
        }
693
 
694
        // Normalize the grades first - all will have value 0...1
695
        // ungraded items are not used in aggregation.
696
        foreach ($grade_values as $itemid=>$v) {
697
            if (is_null($v)) {
698
                // If null, it means no grade.
699
                if ($this->aggregateonlygraded) {
700
                    unset($grade_values[$itemid]);
701
                    // Mark this item as "excluded empty" because it has no grade.
702
                    $novalue[$itemid] = 0;
703
                    continue;
704
                }
705
            }
706
            if (in_array($itemid, $excluded)) {
707
                unset($grade_values[$itemid]);
708
                $dropped[$itemid] = 0;
709
                continue;
710
            }
711
            // Check for user specific grade min/max overrides.
712
            $usergrademin = $items[$itemid]->grademin;
713
            $usergrademax = $items[$itemid]->grademax;
714
            if (isset($grademinoverrides[$itemid])) {
715
                $usergrademin = $grademinoverrides[$itemid];
716
            }
717
            if (isset($grademaxoverrides[$itemid])) {
718
                $usergrademax = $grademaxoverrides[$itemid];
719
            }
720
            if ($this->aggregation == GRADE_AGGREGATE_SUM) {
721
                // Assume that the grademin is 0 when standardising the score, to preserve negative grades.
722
                $grade_values[$itemid] = grade_grade::standardise_score($v, 0, $usergrademax, 0, 1);
723
            } else {
724
                $grade_values[$itemid] = grade_grade::standardise_score($v, $usergrademin, $usergrademax, 0, 1);
725
            }
726
 
727
        }
728
 
729
        // First, check if all grades are null, because the final grade will be null
730
        // even when aggreateonlygraded is true.
731
        $allnull = true;
732
        foreach ($grade_values as $v) {
733
            if (!is_null($v)) {
734
                $allnull = false;
735
                break;
736
            }
737
        }
738
 
739
        // For items with no value, and not excluded - either set their grade to 0 or exclude them.
740
        foreach ($items as $itemid=>$value) {
741
            if (!isset($grade_values[$itemid]) and !in_array($itemid, $excluded)) {
742
                if (!$this->aggregateonlygraded) {
743
                    $grade_values[$itemid] = 0;
744
                } else {
745
                    // We are specifically marking these items as "excluded empty".
746
                    $novalue[$itemid] = 0;
747
                }
748
            }
749
        }
750
 
751
        // limit and sort
752
        $allvalues = $grade_values;
753
        if ($this->can_apply_limit_rules()) {
754
            $this->apply_limit_rules($grade_values, $items);
755
        }
756
 
757
        $moredropped = array_diff($allvalues, $grade_values);
758
        foreach ($moredropped as $drop => $unused) {
759
            $dropped[$drop] = 0;
760
        }
761
 
762
        foreach ($grade_values as $itemid => $val) {
763
            if (self::is_extracredit_used() && ($items[$itemid]->aggregationcoef > 0)) {
764
                $extracredit[$itemid] = 0;
765
            }
766
        }
767
 
768
        asort($grade_values, SORT_NUMERIC);
769
 
770
        // let's see we have still enough grades to do any statistics
771
        if (count($grade_values) == 0) {
772
            // not enough attempts yet
773
            $grade->finalgrade = null;
774
 
775
            if (!is_null($oldfinalgrade)) {
776
                $grade->timemodified = time();
777
                $success = $grade->update('aggregation');
778
 
779
                // If successful trigger a user_graded event.
780
                if ($success) {
781
                    \core\event\user_graded::create_from_grade($grade, \core\event\base::USER_OTHER)->trigger();
782
                }
783
            }
784
            $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
785
            return;
786
        }
787
 
788
        // do the maths
789
        $result = $this->aggregate_values_and_adjust_bounds($grade_values,
790
                                                            $items,
791
                                                            $usedweights,
792
                                                            $grademinoverrides,
793
                                                            $grademaxoverrides);
794
        $agg_grade = $result['grade'];
795
 
796
        // Set the actual grademin and max to bind the grade properly.
797
        $this->grade_item->grademin = $result['grademin'];
798
        $this->grade_item->grademax = $result['grademax'];
799
 
800
        if ($this->aggregation == GRADE_AGGREGATE_SUM) {
801
            // The natural aggregation always displays the range as coming from 0 for categories.
802
            // However, when we bind the grade we allow for negative values.
803
            $result['grademin'] = 0;
804
        }
805
 
806
        if ($allnull) {
807
            $grade->finalgrade = null;
808
        } else {
809
            // Recalculate the grade back to requested range.
810
            $finalgrade = grade_grade::standardise_score($agg_grade, 0, 1, $result['grademin'], $result['grademax']);
811
            $grade->finalgrade = $this->grade_item->bounded_grade($finalgrade);
812
        }
813
 
814
        $oldrawgrademin = $grade->rawgrademin;
815
        $oldrawgrademax = $grade->rawgrademax;
816
        $grade->rawgrademin = $result['grademin'];
817
        $grade->rawgrademax = $result['grademax'];
818
 
819
        // Update in db if changed.
820
        if (grade_floats_different($grade->finalgrade, $oldfinalgrade) ||
821
                grade_floats_different($grade->rawgrademax, $oldrawgrademax) ||
822
                grade_floats_different($grade->rawgrademin, $oldrawgrademin)) {
823
            $grade->timemodified = time();
824
            $success = $grade->update('aggregation');
825
 
826
            // If successful trigger a user_graded event.
827
            if ($success) {
828
                \core\event\user_graded::create_from_grade($grade, \core\event\base::USER_OTHER)->trigger();
829
            }
830
        }
831
 
832
        $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
833
 
834
        return;
835
    }
836
 
837
    /**
838
     * Set the flags on the grade_grade items to indicate how individual grades are used
839
     * in the aggregation.
840
     *
841
     * WARNING: This function is called a lot during gradebook recalculation, be very performance considerate.
842
     *
843
     * @param int $userid The user we have aggregated the grades for.
844
     * @param array $usedweights An array with keys for each of the grade_item columns included in the aggregation. The value are the relative weight.
845
     * @param array $novalue An array with keys for each of the grade_item columns skipped because
846
     *                       they had no value in the aggregation.
847
     * @param array $dropped An array with keys for each of the grade_item columns dropped
848
     *                       because of any drop lowest/highest settings in the aggregation.
849
     * @param array $extracredit An array with keys for each of the grade_item columns
850
     *                       considered extra credit by the aggregation.
851
     */
852
    private function set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit) {
853
        global $DB;
854
 
855
        // We want to know all current user grades so we can decide whether they need to be updated or they already contain the
856
        // expected value.
857
        $sql = "SELECT gi.id, gg.aggregationstatus, gg.aggregationweight FROM {grade_grades} gg
858
                  JOIN {grade_items} gi ON (gg.itemid = gi.id)
859
                 WHERE gg.userid = :userid";
860
        $params = array('categoryid' => $this->id, 'userid' => $userid);
861
 
862
        // These are all grade_item ids which grade_grades will NOT end up being 'unknown' (because they are not unknown or
863
        // because we will update them to something different that 'unknown').
864
        $giids = array_keys($usedweights + $novalue + $dropped + $extracredit);
865
 
866
        if ($giids) {
867
            // We include grade items that might not be in categoryid.
868
            list($itemsql, $itemlist) = $DB->get_in_or_equal($giids, SQL_PARAMS_NAMED, 'gg');
869
            $sql .= ' AND (gi.categoryid = :categoryid OR gi.id ' . $itemsql . ')';
870
            $params = $params + $itemlist;
871
        } else {
872
            $sql .= ' AND gi.categoryid = :categoryid';
873
        }
874
        $currentgrades = $DB->get_recordset_sql($sql, $params);
875
 
876
        // We will store here the grade_item ids that need to be updated on db.
877
        $toupdate = array();
878
 
879
        if ($currentgrades->valid()) {
880
 
881
            // Iterate through the user grades to see if we really need to update any of them.
882
            foreach ($currentgrades as $currentgrade) {
883
 
884
                // Unset $usedweights that we do not need to update.
885
                if (!empty($usedweights) && isset($usedweights[$currentgrade->id]) && $currentgrade->aggregationstatus === 'used') {
886
                    // We discard the ones that already have the contribution specified in $usedweights and are marked as 'used'.
887
                    if (grade_floats_equal($currentgrade->aggregationweight, $usedweights[$currentgrade->id])) {
888
                        unset($usedweights[$currentgrade->id]);
889
                    }
890
                    // Used weights can be present in multiple set_usedinaggregation arguments.
891
                    if (!isset($novalue[$currentgrade->id]) && !isset($dropped[$currentgrade->id]) &&
892
                            !isset($extracredit[$currentgrade->id])) {
893
                        continue;
894
                    }
895
                }
896
 
897
                // No value grades.
898
                if (!empty($novalue) && isset($novalue[$currentgrade->id])) {
899
                    if ($currentgrade->aggregationstatus !== 'novalue' ||
900
                            grade_floats_different($currentgrade->aggregationweight, 0)) {
901
                        $toupdate['novalue'][] = $currentgrade->id;
902
                    }
903
                    continue;
904
                }
905
 
906
                // Dropped grades.
907
                if (!empty($dropped) && isset($dropped[$currentgrade->id])) {
908
                    if ($currentgrade->aggregationstatus !== 'dropped' ||
909
                            grade_floats_different($currentgrade->aggregationweight, 0)) {
910
                        $toupdate['dropped'][] = $currentgrade->id;
911
                    }
912
                    continue;
913
                }
914
 
915
                // Extra credit grades.
916
                if (!empty($extracredit) && isset($extracredit[$currentgrade->id])) {
917
 
918
                    // If this grade item is already marked as 'extra' and it already has the provided $usedweights value would be
919
                    // silly to update to 'used' to later update to 'extra'.
920
                    if (!empty($usedweights) && isset($usedweights[$currentgrade->id]) &&
921
                            grade_floats_equal($currentgrade->aggregationweight, $usedweights[$currentgrade->id])) {
922
                        unset($usedweights[$currentgrade->id]);
923
                    }
924
 
925
                    // Update the item to extra if it is not already marked as extra in the database or if the item's
926
                    // aggregationweight will be updated when going through $usedweights items.
927
                    if ($currentgrade->aggregationstatus !== 'extra' ||
928
                            (!empty($usedweights) && isset($usedweights[$currentgrade->id]))) {
929
                        $toupdate['extracredit'][] = $currentgrade->id;
930
                    }
931
                    continue;
932
                }
933
 
934
                // If is not in any of the above groups it should be set to 'unknown', checking that the item is not already
935
                // unknown, if it is we don't need to update it.
936
                if ($currentgrade->aggregationstatus !== 'unknown' || grade_floats_different($currentgrade->aggregationweight, 0)) {
937
                    $toupdate['unknown'][] = $currentgrade->id;
938
                }
939
            }
940
            $currentgrades->close();
941
        }
942
 
943
        // Update items to 'unknown' status.
944
        if (!empty($toupdate['unknown'])) {
945
            list($itemsql, $itemlist) = $DB->get_in_or_equal($toupdate['unknown'], SQL_PARAMS_NAMED, 'g');
946
 
947
            $itemlist['userid'] = $userid;
948
 
949
            $sql = "UPDATE {grade_grades}
950
                       SET aggregationstatus = 'unknown',
951
                           aggregationweight = 0
952
                     WHERE itemid $itemsql AND userid = :userid";
953
            $DB->execute($sql, $itemlist);
954
        }
955
 
956
        // Update items to 'used' status and setting the proper weight.
957
        if (!empty($usedweights)) {
958
            // The usedweights items are updated individually to record the weights.
959
            foreach ($usedweights as $gradeitemid => $contribution) {
960
                $sql = "UPDATE {grade_grades}
961
                           SET aggregationstatus = 'used',
962
                               aggregationweight = :contribution
963
                         WHERE itemid = :itemid AND userid = :userid";
964
 
965
                $params = array('contribution' => $contribution, 'itemid' => $gradeitemid, 'userid' => $userid);
966
                $DB->execute($sql, $params);
967
            }
968
        }
969
 
970
        // Update items to 'novalue' status.
971
        if (!empty($toupdate['novalue'])) {
972
            list($itemsql, $itemlist) = $DB->get_in_or_equal($toupdate['novalue'], SQL_PARAMS_NAMED, 'g');
973
 
974
            $itemlist['userid'] = $userid;
975
 
976
            $sql = "UPDATE {grade_grades}
977
                       SET aggregationstatus = 'novalue',
978
                           aggregationweight = 0
979
                     WHERE itemid $itemsql AND userid = :userid";
980
 
981
            $DB->execute($sql, $itemlist);
982
        }
983
 
984
        // Update items to 'dropped' status.
985
        if (!empty($toupdate['dropped'])) {
986
            list($itemsql, $itemlist) = $DB->get_in_or_equal($toupdate['dropped'], SQL_PARAMS_NAMED, 'g');
987
 
988
            $itemlist['userid'] = $userid;
989
 
990
            $sql = "UPDATE {grade_grades}
991
                       SET aggregationstatus = 'dropped',
992
                           aggregationweight = 0
993
                     WHERE itemid $itemsql AND userid = :userid";
994
 
995
            $DB->execute($sql, $itemlist);
996
        }
997
 
998
        // Update items to 'extracredit' status.
999
        if (!empty($toupdate['extracredit'])) {
1000
            list($itemsql, $itemlist) = $DB->get_in_or_equal($toupdate['extracredit'], SQL_PARAMS_NAMED, 'g');
1001
 
1002
            $itemlist['userid'] = $userid;
1003
 
1004
            $DB->set_field_select('grade_grades',
1005
                                  'aggregationstatus',
1006
                                  'extra',
1007
                                  "itemid $itemsql AND userid = :userid",
1008
                                  $itemlist);
1009
        }
1010
    }
1011
 
1012
    /**
1013
     * Internal function that calculates the aggregated grade and new min/max for this grade category
1014
     *
1015
     * Must be public as it is used by grade_grade::get_hiding_affected()
1016
     *
1017
     * @param array $grade_values An array of values to be aggregated
1018
     * @param array $items The array of grade_items
1019
     * @since Moodle 2.6.5, 2.7.2
1020
     * @param array & $weights If provided, will be filled with the normalized weights
1021
     *                         for each grade_item as used in the aggregation.
1022
     *                         Some rules for the weights are:
1023
     *                         1. The weights must add up to 1 (unless there are extra credit)
1024
     *                         2. The contributed points column must add up to the course
1025
     *                         final grade and this column is calculated from these weights.
1026
     * @param array  $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
1027
     * @param array  $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
1028
     * @return array containing values for:
1029
     *                'grade' => the new calculated grade
1030
     *                'grademin' => the new calculated min grade for the category
1031
     *                'grademax' => the new calculated max grade for the category
1032
     */
1033
    public function aggregate_values_and_adjust_bounds($grade_values,
1034
                                                       $items,
1035
                                                       & $weights = null,
1036
                                                       $grademinoverrides = array(),
1037
                                                       $grademaxoverrides = array()) {
1038
        global $CFG;
1039
 
1040
        $category_item = $this->load_grade_item();
1041
        $grademin = $category_item->grademin;
1042
        $grademax = $category_item->grademax;
1043
 
1044
        switch ($this->aggregation) {
1045
 
1046
            case GRADE_AGGREGATE_MEDIAN: // Middle point value in the set: ignores frequencies
1047
                $num = count($grade_values);
1048
                $grades = array_values($grade_values);
1049
 
1050
                // The median gets 100% - others get 0.
1051
                if ($weights !== null && $num > 0) {
1052
                    $count = 0;
1053
                    foreach ($grade_values as $itemid=>$grade_value) {
1054
                        if (($num % 2 == 0) && ($count == intval($num/2)-1 || $count == intval($num/2))) {
1055
                            $weights[$itemid] = 0.5;
1056
                        } else if (($num % 2 != 0) && ($count == intval(($num/2)-0.5))) {
1057
                            $weights[$itemid] = 1.0;
1058
                        } else {
1059
                            $weights[$itemid] = 0;
1060
                        }
1061
                        $count++;
1062
                    }
1063
                }
1064
                if ($num % 2 == 0) {
1065
                    $agg_grade = ($grades[intval($num/2)-1] + $grades[intval($num/2)]) / 2;
1066
                } else {
1067
                    $agg_grade = $grades[intval(($num/2)-0.5)];
1068
                }
1069
 
1070
                break;
1071
 
1072
            case GRADE_AGGREGATE_MIN:
1073
                $agg_grade = reset($grade_values);
1074
                // Record the weights as used.
1075
                if ($weights !== null) {
1076
                    foreach ($grade_values as $itemid=>$grade_value) {
1077
                        $weights[$itemid] = 0;
1078
                    }
1079
                }
1080
                // Set the first item to 1.
1081
                $itemids = array_keys($grade_values);
1082
                $weights[reset($itemids)] = 1;
1083
                break;
1084
 
1085
            case GRADE_AGGREGATE_MAX:
1086
                // Record the weights as used.
1087
                if ($weights !== null) {
1088
                    foreach ($grade_values as $itemid=>$grade_value) {
1089
                        $weights[$itemid] = 0;
1090
                    }
1091
                }
1092
                // Set the last item to 1.
1093
                $itemids = array_keys($grade_values);
1094
                $weights[end($itemids)] = 1;
1095
                $agg_grade = end($grade_values);
1096
                break;
1097
 
1098
            case GRADE_AGGREGATE_MODE:       // the most common value
1099
                // array_count_values only counts INT and STRING, so if grades are floats we must convert them to string
1100
                $converted_grade_values = array();
1101
 
1102
                foreach ($grade_values as $k => $gv) {
1103
 
1104
                    if (!is_int($gv) && !is_string($gv)) {
1105
                        $converted_grade_values[$k] = (string) $gv;
1106
 
1107
                    } else {
1108
                        $converted_grade_values[$k] = $gv;
1109
                    }
1110
                    if ($weights !== null) {
1111
                        $weights[$k] = 0;
1112
                    }
1113
                }
1114
 
1115
                $freq = array_count_values($converted_grade_values);
1116
                arsort($freq);                      // sort by frequency keeping keys
1117
                $top = reset($freq);               // highest frequency count
1118
                $modes = moodle_array_keys_filter($freq, $top);  // Search for all modes (have the same highest count).
1119
                rsort($modes, SORT_NUMERIC);       // get highest mode
1120
                $agg_grade = reset($modes);
1121
                // Record the weights as used.
1122
                if ($weights !== null && $top > 0) {
1123
                    foreach ($grade_values as $k => $gv) {
1124
                        if ($gv == $agg_grade) {
1125
                            $weights[$k] = 1.0 / $top;
1126
                        }
1127
                    }
1128
                }
1129
                break;
1130
 
1131
            case GRADE_AGGREGATE_WEIGHTED_MEAN: // Weighted average of all existing final grades, weight specified in coef
1132
                $weightsum = 0;
1133
                $sum       = 0;
1134
 
1135
                foreach ($grade_values as $itemid=>$grade_value) {
1136
                    if ($weights !== null) {
1137
                        $weights[$itemid] = $items[$itemid]->aggregationcoef;
1138
                    }
1139
                    if ($items[$itemid]->aggregationcoef <= 0) {
1140
                        continue;
1141
                    }
1142
                    $weightsum += $items[$itemid]->aggregationcoef;
1143
                    $sum       += $items[$itemid]->aggregationcoef * $grade_value;
1144
                }
1145
                if ($weightsum == 0) {
1146
                    $agg_grade = null;
1147
 
1148
                } else {
1149
                    $agg_grade = $sum / $weightsum;
1150
                    if ($weights !== null) {
1151
                        // Normalise the weights.
1152
                        foreach ($weights as $itemid => $weight) {
1153
                            $weights[$itemid] = $weight / $weightsum;
1154
                        }
1155
                    }
1156
 
1157
                }
1158
                break;
1159
 
1160
            case GRADE_AGGREGATE_WEIGHTED_MEAN2:
1161
                // Weighted average of all existing final grades with optional extra credit flag,
1162
                // weight is the range of grade (usually grademax)
1163
                $this->load_grade_item();
1164
                $weightsum = 0;
1165
                $sum       = null;
1166
 
1167
                foreach ($grade_values as $itemid=>$grade_value) {
1168
                    if ($items[$itemid]->aggregationcoef > 0) {
1169
                        continue;
1170
                    }
1171
 
1172
                    $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
1173
                    if ($weight <= 0) {
1174
                        continue;
1175
                    }
1176
 
1177
                    $weightsum += $weight;
1178
                    $sum += $weight * $grade_value;
1179
                }
1180
 
1181
                // Handle the extra credit items separately to calculate their weight accurately.
1182
                foreach ($grade_values as $itemid => $grade_value) {
1183
                    if ($items[$itemid]->aggregationcoef <= 0) {
1184
                        continue;
1185
                    }
1186
 
1187
                    $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
1188
                    if ($weight <= 0) {
1189
                        $weights[$itemid] = 0;
1190
                        continue;
1191
                    }
1192
 
1193
                    $oldsum = $sum;
1194
                    $weightedgrade = $weight * $grade_value;
1195
                    $sum += $weightedgrade;
1196
 
1197
                    if ($weights !== null) {
1198
                        if ($weightsum <= 0) {
1199
                            $weights[$itemid] = 0;
1200
                            continue;
1201
                        }
1202
 
1203
                        $oldgrade = $oldsum / $weightsum;
1204
                        $grade = $sum / $weightsum;
1205
                        $normoldgrade = grade_grade::standardise_score($oldgrade, 0, 1, $grademin, $grademax);
1206
                        $normgrade = grade_grade::standardise_score($grade, 0, 1, $grademin, $grademax);
1207
                        $boundedoldgrade = $this->grade_item->bounded_grade($normoldgrade);
1208
                        $boundedgrade = $this->grade_item->bounded_grade($normgrade);
1209
 
1210
                        if ($boundedgrade - $boundedoldgrade <= 0) {
1211
                            // Nothing new was added to the grade.
1212
                            $weights[$itemid] = 0;
1213
                        } else if ($boundedgrade < $normgrade) {
1214
                            // The grade has been bounded, the extra credit item needs to have a different weight.
1215
                            $gradediff = $boundedgrade - $normoldgrade;
1216
                            $gradediffnorm = grade_grade::standardise_score($gradediff, $grademin, $grademax, 0, 1);
1217
                            $weights[$itemid] = $gradediffnorm / $grade_value;
1218
                        } else {
1219
                            // Default weighting.
1220
                            $weights[$itemid] = $weight / $weightsum;
1221
                        }
1222
                    }
1223
                }
1224
 
1225
                if ($weightsum == 0) {
1226
                    $agg_grade = $sum; // only extra credits
1227
 
1228
                } else {
1229
                    $agg_grade = $sum / $weightsum;
1230
                }
1231
 
1232
                // Record the weights as used.
1233
                if ($weights !== null) {
1234
                    foreach ($grade_values as $itemid=>$grade_value) {
1235
                        if ($items[$itemid]->aggregationcoef > 0) {
1236
                            // Ignore extra credit items, the weights have already been computed.
1237
                            continue;
1238
                        }
1239
                        if ($weightsum > 0) {
1240
                            $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
1241
                            $weights[$itemid] = $weight / $weightsum;
1242
                        } else {
1243
                            $weights[$itemid] = 0;
1244
                        }
1245
                    }
1246
                }
1247
                break;
1248
 
1249
            case GRADE_AGGREGATE_EXTRACREDIT_MEAN: // special average
1250
                $this->load_grade_item();
1251
                $num = 0;
1252
                $sum = null;
1253
 
1254
                foreach ($grade_values as $itemid=>$grade_value) {
1255
                    if ($items[$itemid]->aggregationcoef == 0) {
1256
                        $num += 1;
1257
                        $sum += $grade_value;
1258
                        if ($weights !== null) {
1259
                            $weights[$itemid] = 1;
1260
                        }
1261
                    }
1262
                }
1263
 
1264
                // Treating the extra credit items separately to get a chance to calculate their effective weights.
1265
                foreach ($grade_values as $itemid=>$grade_value) {
1266
                    if ($items[$itemid]->aggregationcoef > 0) {
1267
                        $oldsum = $sum;
1268
                        $sum += $items[$itemid]->aggregationcoef * $grade_value;
1269
 
1270
                        if ($weights !== null) {
1271
                            if ($num <= 0) {
1272
                                // The category only contains extra credit items, not setting the weight.
1273
                                continue;
1274
                            }
1275
 
1276
                            $oldgrade = $oldsum / $num;
1277
                            $grade = $sum / $num;
1278
                            $normoldgrade = grade_grade::standardise_score($oldgrade, 0, 1, $grademin, $grademax);
1279
                            $normgrade = grade_grade::standardise_score($grade, 0, 1, $grademin, $grademax);
1280
                            $boundedoldgrade = $this->grade_item->bounded_grade($normoldgrade);
1281
                            $boundedgrade = $this->grade_item->bounded_grade($normgrade);
1282
 
1283
                            if ($boundedgrade - $boundedoldgrade <= 0) {
1284
                                // Nothing new was added to the grade.
1285
                                $weights[$itemid] = 0;
1286
                            } else if ($boundedgrade < $normgrade) {
1287
                                // The grade has been bounded, the extra credit item needs to have a different weight.
1288
                                $gradediff = $boundedgrade - $normoldgrade;
1289
                                $gradediffnorm = grade_grade::standardise_score($gradediff, $grademin, $grademax, 0, 1);
1290
                                $weights[$itemid] = $gradediffnorm / $grade_value;
1291
                            } else {
1292
                                // Default weighting.
1293
                                $weights[$itemid] = 1.0 / $num;
1294
                            }
1295
                        }
1296
                    }
1297
                }
1298
 
1299
                if ($weights !== null && $num > 0) {
1300
                    foreach ($grade_values as $itemid=>$grade_value) {
1301
                        if ($items[$itemid]->aggregationcoef > 0) {
1302
                            // Extra credit weights were already calculated.
1303
                            continue;
1304
                        }
1305
                        if ($weights[$itemid]) {
1306
                            $weights[$itemid] = 1.0 / $num;
1307
                        }
1308
                    }
1309
                }
1310
 
1311
                if ($num == 0) {
1312
                    $agg_grade = $sum; // only extra credits or wrong coefs
1313
 
1314
                } else {
1315
                    $agg_grade = $sum / $num;
1316
                }
1317
 
1318
                break;
1319
 
1320
            case GRADE_AGGREGATE_SUM:    // Add up all the items.
1321
                $this->load_grade_item();
1322
                $num = count($grade_values);
1323
                $sum = 0;
1324
 
1325
                // This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
1326
                // Even though old algorith has bugs in it, we need to preserve existing grades.
1327
                $gradebookcalculationfreeze = 'gradebook_calculations_freeze_' . $this->courseid;
1328
                $oldextracreditcalculation = isset($CFG->$gradebookcalculationfreeze)
1329
                        && ($CFG->$gradebookcalculationfreeze <= 20150619);
1330
 
1331
                $sumweights = 0;
1332
                $grademin = 0;
1333
                $grademax = 0;
1334
                $extracredititems = array();
1335
                foreach ($grade_values as $itemid => $gradevalue) {
1336
                    // We need to check if the grademax/min was adjusted per user because of excluded items.
1337
                    $usergrademin = $items[$itemid]->grademin;
1338
                    $usergrademax = $items[$itemid]->grademax;
1339
                    if (isset($grademinoverrides[$itemid])) {
1340
                        $usergrademin = $grademinoverrides[$itemid];
1341
                    }
1342
                    if (isset($grademaxoverrides[$itemid])) {
1343
                        $usergrademax = $grademaxoverrides[$itemid];
1344
                    }
1345
 
1346
                    // Keep track of the extra credit items, we will need them later on.
1347
                    if ($items[$itemid]->aggregationcoef > 0) {
1348
                        $extracredititems[$itemid] = $items[$itemid];
1349
                    }
1350
 
1351
                    // Ignore extra credit and items with a weight of 0.
1352
                    if (!isset($extracredititems[$itemid]) && $items[$itemid]->aggregationcoef2 > 0) {
1353
                        $grademin += $usergrademin;
1354
                        $grademax += $usergrademax;
1355
                        $sumweights += $items[$itemid]->aggregationcoef2;
1356
                    }
1357
                }
1358
                $userweights = array();
1359
                $totaloverriddenweight = 0;
1360
                $totaloverriddengrademax = 0;
1361
                // We first need to rescale all manually assigned weights down by the
1362
                // percentage of weights missing from the category.
1363
                foreach ($grade_values as $itemid => $gradevalue) {
1364
                    if ($items[$itemid]->weightoverride) {
1365
                        if ($items[$itemid]->aggregationcoef2 <= 0) {
1366
                            // Records the weight of 0 and continue.
1367
                            $userweights[$itemid] = 0;
1368
                            continue;
1369
                        }
1370
                        $userweights[$itemid] = $sumweights ? ($items[$itemid]->aggregationcoef2 / $sumweights) : 0;
1371
                        if (!$oldextracreditcalculation && isset($extracredititems[$itemid])) {
1372
                            // Extra credit items do not affect totals.
1373
                            continue;
1374
                        }
1375
                        $totaloverriddenweight += $userweights[$itemid];
1376
                        $usergrademax = $items[$itemid]->grademax;
1377
                        if (isset($grademaxoverrides[$itemid])) {
1378
                            $usergrademax = $grademaxoverrides[$itemid];
1379
                        }
1380
                        $totaloverriddengrademax += $usergrademax;
1381
                    }
1382
                }
1383
                $nonoverriddenpoints = $grademax - $totaloverriddengrademax;
1384
 
1385
                // Then we need to recalculate the automatic weights except for extra credit items.
1386
                foreach ($grade_values as $itemid => $gradevalue) {
1387
                    if (!$items[$itemid]->weightoverride && ($oldextracreditcalculation || !isset($extracredititems[$itemid]))) {
1388
                        $usergrademax = $items[$itemid]->grademax;
1389
                        if (isset($grademaxoverrides[$itemid])) {
1390
                            $usergrademax = $grademaxoverrides[$itemid];
1391
                        }
1392
                        if ($nonoverriddenpoints > 0) {
1393
                            $userweights[$itemid] = ($usergrademax/$nonoverriddenpoints) * (1 - $totaloverriddenweight);
1394
                        } else {
1395
                            $userweights[$itemid] = 0;
1396
                            if ($items[$itemid]->aggregationcoef2 > 0) {
1397
                                // Items with a weight of 0 should not count for the grade max,
1398
                                // though this only applies if the weight was changed to 0.
1399
                                $grademax -= $usergrademax;
1400
                            }
1401
                        }
1402
                    }
1403
                }
1404
 
1405
                // Now when we finally know the grademax we can adjust the automatic weights of extra credit items.
1406
                if (!$oldextracreditcalculation) {
1407
                    foreach ($grade_values as $itemid => $gradevalue) {
1408
                        if (!$items[$itemid]->weightoverride && isset($extracredititems[$itemid])) {
1409
                            $usergrademax = $items[$itemid]->grademax;
1410
                            if (isset($grademaxoverrides[$itemid])) {
1411
                                $usergrademax = $grademaxoverrides[$itemid];
1412
                            }
1413
                            $userweights[$itemid] = $grademax ? ($usergrademax / $grademax) : 0;
1414
                        }
1415
                    }
1416
                }
1417
 
1418
                // We can use our freshly corrected weights below.
1419
                foreach ($grade_values as $itemid => $gradevalue) {
1420
                    if (isset($extracredititems[$itemid])) {
1421
                        // We skip the extra credit items first.
1422
                        continue;
1423
                    }
1424
                    $sum += $gradevalue * $userweights[$itemid] * $grademax;
1425
                    if ($weights !== null) {
1426
                        $weights[$itemid] = $userweights[$itemid];
1427
                    }
1428
                }
1429
 
1430
                // No we proceed with the extra credit items. They might have a different final
1431
                // weight in case the final grade was bounded. So we need to treat them different.
1432
                // Also, as we need to use the bounded_grade() method, we have to inject the
1433
                // right values there, and restore them afterwards.
1434
                $oldgrademax = $this->grade_item->grademax;
1435
                $oldgrademin = $this->grade_item->grademin;
1436
                foreach ($grade_values as $itemid => $gradevalue) {
1437
                    if (!isset($extracredititems[$itemid])) {
1438
                        continue;
1439
                    }
1440
                    $oldsum = $sum;
1441
                    $weightedgrade = $gradevalue * $userweights[$itemid] * $grademax;
1442
                    $sum += $weightedgrade;
1443
 
1444
                    // Only go through this when we need to record the weights.
1445
                    if ($weights !== null) {
1446
                        if ($grademax <= 0) {
1447
                            // There are only extra credit items in this category,
1448
                            // all the weights should be accurate (and be 0).
1449
                            $weights[$itemid] = $userweights[$itemid];
1450
                            continue;
1451
                        }
1452
 
1453
                        $oldfinalgrade = $this->grade_item->bounded_grade($oldsum);
1454
                        $newfinalgrade = $this->grade_item->bounded_grade($sum);
1455
                        $finalgradediff = $newfinalgrade - $oldfinalgrade;
1456
                        if ($finalgradediff <= 0) {
1457
                            // This item did not contribute to the category total at all.
1458
                            $weights[$itemid] = 0;
1459
                        } else if ($finalgradediff < $weightedgrade) {
1460
                            // The weight needs to be adjusted because only a portion of the
1461
                            // extra credit item contributed to the category total.
1462
                            $weights[$itemid] = $finalgradediff / ($gradevalue * $grademax);
1463
                        } else {
1464
                            // The weight was accurate.
1465
                            $weights[$itemid] = $userweights[$itemid];
1466
                        }
1467
                    }
1468
                }
1469
                $this->grade_item->grademax = $oldgrademax;
1470
                $this->grade_item->grademin = $oldgrademin;
1471
 
1472
                if ($grademax > 0) {
1473
                    $agg_grade = $sum / $grademax; // Re-normalize score.
1474
                } else {
1475
                    // Every item in the category is extra credit.
1476
                    $agg_grade = $sum;
1477
                    $grademax = $sum;
1478
                }
1479
 
1480
                break;
1481
 
1482
            case GRADE_AGGREGATE_MEAN:    // Arithmetic average of all grade items (if ungraded aggregated, NULL counted as minimum)
1483
            default:
1484
                $num = count($grade_values);
1485
                $sum = array_sum($grade_values);
1486
                $agg_grade = $sum / $num;
1487
                // Record the weights evenly.
1488
                if ($weights !== null && $num > 0) {
1489
                    foreach ($grade_values as $itemid=>$grade_value) {
1490
                        $weights[$itemid] = 1.0 / $num;
1491
                    }
1492
                }
1493
                break;
1494
        }
1495
 
1496
        return array('grade' => $agg_grade, 'grademin' => $grademin, 'grademax' => $grademax);
1497
    }
1498
 
1499
    /**
1500
     * Internal function that calculates the aggregated grade for this grade category
1501
     *
1502
     * Must be public as it is used by grade_grade::get_hiding_affected()
1503
     *
1504
     * @deprecated since Moodle 2.8
1505
     * @param array $grade_values An array of values to be aggregated
1506
     * @param array $items The array of grade_items
1507
     * @return float The aggregate grade for this grade category
1508
     */
1509
    public function aggregate_values($grade_values, $items) {
1510
        debugging('grade_category::aggregate_values() is deprecated.
1511
                   Call grade_category::aggregate_values_and_adjust_bounds() instead.', DEBUG_DEVELOPER);
1512
        $result = $this->aggregate_values_and_adjust_bounds($grade_values, $items);
1513
        return $result['grade'];
1514
    }
1515
 
1516
    /**
1517
     * Some aggregation types may need to update their max grade.
1518
     *
1519
     * This must be executed after updating the weights as it relies on them.
1520
     *
1521
     * @return void
1522
     */
1523
    private function auto_update_max() {
1524
        global $CFG, $DB;
1525
        if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1526
            // not needed at all
1527
            return;
1528
        }
1529
 
1530
        // Find grade items of immediate children (category or grade items) and force site settings.
1531
        $this->load_grade_item();
1532
        $depends_on = $this->grade_item->depends_on();
1533
 
1534
        // Check to see if the gradebook is frozen. This allows grades to not be altered at all until a user verifies that they
1535
        // wish to update the grades.
1536
        $gradebookcalculationfreeze = 'gradebook_calculations_freeze_' . $this->courseid;
1537
        $oldextracreditcalculation = isset($CFG->$gradebookcalculationfreeze) && ($CFG->$gradebookcalculationfreeze <= 20150627);
1538
        // Only run if the gradebook isn't frozen.
1539
        if (!$oldextracreditcalculation) {
1540
            // Don't automatically update the max for calculated items.
1541
            if ($this->grade_item->is_calculated()) {
1542
                return;
1543
            }
1544
        }
1545
 
1546
        $items = false;
1547
        if (!empty($depends_on)) {
1548
            list($usql, $params) = $DB->get_in_or_equal($depends_on);
1549
            $sql = "SELECT *
1550
                      FROM {grade_items}
1551
                     WHERE id $usql";
1552
            $items = $DB->get_records_sql($sql, $params);
1553
        }
1554
 
1555
        if (!$items) {
1556
 
1557
            if ($this->grade_item->grademax != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1558
                $this->grade_item->grademax  = 0;
1559
                $this->grade_item->grademin  = 0;
1560
                $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1561
                $this->grade_item->update('aggregation');
1562
            }
1563
            return;
1564
        }
1565
 
1566
        //find max grade possible
1567
        $maxes = array();
1568
 
1569
        foreach ($items as $item) {
1570
 
1571
            if ($item->aggregationcoef > 0) {
1572
                // extra credit from this activity - does not affect total
1573
                continue;
1574
            } else if ($item->aggregationcoef2 <= 0) {
1575
                // Items with a weight of 0 do not affect the total.
1576
                continue;
1577
            }
1578
 
1579
            if ($item->gradetype == GRADE_TYPE_VALUE) {
1580
                $maxes[$item->id] = $item->grademax;
1581
 
1582
            } else if ($item->gradetype == GRADE_TYPE_SCALE) {
1583
                $maxes[$item->id] = $item->grademax; // 0 = nograde, 1 = first scale item, 2 = second scale item
1584
            }
1585
        }
1586
 
1587
        if ($this->can_apply_limit_rules()) {
1588
            // Apply droplow and keephigh.
1589
            $this->apply_limit_rules($maxes, $items);
1590
        }
1591
        $max = array_sum($maxes);
1592
 
1593
        // update db if anything changed
1594
        if ($this->grade_item->grademax != $max or $this->grade_item->grademin != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1595
            $this->grade_item->grademax  = $max;
1596
            $this->grade_item->grademin  = 0;
1597
            $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1598
            $this->grade_item->update('aggregation');
1599
        }
1600
    }
1601
 
1602
    /**
1603
     * Recalculate the weights of the grade items in this category.
1604
     *
1605
     * The category total is not updated here, a further call to
1606
     * {@link self::auto_update_max()} is required.
1607
     *
1608
     * @return void
1609
     */
1610
    private function auto_update_weights() {
1611
        global $CFG;
1612
        if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1613
            // This is only required if we are using natural weights.
1614
            return;
1615
        }
1616
        $children = $this->get_children();
1617
 
1618
        $gradeitem = null;
1619
 
1620
        // Calculate the sum of the grademax's of all the items within this category.
1621
        $totalnonoverriddengrademax = 0;
1622
        $totalgrademax = 0;
1623
 
1624
        // Out of 1, how much weight has been manually overriden by a user?
1625
        $totaloverriddenweight  = 0;
1626
        $totaloverriddengrademax  = 0;
1627
 
1628
        // Has every assessment in this category been overridden?
1629
        $automaticgradeitemspresent = false;
1630
        // Does the grade item require normalising?
1631
        $requiresnormalising = false;
1632
 
1633
        // This array keeps track of the id and weight of every grade item that has been overridden.
1634
        $overridearray = array();
1635
        foreach ($children as $sortorder => $child) {
1636
            $gradeitem = null;
1637
 
1638
            if ($child['type'] == 'item') {
1639
                $gradeitem = $child['object'];
1640
            } else if ($child['type'] == 'category') {
1641
                $gradeitem = $child['object']->load_grade_item();
1642
            }
1643
 
1644
            if ($gradeitem->gradetype == GRADE_TYPE_NONE || $gradeitem->gradetype == GRADE_TYPE_TEXT) {
1645
                // Text items and none items do not have a weight.
1646
                continue;
1647
            } else if (!$this->aggregateoutcomes && $gradeitem->is_outcome_item()) {
1648
                // We will not aggregate outcome items, so we can ignore them.
1649
                continue;
1650
            } else if (empty($CFG->grade_includescalesinaggregation) && $gradeitem->gradetype == GRADE_TYPE_SCALE) {
1651
                // The scales are not included in the aggregation, ignore them.
1652
                continue;
1653
            }
1654
 
1655
            // Record the ID and the weight for this grade item.
1656
            $overridearray[$gradeitem->id] = array();
1657
            $overridearray[$gradeitem->id]['extracredit'] = intval($gradeitem->aggregationcoef);
1658
            $overridearray[$gradeitem->id]['weight'] = $gradeitem->aggregationcoef2;
1659
            $overridearray[$gradeitem->id]['weightoverride'] = intval($gradeitem->weightoverride);
1660
            // If this item has had its weight overridden then set the flag to true, but
1661
            // only if all previous items were also overridden. Note that extra credit items
1662
            // are counted as overridden grade items.
1663
            if (!$gradeitem->weightoverride && $gradeitem->aggregationcoef == 0) {
1664
                $automaticgradeitemspresent = true;
1665
            }
1666
 
1667
            if ($gradeitem->aggregationcoef > 0) {
1668
                // An extra credit grade item doesn't contribute to $totaloverriddengrademax.
1669
                continue;
1670
            } else if ($gradeitem->weightoverride > 0 && $gradeitem->aggregationcoef2 <= 0) {
1671
                // An overridden item that defines a weight of 0 does not contribute to $totaloverriddengrademax.
1672
                continue;
1673
            }
1674
 
1675
            $totalgrademax += $gradeitem->grademax;
1676
            if ($gradeitem->weightoverride > 0) {
1677
                $totaloverriddenweight += $gradeitem->aggregationcoef2;
1678
                $totaloverriddengrademax += $gradeitem->grademax;
1679
            }
1680
        }
1681
 
1682
        // Initialise this variable (used to keep track of the weight override total).
1683
        $normalisetotal = 0;
1684
        // Keep a record of how much the override total is to see if it is above 100. It it is then we need to set the
1685
        // other weights to zero and normalise the others.
1686
        $overriddentotal = 0;
1687
        // Total up all of the weights.
1688
        foreach ($overridearray as $gradeitemdetail) {
1689
            // If the grade item has extra credit, then don't add it to the normalisetotal.
1690
            if (!$gradeitemdetail['extracredit']) {
1691
                $normalisetotal += $gradeitemdetail['weight'];
1692
            }
1693
            // The overridden total comprises of items that are set as overridden, that aren't extra credit and have a value
1694
            // greater than zero.
1695
            if ($gradeitemdetail['weightoverride'] && !$gradeitemdetail['extracredit'] && $gradeitemdetail['weight'] > 0) {
1696
                // Add overriden weights up to see if they are greater than 1.
1697
                $overriddentotal += $gradeitemdetail['weight'];
1698
            }
1699
        }
1700
        if ($overriddentotal > 1) {
1701
            // Make sure that this catergory of weights gets normalised.
1702
            $requiresnormalising = true;
1703
            // The normalised weights are only the overridden weights, so we just use the total of those.
1704
            $normalisetotal = $overriddentotal;
1705
        }
1706
 
1707
        $totalnonoverriddengrademax = $totalgrademax - $totaloverriddengrademax;
1708
 
1709
        // This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
1710
        // Even though old algorith has bugs in it, we need to preserve existing grades.
1711
        $gradebookcalculationfreeze = (int)get_config('core', 'gradebook_calculations_freeze_' . $this->courseid);
1712
        $oldextracreditcalculation = $gradebookcalculationfreeze && ($gradebookcalculationfreeze <= 20150619);
1713
 
1714
        reset($children);
1715
        foreach ($children as $sortorder => $child) {
1716
            $gradeitem = null;
1717
 
1718
            if ($child['type'] == 'item') {
1719
                $gradeitem = $child['object'];
1720
            } else if ($child['type'] == 'category') {
1721
                $gradeitem = $child['object']->load_grade_item();
1722
            }
1723
 
1724
            if ($gradeitem->gradetype == GRADE_TYPE_NONE || $gradeitem->gradetype == GRADE_TYPE_TEXT) {
1725
                // Text items and none items do not have a weight, no need to set their weight to
1726
                // zero as they must never be used during aggregation.
1727
                continue;
1728
            } else if (!$this->aggregateoutcomes && $gradeitem->is_outcome_item()) {
1729
                // We will not aggregate outcome items, so we can ignore updating their weights.
1730
                continue;
1731
            } else if (empty($CFG->grade_includescalesinaggregation) && $gradeitem->gradetype == GRADE_TYPE_SCALE) {
1732
                // We will not aggregate the scales, so we can ignore upating their weights.
1733
                continue;
1734
            } else if (!$oldextracreditcalculation && $gradeitem->aggregationcoef > 0 && $gradeitem->weightoverride) {
1735
                // For an item with extra credit ignore other weigths and overrides but do not change anything at all
1736
                // if it's weight was already overridden.
1737
                continue;
1738
            }
1739
 
1740
            // Store the previous value here, no need to update if it is the same value.
1741
            $prevaggregationcoef2 = $gradeitem->aggregationcoef2;
1742
 
1743
            if (!$oldextracreditcalculation && $gradeitem->aggregationcoef > 0 && !$gradeitem->weightoverride) {
1744
                // For an item with extra credit ignore other weigths and overrides.
1745
                $gradeitem->aggregationcoef2 = $totalgrademax ? ($gradeitem->grademax / $totalgrademax) : 0;
1746
 
1747
            } else if (!$gradeitem->weightoverride) {
1748
                // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.
1749
                if ($totaloverriddenweight >= 1 || $totalnonoverriddengrademax == 0 || $gradeitem->grademax == 0) {
1750
                    // There is no more weight to distribute.
1751
                    $gradeitem->aggregationcoef2 = 0;
1752
                } else {
1753
                    // Calculate this item's weight as a percentage of the non-overridden total grade maxes
1754
                    // then convert it to a proportion of the available non-overriden weight.
1755
                    $gradeitem->aggregationcoef2 = ($gradeitem->grademax/$totalnonoverriddengrademax) *
1756
                            (1 - $totaloverriddenweight);
1757
                }
1758
 
1759
            } else if ((!$automaticgradeitemspresent && $normalisetotal != 1) || ($requiresnormalising)
1760
                    || $overridearray[$gradeitem->id]['weight'] < 0) {
1761
                // Just divide the overriden weight for this item against the total weight override of all
1762
                // items in this category.
1763
                if ($normalisetotal == 0 || $overridearray[$gradeitem->id]['weight'] < 0) {
1764
                    // If the normalised total equals zero, or the weight value is less than zero,
1765
                    // set the weight for the grade item to zero.
1766
                    $gradeitem->aggregationcoef2 = 0;
1767
                } else {
1768
                    $gradeitem->aggregationcoef2 = $overridearray[$gradeitem->id]['weight'] / $normalisetotal;
1769
                }
1770
            }
1771
 
1772
            if (grade_floatval($prevaggregationcoef2) !== grade_floatval($gradeitem->aggregationcoef2)) {
1773
                // Update the grade item to reflect these changes.
1774
                $gradeitem->update();
1775
            }
1776
        }
1777
    }
1778
 
1779
    /**
1780
     * Given an array of grade values (numerical indices) applies droplow or keephigh rules to limit the final array.
1781
     *
1782
     * @param array $grade_values itemid=>$grade_value float
1783
     * @param array $items grade item objects
1784
     * @return array Limited grades.
1785
     */
1786
    public function apply_limit_rules(&$grade_values, $items) {
1787
        $extraused = $this->is_extracredit_used();
1788
 
1789
        if (!empty($this->droplow)) {
1790
            asort($grade_values, SORT_NUMERIC);
1791
            $dropped = 0;
1792
 
1793
            // If we have fewer grade items available to drop than $this->droplow, use this flag to escape the loop
1794
            // May occur because of "extra credit" or if droplow is higher than the number of grade items
1795
            $droppedsomething = true;
1796
 
1797
            while ($dropped < $this->droplow && $droppedsomething) {
1798
                $droppedsomething = false;
1799
 
1800
                $grade_keys = array_keys($grade_values);
1801
                $gradekeycount = count($grade_keys);
1802
 
1803
                if ($gradekeycount === 0) {
1804
                    //We've dropped all grade items
1805
                    break;
1806
                }
1807
 
1808
                $originalindex = $founditemid = $foundmax = null;
1809
 
1810
                // Find the first remaining grade item that is available to be dropped
1811
                foreach ($grade_keys as $gradekeyindex=>$gradekey) {
1812
                    if (!$extraused || $items[$gradekey]->aggregationcoef <= 0) {
1813
                        // Found a non-extra credit grade item that is eligible to be dropped
1814
                        $originalindex = $gradekeyindex;
1815
                        $founditemid = $grade_keys[$originalindex];
1816
                        $foundmax = $items[$founditemid]->grademax;
1817
                        break;
1818
                    }
1819
                }
1820
 
1821
                if (empty($founditemid)) {
1822
                    // No grade items available to drop
1823
                    break;
1824
                }
1825
 
1826
                // Now iterate over the remaining grade items
1827
                // We're looking for other grade items with the same grade value but a higher grademax
1828
                $i = 1;
1829
                while ($originalindex + $i < $gradekeycount) {
1830
 
1831
                    $possibleitemid = $grade_keys[$originalindex+$i];
1832
                    $i++;
1833
 
1834
                    if ($grade_values[$founditemid] != $grade_values[$possibleitemid]) {
1835
                        // The next grade item has a different grade value. Stop looking.
1836
                        break;
1837
                    }
1838
 
1839
                    if ($extraused && $items[$possibleitemid]->aggregationcoef > 0) {
1840
                        // Don't drop extra credit grade items. Continue the search.
1841
                        continue;
1842
                    }
1843
 
1844
                    if ($foundmax < $items[$possibleitemid]->grademax) {
1845
                        // Found a grade item with the same grade value and a higher grademax
1846
                        $foundmax = $items[$possibleitemid]->grademax;
1847
                        $founditemid = $possibleitemid;
1848
                        // Continue searching to see if there is an even higher grademax
1849
                    }
1850
                }
1851
 
1852
                // Now drop whatever grade item we have found
1853
                unset($grade_values[$founditemid]);
1854
                $dropped++;
1855
                $droppedsomething = true;
1856
            }
1857
 
1858
        } else if (!empty($this->keephigh)) {
1859
            arsort($grade_values, SORT_NUMERIC);
1860
            $kept = 0;
1861
 
1862
            foreach ($grade_values as $itemid=>$value) {
1863
 
1864
                if ($extraused and $items[$itemid]->aggregationcoef > 0) {
1865
                    // we keep all extra credits
1866
 
1867
                } else if ($kept < $this->keephigh) {
1868
                    $kept++;
1869
 
1870
                } else {
1871
                    unset($grade_values[$itemid]);
1872
                }
1873
            }
1874
        }
1875
    }
1876
 
1877
    /**
1878
     * Returns whether or not we can apply the limit rules.
1879
     *
1880
     * There are cases where drop lowest or keep highest should not be used
1881
     * at all. This method will determine whether or not this logic can be
1882
     * applied considering the current setup of the category.
1883
     *
1884
     * @return bool
1885
     */
1886
    public function can_apply_limit_rules() {
1887
        if ($this->canapplylimitrules !== null) {
1888
            return $this->canapplylimitrules;
1889
        }
1890
 
1891
        // Set it to be supported by default.
1892
        $this->canapplylimitrules = true;
1893
 
1894
        // Natural aggregation.
1895
        if ($this->aggregation == GRADE_AGGREGATE_SUM) {
1896
            $canapply = true;
1897
 
1898
            // Check until one child breaks the rules.
1899
            $gradeitems = $this->get_children();
1900
            $validitems = 0;
1901
            $lastweight = null;
1902
            $lastmaxgrade = null;
1903
            foreach ($gradeitems as $gradeitem) {
1904
                $gi = $gradeitem['object'];
1905
 
1906
                if ($gradeitem['type'] == 'category') {
1907
                    // Sub categories are not allowed because they can have dynamic weights/maxgrades.
1908
                    $canapply = false;
1909
                    break;
1910
                }
1911
 
1912
                if ($gi->aggregationcoef > 0) {
1913
                    // Extra credit items are not allowed.
1914
                    $canapply = false;
1915
                    break;
1916
                }
1917
 
1918
                if ($lastweight !== null && $lastweight != $gi->aggregationcoef2) {
1919
                    // One of the weight differs from another item.
1920
                    $canapply = false;
1921
                    break;
1922
                }
1923
 
1924
                if ($lastmaxgrade !== null && $lastmaxgrade != $gi->grademax) {
1925
                    // One of the max grade differ from another item. This is not allowed for now
1926
                    // because we could be end up with different max grade between users for this category.
1927
                    $canapply = false;
1928
                    break;
1929
                }
1930
 
1931
                $lastweight = $gi->aggregationcoef2;
1932
                $lastmaxgrade = $gi->grademax;
1933
            }
1934
 
1935
            $this->canapplylimitrules = $canapply;
1936
        }
1937
 
1938
        return $this->canapplylimitrules;
1939
    }
1940
 
1941
    /**
1942
     * Returns true if category uses extra credit of any kind
1943
     *
1944
     * @return bool True if extra credit used
1945
     */
1946
    public function is_extracredit_used() {
1947
        return self::aggregation_uses_extracredit($this->aggregation);
1948
    }
1949
 
1950
    /**
1951
     * Returns true if aggregation passed is using extracredit.
1952
     *
1953
     * @param int $aggregation Aggregation const.
1954
     * @return bool True if extra credit used
1955
     */
1956
    public static function aggregation_uses_extracredit($aggregation) {
1957
        return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1958
             or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1959
             or $aggregation == GRADE_AGGREGATE_SUM);
1960
    }
1961
 
1962
    /**
1963
     * Returns true if category uses special aggregation coefficient
1964
     *
1965
     * @return bool True if an aggregation coefficient is being used
1966
     */
1967
    public function is_aggregationcoef_used() {
1968
        return self::aggregation_uses_aggregationcoef($this->aggregation);
1969
 
1970
    }
1971
 
1972
    /**
1973
     * Returns true if aggregation uses aggregationcoef
1974
     *
1975
     * @param int $aggregation Aggregation const.
1976
     * @return bool True if an aggregation coefficient is being used
1977
     */
1978
    public static function aggregation_uses_aggregationcoef($aggregation) {
1979
        return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN
1980
             or $aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1981
             or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1982
             or $aggregation == GRADE_AGGREGATE_SUM);
1983
 
1984
    }
1985
 
1986
    /**
1987
     * Recursive function to find which weight/extra credit field to use in the grade item form.
1988
     *
1989
     * @param string $first Whether or not this is the first item in the recursion
1990
     * @return string
1991
     */
1992
    public function get_coefstring($first=true) {
1993
        if (!is_null($this->coefstring)) {
1994
            return $this->coefstring;
1995
        }
1996
 
1997
        $overriding_coefstring = null;
1998
 
1999
        // Stop recursing upwards if this category has no parent
2000
        if (!$first) {
2001
 
2002
            if ($parent_category = $this->load_parent_category()) {
2003
                return $parent_category->get_coefstring(false);
2004
 
2005
            } else {
2006
                return null;
2007
            }
2008
 
2009
        } else if ($first) {
2010
 
2011
            if ($parent_category = $this->load_parent_category()) {
2012
                $overriding_coefstring = $parent_category->get_coefstring(false);
2013
            }
2014
        }
2015
 
2016
        // If an overriding coefstring has trickled down from one of the parent categories, return it. Otherwise, return self.
2017
        if (!is_null($overriding_coefstring)) {
2018
            return $overriding_coefstring;
2019
        }
2020
 
2021
        // No parent category is overriding this category's aggregation, return its string
2022
        if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN) {
2023
            $this->coefstring = 'aggregationcoefweight';
2024
 
2025
        } else if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2) {
2026
            $this->coefstring = 'aggregationcoefextrasum';
2027
 
2028
        } else if ($this->aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN) {
2029
            $this->coefstring = 'aggregationcoefextraweight';
2030
 
2031
        } else if ($this->aggregation == GRADE_AGGREGATE_SUM) {
2032
            $this->coefstring = 'aggregationcoefextraweightsum';
2033
 
2034
        } else {
2035
            $this->coefstring = 'aggregationcoef';
2036
        }
2037
        return $this->coefstring;
2038
    }
2039
 
2040
    /**
2041
     * Returns tree with all grade_items and categories as elements
2042
     *
2043
     * @param int $courseid The course ID
2044
     * @param bool $include_category_items as category children
2045
     * @return array
2046
     */
2047
    public static function fetch_course_tree($courseid, $include_category_items=false) {
2048
        $course_category = grade_category::fetch_course_category($courseid);
2049
        $category_array = array('object'=>$course_category, 'type'=>'category', 'depth'=>1,
2050
                                'children'=>$course_category->get_children($include_category_items));
2051
 
2052
        $course_category->sortorder = $course_category->get_sortorder();
2053
        $sortorder = $course_category->get_sortorder();
2054
        return grade_category::_fetch_course_tree_recursion($category_array, $sortorder);
2055
    }
2056
 
2057
    /**
2058
     * An internal function that recursively sorts grade categories within a course
2059
     *
2060
     * @param array $category_array The seed of the recursion
2061
     * @param int   $sortorder The current sortorder
2062
     * @return array An array containing 'object', 'type', 'depth' and optionally 'children'
2063
     */
2064
    private static function _fetch_course_tree_recursion($category_array, &$sortorder) {
2065
        if (isset($category_array['object']->gradetype) && $category_array['object']->gradetype==GRADE_TYPE_NONE) {
2066
            return null;
2067
        }
2068
 
2069
        // store the grade_item or grade_category instance with extra info
2070
        $result = array('object'=>$category_array['object'], 'type'=>$category_array['type'], 'depth'=>$category_array['depth']);
2071
 
2072
        // reuse final grades if there
2073
        if (array_key_exists('finalgrades', $category_array)) {
2074
            $result['finalgrades'] = $category_array['finalgrades'];
2075
        }
2076
 
2077
        // recursively resort children
2078
        if (!empty($category_array['children'])) {
2079
            $result['children'] = array();
2080
            //process the category item first
2081
            $child = null;
2082
 
2083
            foreach ($category_array['children'] as $oldorder=>$child_array) {
2084
 
2085
                if ($child_array['type'] == 'courseitem' or $child_array['type'] == 'categoryitem') {
2086
                    $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
2087
                    if (!empty($child)) {
2088
                        $result['children'][$sortorder] = $child;
2089
                    }
2090
                }
2091
            }
2092
 
2093
            foreach ($category_array['children'] as $oldorder=>$child_array) {
2094
 
2095
                if ($child_array['type'] != 'courseitem' and $child_array['type'] != 'categoryitem') {
2096
                    $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
2097
                    if (!empty($child)) {
2098
                        $result['children'][++$sortorder] = $child;
2099
                    }
2100
                }
2101
            }
2102
        }
2103
 
2104
        return $result;
2105
    }
2106
 
2107
    /**
2108
     * Fetches and returns all the children categories and/or grade_items belonging to this category.
2109
     * By default only returns the immediate children (depth=1), but deeper levels can be requested,
2110
     * as well as all levels (0). The elements are indexed by sort order.
2111
     *
2112
     * @param bool $include_category_items Whether or not to include category grade_items in the children array
2113
     * @return array Array of child objects (grade_category and grade_item).
2114
     */
2115
    public function get_children($include_category_items=false) {
2116
        global $DB;
2117
 
2118
        // This function must be as fast as possible ;-)
2119
        // fetch all course grade items and categories into memory - we do not expect hundreds of these in course
2120
        // we have to limit the number of queries though, because it will be used often in grade reports
2121
 
2122
        $cats  = $DB->get_records('grade_categories', array('courseid' => $this->courseid));
2123
        $items = $DB->get_records('grade_items', array('courseid' => $this->courseid));
2124
 
2125
        // init children array first
2126
        foreach ($cats as $catid=>$cat) {
2127
            $cats[$catid]->children = array();
2128
        }
2129
 
2130
        //first attach items to cats and add category sortorder
2131
        foreach ($items as $item) {
2132
 
2133
            if ($item->itemtype == 'course' or $item->itemtype == 'category') {
2134
                $cats[$item->iteminstance]->sortorder = $item->sortorder;
2135
 
2136
                if (!$include_category_items) {
2137
                    continue;
2138
                }
2139
                $categoryid = $item->iteminstance;
2140
 
2141
            } else {
2142
                $categoryid = $item->categoryid;
2143
                if (empty($categoryid)) {
2144
                    debugging('Found a grade item that isnt in a category');
2145
                }
2146
            }
2147
 
2148
            // prevent problems with duplicate sortorders in db
2149
            $sortorder = $item->sortorder;
2150
 
2151
            while (array_key_exists($categoryid, $cats)
2152
                && array_key_exists($sortorder, $cats[$categoryid]->children)) {
2153
 
2154
                $sortorder++;
2155
            }
2156
 
2157
            $cats[$categoryid]->children[$sortorder] = $item;
2158
 
2159
        }
2160
 
2161
        // now find the requested category and connect categories as children
2162
        $category = false;
2163
 
2164
        foreach ($cats as $catid=>$cat) {
2165
 
2166
            if (empty($cat->parent)) {
2167
 
2168
                if ($cat->path !== '/'.$cat->id.'/') {
2169
                    $grade_category = new grade_category($cat, false);
2170
                    $grade_category->path  = '/'.$cat->id.'/';
2171
                    $grade_category->depth = 1;
2172
                    $grade_category->update('system');
2173
                    return $this->get_children($include_category_items);
2174
                }
2175
 
2176
            } else {
2177
 
2178
                if (empty($cat->path) or !preg_match('|/'.$cat->parent.'/'.$cat->id.'/$|', $cat->path)) {
2179
                    //fix paths and depts
2180
                    static $recursioncounter = 0; // prevents infinite recursion
2181
                    $recursioncounter++;
2182
 
2183
                    if ($recursioncounter < 5) {
2184
                        // fix paths and depths!
2185
                        $grade_category = new grade_category($cat, false);
2186
                        $grade_category->depth = 0;
2187
                        $grade_category->path  = null;
2188
                        $grade_category->update('system');
2189
                        return $this->get_children($include_category_items);
2190
                    }
2191
                }
2192
                // prevent problems with duplicate sortorders in db
2193
                $sortorder = $cat->sortorder;
2194
 
2195
                while (array_key_exists($sortorder, $cats[$cat->parent]->children)) {
2196
                    //debugging("$sortorder exists in cat loop");
2197
                    $sortorder++;
2198
                }
2199
 
2200
                $cats[$cat->parent]->children[$sortorder] = &$cats[$catid];
2201
            }
2202
 
2203
            if ($catid == $this->id) {
2204
                $category = &$cats[$catid];
2205
            }
2206
        }
2207
 
2208
        unset($items); // not needed
2209
        unset($cats); // not needed
2210
 
2211
        $children_array = array();
2212
        if (is_object($category)) {
2213
            $children_array = grade_category::_get_children_recursion($category);
2214
            ksort($children_array);
2215
        }
2216
 
2217
        return $children_array;
2218
 
2219
    }
2220
 
2221
    /**
2222
     * Private method used to retrieve all children of this category recursively
2223
     *
2224
     * @param grade_category $category Source of current recursion
2225
     * @return array An array of child grade categories
2226
     */
2227
    private static function _get_children_recursion($category) {
2228
 
2229
        $children_array = array();
2230
        foreach ($category->children as $sortorder=>$child) {
2231
 
2232
            if (property_exists($child, 'itemtype')) {
2233
                $grade_item = new grade_item($child, false);
2234
 
2235
                if (in_array($grade_item->itemtype, array('course', 'category'))) {
2236
                    $type  = $grade_item->itemtype.'item';
2237
                    $depth = $category->depth;
2238
 
2239
                } else {
2240
                    $type  = 'item';
2241
                    $depth = $category->depth; // we use this to set the same colour
2242
                }
2243
                $children_array[$sortorder] = array('object'=>$grade_item, 'type'=>$type, 'depth'=>$depth);
2244
 
2245
            } else {
2246
                $children = grade_category::_get_children_recursion($child);
2247
                $grade_category = new grade_category($child, false);
2248
 
2249
                if (empty($children)) {
2250
                    $children = array();
2251
                }
2252
                $children_array[$sortorder] = array('object'=>$grade_category, 'type'=>'category', 'depth'=>$grade_category->depth, 'children'=>$children);
2253
            }
2254
        }
2255
 
2256
        // sort the array
2257
        ksort($children_array);
2258
 
2259
        return $children_array;
2260
    }
2261
 
2262
    /**
2263
     * Uses {@link get_grade_item()} to load or create a grade_item, then saves it as $this->grade_item.
2264
     *
2265
     * @return grade_item
2266
     */
2267
    public function load_grade_item() {
2268
        if (empty($this->grade_item)) {
2269
            $this->grade_item = $this->get_grade_item();
2270
        }
2271
        return $this->grade_item;
2272
    }
2273
 
2274
    /**
2275
     * Retrieves this grade categories' associated grade_item from the database
2276
     *
2277
     * If no grade_item exists yet, creates one.
2278
     *
2279
     * @return grade_item
2280
     */
2281
    public function get_grade_item() {
2282
        if (empty($this->id)) {
2283
            debugging("Attempt to obtain a grade_category's associated grade_item without the category's ID being set.");
2284
            return false;
2285
        }
2286
 
2287
        if (empty($this->parent)) {
2288
            $params = array('courseid'=>$this->courseid, 'itemtype'=>'course', 'iteminstance'=>$this->id);
2289
 
2290
        } else {
2291
            $params = array('courseid'=>$this->courseid, 'itemtype'=>'category', 'iteminstance'=>$this->id);
2292
        }
2293
 
2294
        if (!$grade_items = grade_item::fetch_all($params)) {
2295
            // create a new one
2296
            $grade_item = new grade_item($params, false);
2297
            $grade_item->gradetype = GRADE_TYPE_VALUE;
2298
            $grade_item->insert('system');
2299
 
2300
        } else if (count($grade_items) == 1) {
2301
            // found existing one
2302
            $grade_item = reset($grade_items);
2303
 
2304
        } else {
2305
            debugging("Found more than one grade_item attached to category id:".$this->id);
2306
            // return first one
2307
            $grade_item = reset($grade_items);
2308
        }
2309
 
2310
        return $grade_item;
2311
    }
2312
 
2313
    /**
2314
     * Uses $this->parent to instantiate $this->parent_category based on the referenced record in the DB
2315
     *
2316
     * @return grade_category The parent category
2317
     */
2318
    public function load_parent_category() {
2319
        if (empty($this->parent_category) && !empty($this->parent)) {
2320
            $this->parent_category = $this->get_parent_category();
2321
        }
2322
        return $this->parent_category;
2323
    }
2324
 
2325
    /**
2326
     * Uses $this->parent to instantiate and return a grade_category object
2327
     *
2328
     * @return grade_category Returns the parent category or null if this category has no parent
2329
     */
2330
    public function get_parent_category() {
2331
        if (!empty($this->parent)) {
2332
            $parent_category = new grade_category(array('id' => $this->parent));
2333
            return $parent_category;
2334
        } else {
2335
            return null;
2336
        }
2337
    }
2338
 
2339
    /**
2340
     * Returns the most descriptive field for this grade category
2341
     *
2342
     * @return string name
2343
     * @param bool $escape Whether the returned category name is to be HTML escaped or not.
2344
     */
2345
    public function get_name($escape = true) {
2346
        global $DB;
2347
        // For a course category, we return the course name if the fullname is set to '?' in the DB (empty in the category edit form)
2348
        if (empty($this->parent) && $this->fullname == '?') {
2349
            $course = $DB->get_record('course', array('id'=> $this->courseid));
2350
            return format_string($course->fullname, false, ['context' => context_course::instance($this->courseid),
2351
                'escape' => $escape]);
2352
 
2353
        } else {
2354
            // Grade categories can't be set up at system context (unlike scales and outcomes)
2355
            // We therefore must have a courseid, and don't need to handle system contexts when filtering.
2356
            return format_string($this->fullname, false, ['context' => context_course::instance($this->courseid),
2357
                'escape' => $escape]);
2358
        }
2359
    }
2360
 
2361
    /**
2362
     * Describe the aggregation settings for this category so the reports make more sense.
2363
     *
2364
     * @return string description
2365
     */
2366
    public function get_description() {
2367
        $allhelp = array();
2368
        if ($this->aggregation != GRADE_AGGREGATE_SUM) {
2369
            $aggrstrings = grade_helper::get_aggregation_strings();
2370
            $allhelp[] = $aggrstrings[$this->aggregation];
2371
        }
2372
 
2373
        if ($this->droplow && $this->can_apply_limit_rules()) {
2374
            $allhelp[] = get_string('droplowestvalues', 'grades', $this->droplow);
2375
        }
2376
        if ($this->keephigh && $this->can_apply_limit_rules()) {
2377
            $allhelp[] = get_string('keephighestvalues', 'grades', $this->keephigh);
2378
        }
2379
        if (!$this->aggregateonlygraded) {
2380
            $allhelp[] = get_string('aggregatenotonlygraded', 'grades');
2381
        }
2382
        if ($allhelp) {
2383
            return implode('. ', $allhelp) . '.';
2384
        }
2385
        return '';
2386
    }
2387
 
2388
    /**
2389
     * Sets this category's parent id
2390
     *
2391
     * @param int $parentid The ID of the category that is the new parent to $this
2392
     * @param string $source From where was the object updated (mod/forum, manual, etc.)
2393
     * @return bool success
2394
     */
2395
    public function set_parent($parentid, $source=null) {
2396
        if ($this->parent == $parentid) {
2397
            return true;
2398
        }
2399
 
2400
        if ($parentid == $this->id) {
2401
            throw new \moodle_exception('cannotassignselfasparent');
2402
        }
2403
 
2404
        if (empty($this->parent) and $this->is_course_category()) {
2405
            throw new \moodle_exception('cannothaveparentcate');
2406
        }
2407
 
2408
        // find parent and check course id
2409
        if (!$parent_category = grade_category::fetch(array('id'=>$parentid, 'courseid'=>$this->courseid))) {
2410
            return false;
2411
        }
2412
 
2413
        $this->force_regrading();
2414
 
2415
        // set new parent category
2416
        $this->parent          = $parent_category->id;
2417
        $this->parent_category =& $parent_category;
2418
        $this->path            = null;       // remove old path and depth - will be recalculated in update()
2419
        $this->depth           = 0;          // remove old path and depth - will be recalculated in update()
2420
        $this->update($source);
2421
 
2422
        return $this->update($source);
2423
    }
2424
 
2425
    /**
2426
     * Returns the final grade values for this grade category.
2427
     *
2428
     * @param int $userid Optional user ID to retrieve a single user's final grade
2429
     * @return mixed An array of all final_grades (stdClass objects) for this grade_item, or a single final_grade.
2430
     */
2431
    public function get_final($userid=null) {
2432
        $this->load_grade_item();
2433
        return $this->grade_item->get_final($userid);
2434
    }
2435
 
2436
    /**
2437
     * Returns the sortorder of the grade categories' associated grade_item
2438
     *
2439
     * This method is also available in grade_item for cases where the object type is not known.
2440
     *
2441
     * @return int Sort order
2442
     */
2443
    public function get_sortorder() {
2444
        $this->load_grade_item();
2445
        return $this->grade_item->get_sortorder();
2446
    }
2447
 
2448
    /**
2449
     * Returns the idnumber of the grade categories' associated grade_item.
2450
     *
2451
     * This method is also available in grade_item for cases where the object type is not known.
2452
     *
2453
     * @return string idnumber
2454
     */
2455
    public function get_idnumber() {
2456
        $this->load_grade_item();
2457
        return $this->grade_item->get_idnumber();
2458
    }
2459
 
2460
    /**
2461
     * Sets the sortorder variable for this category.
2462
     *
2463
     * This method is also available in grade_item, for cases where the object type is not know.
2464
     *
2465
     * @param int $sortorder The sortorder to assign to this category
2466
     */
2467
    public function set_sortorder($sortorder) {
2468
        $this->load_grade_item();
2469
        $this->grade_item->set_sortorder($sortorder);
2470
    }
2471
 
2472
    /**
2473
     * Move this category after the given sortorder
2474
     *
2475
     * Does not change the parent
2476
     *
2477
     * @param int $sortorder to place after.
2478
     * @return void
2479
     */
2480
    public function move_after_sortorder($sortorder) {
2481
        $this->load_grade_item();
2482
        $this->grade_item->move_after_sortorder($sortorder);
2483
    }
2484
 
2485
    /**
2486
     * Return true if this is the top most category that represents the total course grade.
2487
     *
2488
     * @return bool
2489
     */
2490
    public function is_course_category() {
2491
        $this->load_grade_item();
2492
        return $this->grade_item->is_course_item();
2493
    }
2494
 
2495
    /**
2496
     * Return the course level grade_category object
2497
     *
2498
     * @param int $courseid The Course ID
2499
     * @return grade_category Returns the course level grade_category instance
2500
     */
2501
    public static function fetch_course_category($courseid) {
2502
        if (empty($courseid)) {
2503
            debugging('Missing course id!');
2504
            return false;
2505
        }
2506
 
2507
        // course category has no parent
2508
        if ($course_category = grade_category::fetch(array('courseid'=>$courseid, 'parent'=>null))) {
2509
            return $course_category;
2510
        }
2511
 
2512
        // create a new one
2513
        $course_category = new grade_category();
2514
        $course_category->insert_course_category($courseid);
2515
 
2516
        return $course_category;
2517
    }
2518
 
2519
    /**
2520
     * Is grading object editable?
2521
     *
2522
     * @return bool
2523
     */
2524
    public function is_editable() {
2525
        return true;
2526
    }
2527
 
2528
    /**
2529
     * Returns the locked state/date of the grade categories' associated grade_item.
2530
     *
2531
     * This method is also available in grade_item, for cases where the object type is not known.
2532
     *
2533
     * @return bool
2534
     */
2535
    public function is_locked() {
2536
        $this->load_grade_item();
2537
        return $this->grade_item->is_locked();
2538
    }
2539
 
2540
    /**
2541
     * Sets the grade_item's locked variable and updates the grade_item.
2542
     *
2543
     * Calls set_locked() on the categories' grade_item
2544
     *
2545
     * @param int  $lockedstate 0, 1 or a timestamp int(10) after which date the item will be locked.
2546
     * @param bool $cascade lock/unlock child objects too
2547
     * @param bool $refresh refresh grades when unlocking
2548
     * @return bool success if category locked (not all children mayb be locked though)
2549
     */
2550
    public function set_locked($lockedstate, $cascade=false, $refresh=true) {
2551
        $this->load_grade_item();
2552
 
2553
        $result = $this->grade_item->set_locked($lockedstate, $cascade, true);
2554
 
2555
        // Process all children - items and categories.
2556
        if ($children = grade_item::fetch_all(['categoryid' => $this->id])) {
2557
            foreach ($children as $child) {
2558
                $child->set_locked($lockedstate, $cascade, false);
2559
 
2560
                if (empty($lockedstate) && $refresh) {
2561
                    // Refresh when unlocking.
2562
                    $child->refresh_grades();
2563
                }
2564
            }
2565
        }
2566
 
2567
        if ($children = static::fetch_all(['parent' => $this->id])) {
2568
            foreach ($children as $child) {
2569
                $child->set_locked($lockedstate, $cascade, true);
2570
            }
2571
        }
2572
 
2573
        return $result;
2574
    }
2575
 
2576
    /**
2577
     * Overrides grade_object::set_properties() to add special handling for changes to category aggregation types
2578
     *
2579
     * @param grade_category $instance the object to set the properties on
2580
     * @param array|stdClass $params Either an associative array or an object containing property name, property value pairs
2581
     */
2582
    public static function set_properties(&$instance, $params) {
2583
        global $DB;
2584
 
2585
        $fromaggregation = $instance->aggregation;
2586
 
2587
        parent::set_properties($instance, $params);
2588
 
2589
        // The aggregation method is changing and this category has already been saved.
2590
        if (isset($params->aggregation) && !empty($instance->id)) {
2591
            $achildwasdupdated = false;
2592
 
2593
            // Get all its children.
2594
            $children = $instance->get_children();
2595
            foreach ($children as $child) {
2596
                $item = $child['object'];
2597
                if ($child['type'] == 'category') {
2598
                    $item = $item->load_grade_item();
2599
                }
2600
 
2601
                // Set the new aggregation fields.
2602
                if ($item->set_aggregation_fields_for_aggregation($fromaggregation, $params->aggregation)) {
2603
                    $item->update();
2604
                    $achildwasdupdated = true;
2605
                }
2606
            }
2607
 
2608
            // If this is the course category, it is possible that its grade item was set as needsupdate
2609
            // by one of its children. If we keep a reference to that stale object we might cause the
2610
            // needsupdate flag to be lost. It's safer to just reload the grade_item from the database.
2611
            if ($achildwasdupdated && !empty($instance->grade_item) && $instance->is_course_category()) {
2612
                $instance->grade_item = null;
2613
                $instance->load_grade_item();
2614
            }
2615
        }
2616
    }
2617
 
2618
    /**
2619
     * Sets the grade_item's hidden variable and updates the grade_item.
2620
     *
2621
     * Overrides grade_item::set_hidden() to add cascading of the hidden value to grade items in this grade category
2622
     *
2623
     * @param int $hidden 0 mean always visible, 1 means always hidden and a number > 1 is a timestamp to hide until
2624
     * @param bool $cascade apply to child objects too
2625
     */
2626
    public function set_hidden($hidden, $cascade=false) {
2627
        $this->load_grade_item();
2628
        //this hides the category itself and everything it contains
2629
        parent::set_hidden($hidden, $cascade);
2630
 
2631
        if ($cascade) {
2632
 
2633
            // This hides the associated grade item (the course/category total).
2634
            $this->grade_item->set_hidden($hidden, $cascade);
2635
 
2636
            if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
2637
 
2638
                foreach ($children as $child) {
2639
                    if ($child->can_control_visibility()) {
2640
                        $child->set_hidden($hidden, $cascade);
2641
                    }
2642
                }
2643
            }
2644
 
2645
            if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
2646
 
2647
                foreach ($children as $child) {
2648
                    $child->set_hidden($hidden, $cascade);
2649
                }
2650
            }
2651
        }
2652
 
2653
        //if marking category visible make sure parent category is visible MDL-21367
2654
        if( !$hidden ) {
2655
            $category_array = grade_category::fetch_all(array('id'=>$this->parent));
2656
            if ($category_array && array_key_exists($this->parent, $category_array)) {
2657
                $category = $category_array[$this->parent];
2658
                //call set_hidden on the category regardless of whether it is hidden as its parent might be hidden
2659
                $category->set_hidden($hidden, false);
2660
            }
2661
        }
2662
    }
2663
 
2664
    /**
2665
     * Applies default settings on this category
2666
     *
2667
     * @return bool True if anything changed
2668
     */
2669
    public function apply_default_settings() {
2670
        global $CFG;
2671
 
2672
        foreach ($this->forceable as $property) {
2673
 
2674
            if (isset($CFG->{"grade_$property"})) {
2675
 
2676
                if ($CFG->{"grade_$property"} == -1) {
2677
                    continue; //temporary bc before version bump
2678
                }
2679
                $this->$property = $CFG->{"grade_$property"};
2680
            }
2681
        }
2682
    }
2683
 
2684
    /**
2685
     * Applies forced settings on this category
2686
     *
2687
     * @return bool True if anything changed
2688
     */
2689
    public function apply_forced_settings() {
2690
        global $CFG;
2691
 
2692
        $updated = false;
2693
 
2694
        foreach ($this->forceable as $property) {
2695
 
2696
            if (isset($CFG->{"grade_$property"}) and isset($CFG->{"grade_{$property}_flag"}) and
2697
                                                    ((int) $CFG->{"grade_{$property}_flag"} & 1)) {
2698
 
2699
                if ($CFG->{"grade_$property"} == -1) {
2700
                    continue; //temporary bc before version bump
2701
                }
2702
                $this->$property = $CFG->{"grade_$property"};
2703
                $updated = true;
2704
            }
2705
        }
2706
 
2707
        return $updated;
2708
    }
2709
 
2710
    /**
2711
     * Notification of change in forced category settings.
2712
     *
2713
     * Causes all course and category grade items to be marked as needing to be updated
2714
     */
2715
    public static function updated_forced_settings() {
2716
        global $CFG, $DB;
2717
        $params = array(1, 'course', 'category');
2718
        $sql = "UPDATE {grade_items} SET needsupdate=? WHERE itemtype=? or itemtype=?";
2719
        $DB->execute($sql, $params);
2720
    }
2721
 
2722
    /**
2723
     * Determine the default aggregation values for a given aggregation method.
2724
     *
2725
     * @param int $aggregationmethod The aggregation method constant value.
2726
     * @return array Containing the keys 'aggregationcoef', 'aggregationcoef2' and 'weightoverride'.
2727
     */
2728
    public static function get_default_aggregation_coefficient_values($aggregationmethod) {
2729
        $defaultcoefficients = array(
2730
            'aggregationcoef' => 0,
2731
            'aggregationcoef2' => 0,
2732
            'weightoverride' => 0
2733
        );
2734
 
2735
        switch ($aggregationmethod) {
2736
            case GRADE_AGGREGATE_WEIGHTED_MEAN:
2737
                $defaultcoefficients['aggregationcoef'] = 1;
2738
                break;
2739
            case GRADE_AGGREGATE_SUM:
2740
                $defaultcoefficients['aggregationcoef2'] = 1;
2741
                break;
2742
        }
2743
 
2744
        return $defaultcoefficients;
2745
    }
2746
 
2747
    /**
2748
     * Cleans the cache.
2749
     *
2750
     * We invalidate them all so it can be completely reloaded.
2751
     *
2752
     * Being conservative here, if there is a new grade_category we purge them, the important part
2753
     * is that this is not purged when there are no changes in grade_categories.
2754
     *
2755
     * @param bool $deleted
2756
     * @return void
2757
     */
2758
    protected function notify_changed($deleted) {
2759
        self::clean_record_set();
2760
    }
2761
 
2762
    /**
2763
     * Generates a unique key per query.
2764
     *
2765
     * Not unique between grade_object children. self::retrieve_record_set and self::set_record_set will be in charge of
2766
     * selecting the appropriate cache.
2767
     *
2768
     * @param array $params An array of conditions like $fieldname => $fieldvalue
2769
     * @return string
2770
     */
2771
    protected static function generate_record_set_key($params) {
2772
        return sha1(json_encode($params));
2773
    }
2774
 
2775
    /**
2776
     * Tries to retrieve a record set from the cache.
2777
     *
2778
     * @param array $params The query params
2779
     * @return grade_object[]|bool An array of grade_objects or false if not found.
2780
     */
2781
    protected static function retrieve_record_set($params) {
2782
        $cache = cache::make('core', 'grade_categories');
2783
        return $cache->get(self::generate_record_set_key($params));
2784
    }
2785
 
2786
    /**
2787
     * Sets a result to the records cache, even if there were no results.
2788
     *
2789
     * @param string $params The query params
2790
     * @param grade_object[]|bool $records An array of grade_objects or false if there are no records matching the $key filters
2791
     * @return void
2792
     */
2793
    protected static function set_record_set($params, $records) {
2794
        $cache = cache::make('core', 'grade_categories');
2795
        return $cache->set(self::generate_record_set_key($params), $records);
2796
    }
2797
 
2798
    /**
2799
     * Cleans the cache.
2800
     *
2801
     * Aggressive deletion to be conservative given the gradebook design.
2802
     * The key is based on the requested params, not easy nor worth to purge selectively.
2803
     *
2804
     * @return void
2805
     */
2806
    public static function clean_record_set() {
2807
        cache_helper::purge_by_event('changesingradecategories');
2808
    }
2809
}