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