MDL-47059 Grades: Update the grade_grade if the finalgrade/or the grademax has changed.
[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('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                                  'aggregatesubcats', '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_MEAN;
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      * Ignore subcategories when aggregating
121      * @var int $aggregatesubcats
122      */
123     public $aggregatesubcats = 0;
125     /**
126      * Array of grade_items or grade_categories nested exactly 1 level below this category
127      * @var array $children
128      */
129     public $children;
131     /**
132      * A hierarchical array of all children below this category. This is stored separately from
133      * $children because it is more memory-intensive and may not be used as often.
134      * @var array $all_children
135      */
136     public $all_children;
138     /**
139      * An associated grade_item object, with itemtype=category, used to calculate and cache a set of grade values
140      * for this category.
141      * @var grade_item $grade_item
142      */
143     public $grade_item;
145     /**
146      * Temporary sortorder for speedup of children resorting
147      * @var int $sortorder
148      */
149     public $sortorder;
151     /**
152      * List of options which can be "forced" from site settings.
153      * @var array $forceable
154      */
155     public $forceable = array('aggregation', 'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes', 'aggregatesubcats');
157     /**
158      * String representing the aggregation coefficient. Variable is used as cache.
159      * @var string $coefstring
160      */
161     public $coefstring = null;
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         return grade_object::fetch_helper('grade_categories', 'grade_category', $params);
192     }
194     /**
195      * Finds and returns all grade_category instances based on params.
196      *
197      * @param array $params associative arrays varname=>value
198      * @return array array of grade_category insatnces or false if none found.
199      */
200     public static function fetch_all($params) {
201         return grade_object::fetch_all_helper('grade_categories', 'grade_category', $params);
202     }
204     /**
205      * In addition to update() as defined in grade_object, call force_regrading of parent categories, if applicable.
206      *
207      * @param string $source from where was the object updated (mod/forum, manual, etc.)
208      * @return bool success
209      */
210     public function update($source=null) {
211         // load the grade item or create a new one
212         $this->load_grade_item();
214         // force recalculation of path;
215         if (empty($this->path)) {
216             $this->path  = grade_category::build_path($this);
217             $this->depth = substr_count($this->path, '/') - 1;
218             $updatechildren = true;
220         } else {
221             $updatechildren = false;
222         }
224         $this->apply_forced_settings();
226         // these are exclusive
227         if ($this->droplow > 0) {
228             $this->keephigh = 0;
230         } else if ($this->keephigh > 0) {
231             $this->droplow = 0;
232         }
234         // Recalculate grades if needed
235         if ($this->qualifies_for_regrading()) {
236             $this->force_regrading();
237         }
239         $this->timemodified = time();
241         $result = parent::update($source);
243         // now update paths in all child categories
244         if ($result and $updatechildren) {
246             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
248                 foreach ($children as $child) {
249                     $child->path  = null;
250                     $child->depth = 0;
251                     $child->update($source);
252                 }
253             }
254         }
256         return $result;
257     }
259     /**
260      * If parent::delete() is successful, send force_regrading message to parent category.
261      *
262      * @param string $source from where was the object deleted (mod/forum, manual, etc.)
263      * @return bool success
264      */
265     public function delete($source=null) {
266         $grade_item = $this->load_grade_item();
268         if ($this->is_course_category()) {
270             if ($categories = grade_category::fetch_all(array('courseid'=>$this->courseid))) {
272                 foreach ($categories as $category) {
274                     if ($category->id == $this->id) {
275                         continue; // do not delete course category yet
276                     }
277                     $category->delete($source);
278                 }
279             }
281             if ($items = grade_item::fetch_all(array('courseid'=>$this->courseid))) {
283                 foreach ($items as $item) {
285                     if ($item->id == $grade_item->id) {
286                         continue; // do not delete course item yet
287                     }
288                     $item->delete($source);
289                 }
290             }
292         } else {
293             $this->force_regrading();
295             $parent = $this->load_parent_category();
297             // Update children's categoryid/parent field first
298             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
299                 foreach ($children as $child) {
300                     $child->set_parent($parent->id);
301                 }
302             }
304             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
305                 foreach ($children as $child) {
306                     $child->set_parent($parent->id);
307                 }
308             }
309         }
311         // first delete the attached grade item and grades
312         $grade_item->delete($source);
314         // delete category itself
315         return parent::delete($source);
316     }
318     /**
319      * In addition to the normal insert() defined in grade_object, this method sets the depth
320      * and path for this object, and update the record accordingly.
321      *
322      * We do this here instead of in the constructor as they both need to know the record's
323      * ID number, which only gets created at insertion time.
324      * This method also creates an associated grade_item if this wasn't done during construction.
325      *
326      * @param string $source from where was the object inserted (mod/forum, manual, etc.)
327      * @return int PK ID if successful, false otherwise
328      */
329     public function insert($source=null) {
331         if (empty($this->courseid)) {
332             print_error('cannotinsertgrade');
333         }
335         if (empty($this->parent)) {
336             $course_category = grade_category::fetch_course_category($this->courseid);
337             $this->parent = $course_category->id;
338         }
340         $this->path = null;
342         $this->timecreated = $this->timemodified = time();
344         if (!parent::insert($source)) {
345             debugging("Could not insert this category: " . print_r($this, true));
346             return false;
347         }
349         $this->force_regrading();
351         // build path and depth
352         $this->update($source);
354         return $this->id;
355     }
357     /**
358      * Internal function - used only from fetch_course_category()
359      * Normal insert() can not be used for course category
360      *
361      * @param int $courseid The course ID
362      * @return int The ID of the new course category
363      */
364     public function insert_course_category($courseid) {
365         $this->courseid    = $courseid;
366         $this->fullname    = '?';
367         $this->path        = null;
368         $this->parent      = null;
369         $this->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
371         $this->apply_default_settings();
372         $this->apply_forced_settings();
374         $this->timecreated = $this->timemodified = time();
376         if (!parent::insert('system')) {
377             debugging("Could not insert this category: " . print_r($this, true));
378             return false;
379         }
381         // build path and depth
382         $this->update('system');
384         return $this->id;
385     }
387     /**
388      * Compares the values held by this object with those of the matching record in DB, and returns
389      * whether or not these differences are sufficient to justify an update of all parent objects.
390      * This assumes that this object has an ID number and a matching record in DB. If not, it will return false.
391      *
392      * @return bool
393      */
394     public function qualifies_for_regrading() {
395         if (empty($this->id)) {
396             debugging("Can not regrade non existing category");
397             return false;
398         }
400         $db_item = grade_category::fetch(array('id'=>$this->id));
402         $aggregationdiff = $db_item->aggregation         != $this->aggregation;
403         $keephighdiff    = $db_item->keephigh            != $this->keephigh;
404         $droplowdiff     = $db_item->droplow             != $this->droplow;
405         $aggonlygrddiff  = $db_item->aggregateonlygraded != $this->aggregateonlygraded;
406         $aggoutcomesdiff = $db_item->aggregateoutcomes   != $this->aggregateoutcomes;
407         $aggsubcatsdiff  = $db_item->aggregatesubcats    != $this->aggregatesubcats;
409         return ($aggregationdiff || $keephighdiff || $droplowdiff || $aggonlygrddiff || $aggoutcomesdiff || $aggsubcatsdiff);
410     }
412     /**
413      * Marks this grade categories' associated grade item as needing regrading
414      */
415     public function force_regrading() {
416         $grade_item = $this->load_grade_item();
417         $grade_item->force_regrading();
418     }
420     /**
421      * Something that should be called before we start regrading the whole course.
422      *
423      * @return void
424      */
425     public function pre_regrade_final_grades() {
426         $this->auto_update_max();
427         $this->auto_update_weights();
428     }
430     /**
431      * Generates and saves final grades in associated category grade item.
432      * These immediate children must already have their own final grades.
433      * The category's aggregation method is used to generate final grades.
434      *
435      * Please note that category grade is either calculated or aggregated, not both at the same time.
436      *
437      * This method must be used ONLY from grade_item::regrade_final_grades(),
438      * because the calculation must be done in correct order!
439      *
440      * Steps to follow:
441      *  1. Get final grades from immediate children
442      *  3. Aggregate these grades
443      *  4. Save them in final grades of associated category grade item
444      *
445      * @param int $userid The user ID if final grade generation should be limited to a single user
446      * @return bool
447      */
448     public function generate_grades($userid=null) {
449         global $CFG, $DB;
451         $this->load_grade_item();
453         if ($this->grade_item->is_locked()) {
454             return true; // no need to recalculate locked items
455         }
457         // find grade items of immediate children (category or grade items) and force site settings
458         $depends_on = $this->grade_item->depends_on();
460         if (empty($depends_on)) {
461             $items = false;
463         } else {
464             list($usql, $params) = $DB->get_in_or_equal($depends_on);
465             $sql = "SELECT *
466                       FROM {grade_items}
467                      WHERE id $usql";
468             $items = $DB->get_records_sql($sql, $params);
469         }
471         $grade_inst = new grade_grade();
472         $fields = 'g.'.implode(',g.', $grade_inst->required_fields);
474         // where to look for final grades - include grade of this item too, we will store the results there
475         $gis = array_merge($depends_on, array($this->grade_item->id));
476         list($usql, $params) = $DB->get_in_or_equal($gis);
478         if ($userid) {
479             $usersql = "AND g.userid=?";
480             $params[] = $userid;
482         } else {
483             $usersql = "";
484         }
486         $sql = "SELECT $fields
487                   FROM {grade_grades} g, {grade_items} gi
488                  WHERE gi.id = g.itemid AND gi.id $usql $usersql
489               ORDER BY g.userid";
491         // group the results by userid and aggregate the grades for this user
492         $rs = $DB->get_recordset_sql($sql, $params);
493         if ($rs->valid()) {
494             $prevuser = 0;
495             $grade_values = array();
496             $excluded     = array();
497             $oldgrade     = null;
498             $grademaxoverrides = array();
499             $grademinoverrides = array();
501             foreach ($rs as $used) {
503                 if ($used->userid != $prevuser) {
504                     $this->aggregate_grades($prevuser,
505                                             $items,
506                                             $grade_values,
507                                             $oldgrade,
508                                             $excluded,
509                                             $grademinoverrides,
510                                             $grademaxoverrides);
511                     $prevuser = $used->userid;
512                     $grade_values = array();
513                     $excluded     = array();
514                     $oldgrade     = null;
515                     $grademaxoverrides = array();
516                     $grademinoverrides = array();
517                 }
518                 $grade_values[$used->itemid] = $used->finalgrade;
519                 $grademaxoverrides[$used->itemid] = $used->rawgrademax;
520                 $grademinoverrides[$used->itemid] = $used->rawgrademin;
522                 if ($used->excluded) {
523                     $excluded[] = $used->itemid;
524                 }
526                 if ($this->grade_item->id == $used->itemid) {
527                     $oldgrade = $used;
528                 }
529             }
530             $this->aggregate_grades($prevuser,
531                                     $items,
532                                     $grade_values,
533                                     $oldgrade,
534                                     $excluded,
535                                     $grademinoverrides,
536                                     $grademaxoverrides);//the last one
537         }
538         $rs->close();
540         return true;
541     }
543     /**
544      * Internal function for grade category grade aggregation
545      *
546      * @param int    $userid The User ID
547      * @param array  $items Grade items
548      * @param array  $grade_values Array of grade values
549      * @param object $oldgrade Old grade
550      * @param array  $excluded Excluded
551      * @param array  $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
552      * @param array  $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
553      */
554     private function aggregate_grades($userid,
555                                       $items,
556                                       $grade_values,
557                                       $oldgrade,
558                                       $excluded,
559                                       $grademinoverrides,
560                                       $grademaxoverrides) {
561         global $CFG;
563         // Remember these so we can set flags on them to describe how they were used in the aggregation.
564         $novalue = array();
565         $dropped = array();
566         $usedweights = array();
568         if (empty($userid)) {
569             //ignore first call
570             return;
571         }
573         if ($oldgrade) {
574             $oldfinalgrade = $oldgrade->finalgrade;
575             $grade = new grade_grade($oldgrade, false);
576             $grade->grade_item =& $this->grade_item;
578         } else {
579             // insert final grade - it will be needed later anyway
580             $grade = new grade_grade(array('itemid'=>$this->grade_item->id, 'userid'=>$userid), false);
581             $grade->grade_item =& $this->grade_item;
582             $grade->insert('system');
583             $oldfinalgrade = null;
584         }
586         // no need to recalculate locked or overridden grades
587         if ($grade->is_locked() or $grade->is_overridden()) {
588             return;
589         }
591         // can not use own final category grade in calculation
592         unset($grade_values[$this->grade_item->id]);
594         // if no grades calculation possible or grading not allowed clear final grade
595         if (empty($grade_values) or empty($items) or ($this->grade_item->gradetype != GRADE_TYPE_VALUE and $this->grade_item->gradetype != GRADE_TYPE_SCALE)) {
596             $grade->finalgrade = null;
598             if (!is_null($oldfinalgrade)) {
599                 $grade->update('aggregation');
600             }
601             $dropped = $grade_values;
602             $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped);
603             return;
604         }
606         $minvisible = (bool) get_config('moodle', 'grade_report_showmin');
608         // normalize the grades first - all will have value 0...1
609         // ungraded items are not used in aggregation
610         foreach ($grade_values as $itemid=>$v) {
611             // Natural weighting currently cannot exclude empty grades, or grades from excluded items.
612             if ($this->aggregation != GRADE_AGGREGATE_SUM) {
613                 if (is_null($v)) {
614                     // null means no grade
615                     unset($grade_values[$itemid]);
616                     $novalue[$itemid] = 0;
617                     continue;
618                 } else if (in_array($itemid, $excluded)) {
619                     unset($grade_values[$itemid]);
620                     $dropped[$itemid] = 0;
621                     continue;
622                 }
623             }
624             // Check for user specific grade min/max overrides.
625             $usergrademin = $items[$itemid]->grademin;
626             $usergrademax = $items[$itemid]->grademax;
627             if (isset($grademinoverrides[$itemid])) {
628                 $usergrademin = $grademinoverrides[$itemid];
629             }
630             if (isset($grademaxoverrides[$itemid])) {
631                 $usergrademax = $grademaxoverrides[$itemid];
632             }
633             $grade_values[$itemid] = grade_grade::standardise_score($v, $usergrademin, $usergrademax, 0, 1);
634         }
635         // use min grade if grade missing for these types
636         if (!$this->aggregateonlygraded) {
638             foreach ($items as $itemid=>$value) {
640                 if (!isset($grade_values[$itemid]) and !in_array($itemid, $excluded)) {
641                     $grade_values[$itemid] = 0;
642                 }
643             }
644         }
646         // limit and sort
647         $allvalues = $grade_values;
648         $this->apply_limit_rules($grade_values, $items);
650         $moredropped = array_diff($allvalues, $grade_values);
651         foreach ($moredropped as $drop => $unused) {
652             $dropped[$drop] = 0;
653         }
654         asort($grade_values, SORT_NUMERIC);
656         // let's see we have still enough grades to do any statistics
657         if (count($grade_values) == 0) {
658             // not enough attempts yet
659             $grade->finalgrade = null;
661             if (!is_null($oldfinalgrade)) {
662                 $grade->update('aggregation');
663             }
664             $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped);
665             return;
666         }
668         // do the maths
669         $result = $this->aggregate_values_and_adjust_bounds($grade_values,
670                                                             $items,
671                                                             $usedweights,
672                                                             $grademinoverrides,
673                                                             $grademaxoverrides);
674         $agg_grade = $result['grade'];
676         // Recalculate the grade back to requested range.
677         $finalgrade = grade_grade::standardise_score($agg_grade, 0, 1, $result['grademin'], $result['grademax']);
679         $grade->finalgrade = $this->grade_item->bounded_grade($finalgrade);
680         $oldrawgrademax = $grade->rawgrademax;
681         $grade->rawgrademax = $result['grademax'];
683         // update in db if changed
684         if (grade_floats_different($grade->finalgrade, $oldfinalgrade) ||
685             grade_floats_different($grade->rawgrademax, $oldrawgrademax)) {
686             $grade->update('aggregation');
687         }
689         $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped);
691         return;
692     }
694     /**
695      * Set the flags on the grade_grade items to indicate how individual grades are used
696      * in the aggregation.
697      *
698      * @param int $userid The user we have aggregated the grades for.
699      * @param array $usedweights An array with keys for each of the grade_item columns included in the aggregation. The value are the relative weight.
700      * @param array $novalue An array with keys for each of the grade_item columns skipped because
701      *                       they had no value in the aggregation
702      * @param array $dropped An array with keys for each of the grade_item columns dropped
703      *                       because of any drop lowest/highest settings in the aggregation
704      */
705     private function set_usedinaggregation($userid, $usedweights, $novalue, $dropped) {
706         global $DB;
708         // Included.
709         if (!empty($usedweights)) {
710             // The usedweights items are updated individually to record the weights.
711             foreach ($usedweights as $gradeitemid => $contribution) {
712                 $DB->set_field_select('grade_grades',
713                                       'aggregationweight',
714                                       $contribution,
715                                       "itemid = :itemid AND userid = :userid",
716                                       array('itemid'=>$gradeitemid, 'userid'=>$userid));
717             }
719             // Now set the status flag for all these weights.
720             list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($usedweights), SQL_PARAMS_NAMED, 'g');
721             $itemlist['userid'] = $userid;
723             $DB->set_field_select('grade_grades',
724                                   'aggregationstatus',
725                                   'used',
726                                   "itemid $itemsql AND userid = :userid",
727                                   $itemlist);
728         }
730         // No value.
731         if (!empty($novalue)) {
732             list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($novalue), SQL_PARAMS_NAMED, 'g');
734             $itemlist['userid'] = $userid;
736             $DB->set_field_select('grade_grades',
737                                   'aggregationstatus',
738                                   'novalue',
739                                   "itemid $itemsql AND userid = :userid",
740                                   $itemlist);
741         }
743         // Dropped.
744         if (!empty($dropped)) {
745             list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($dropped), SQL_PARAMS_NAMED, 'g');
747             $itemlist['userid'] = $userid;
749             $DB->set_field_select('grade_grades',
750                                   'aggregationstatus',
751                                   'dropped',
752                                   "itemid $itemsql AND userid = :userid",
753                                   $itemlist);
754         }
755     }
757     /**
758      * Internal function that calculates the aggregated grade and new min/max for this grade category
759      *
760      * Must be public as it is used by grade_grade::get_hiding_affected()
761      *
762      * @param array $grade_values An array of values to be aggregated
763      * @param array $items The array of grade_items
764      * @since Moodle 2.6.5, 2.7.2
765      * @param array & $weights If provided, will be filled with the normalized weights
766      *                         for each grade_item as used in the aggregation.
767      * @param array  $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
768      * @param array  $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
769      * @return array containing values for:
770      *                'grade' => the new calculated grade
771      *                'grademin' => the new calculated min grade for the category
772      *                'grademax' => the new calculated max grade for the category
773      */
774     public function aggregate_values_and_adjust_bounds($grade_values,
775                                                        $items,
776                                                        & $weights = null,
777                                                        $grademinoverrides = array(),
778                                                        $grademaxoverrides = array()) {
779         $category_item = $this->get_grade_item();
780         $grademin = $category_item->grademin;
781         $grademax = $category_item->grademax;
783         switch ($this->aggregation) {
785             case GRADE_AGGREGATE_MEDIAN: // Middle point value in the set: ignores frequencies
786                 $num = count($grade_values);
787                 $grades = array_values($grade_values);
789                 if ($num % 2 == 0) {
790                     $agg_grade = ($grades[intval($num/2)-1] + $grades[intval($num/2)]) / 2;
792                 } else {
793                     $agg_grade = $grades[intval(($num/2)-0.5)];
794                 }
796                 // Record the weights evenly.
797                 if ($weights !== null && $num > 0) {
798                     foreach ($grade_values as $itemid=>$grade_value) {
799                         $weights[$itemid] = 1.0 / $num;
800                     }
801                 }
802                 break;
804             case GRADE_AGGREGATE_MIN:
805                 $agg_grade = reset($grade_values);
806                 // Record the weights as used.
807                 if ($weights !== null) {
808                     foreach ($grade_values as $itemid=>$grade_value) {
809                         $weights[$itemid] = 0;
810                     }
811                 }
812                 // Set the first item to 1.
813                 $itemids = array_keys($grade_values);
814                 $weights[reset($itemids)] = 1;
815                 break;
817             case GRADE_AGGREGATE_MAX:
818                 // Record the weights as used.
819                 if ($weights !== null) {
820                     foreach ($grade_values as $itemid=>$grade_value) {
821                         $weights[$itemid] = 0;
822                     }
823                 }
824                 // Set the last item to 1.
825                 $itemids = array_keys($grade_values);
826                 $weights[end($itemids)] = 1;
827                 $agg_grade = end($grade_values);
828                 break;
830             case GRADE_AGGREGATE_MODE:       // the most common value
831                 // array_count_values only counts INT and STRING, so if grades are floats we must convert them to string
832                 $converted_grade_values = array();
834                 foreach ($grade_values as $k => $gv) {
836                     if (!is_int($gv) && !is_string($gv)) {
837                         $converted_grade_values[$k] = (string) $gv;
839                     } else {
840                         $converted_grade_values[$k] = $gv;
841                     }
842                     if ($weights !== null) {
843                         $weights[$k] = 0;
844                     }
845                 }
847                 $freq = array_count_values($converted_grade_values);
848                 arsort($freq);                      // sort by frequency keeping keys
849                 $top = reset($freq);               // highest frequency count
850                 $modes = array_keys($freq, $top);  // search for all modes (have the same highest count)
851                 rsort($modes, SORT_NUMERIC);       // get highest mode
852                 $agg_grade = reset($modes);
853                 // Record the weights as used.
854                 if ($weights !== null && $top > 0) {
855                     foreach ($grade_values as $k => $gv) {
856                         if ($gv == $agg_grade) {
857                             $weights[$k] = 1.0 / $top;
858                         }
859                     }
860                 }
861                 break;
863             case GRADE_AGGREGATE_WEIGHTED_MEAN: // Weighted average of all existing final grades, weight specified in coef
864                 $weightsum = 0;
865                 $sum       = 0;
867                 foreach ($grade_values as $itemid=>$grade_value) {
869                     if ($items[$itemid]->aggregationcoef <= 0) {
870                         continue;
871                     }
872                     $weightsum += $items[$itemid]->aggregationcoef;
873                     $sum       += $items[$itemid]->aggregationcoef * $grade_value;
874                     if ($weights !== null) {
875                         $weights[$itemid] = $items[$itemid]->aggregationcoef;
876                     }
877                 }
878                 if ($weightsum == 0) {
879                     $agg_grade = null;
881                 } else {
882                     $agg_grade = $sum / $weightsum;
883                     if ($weights !== null) {
884                         // Normalise the weights.
885                         foreach ($weights as $itemid => $weight) {
886                             $weights[$itemid] = $weight / $weightsum;
887                         }
888                     }
890                 }
891                 break;
893             case GRADE_AGGREGATE_WEIGHTED_MEAN2:
894                 // Weighted average of all existing final grades with optional extra credit flag,
895                 // weight is the range of grade (usually grademax)
896                 $weightsum = 0;
897                 $sum       = null;
899                 foreach ($grade_values as $itemid=>$grade_value) {
900                     $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
902                     if ($weight <= 0) {
903                         continue;
904                     }
906                     if ($items[$itemid]->aggregationcoef == 0) {
907                         $weightsum += $weight;
908                     }
909                     $sum += $weight * $grade_value;
910                 }
911                 if ($weightsum == 0) {
912                     $agg_grade = $sum; // only extra credits
914                 } else {
915                     $agg_grade = $sum / $weightsum;
916                 }
917                 // Record the weights as used.
918                 if ($weights !== null) {
919                     foreach ($grade_values as $itemid=>$grade_value) {
920                         if ($items[$itemid]->aggregationcoef == 0 && $weightsum > 0) {
921                             $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
922                             $weights[$itemid] = ($items[$itemid]->grademax - $items[$itemid]->grademin) / $weightsum;
923                         } else {
924                             $weights[$itemid] = 0;
925                         }
926                     }
927                 }
928                 break;
930             case GRADE_AGGREGATE_EXTRACREDIT_MEAN: // special average
931                 $num = 0;
932                 $sum = null;
934                 foreach ($grade_values as $itemid=>$grade_value) {
936                     if ($items[$itemid]->aggregationcoef == 0) {
937                         $num += 1;
938                         $sum += $grade_value;
939                         if ($weights !== null) {
940                             $weights[$itemid] = 1;
941                         }
943                     } else if ($items[$itemid]->aggregationcoef > 0) {
944                         $sum += $items[$itemid]->aggregationcoef * $grade_value;
945                         if ($weights !== null) {
946                             $weights[$itemid] = 0;
947                         }
948                     }
949                 }
950                 if ($weights !== null && $num > 0) {
951                     foreach ($grade_values as $itemid=>$grade_value) {
952                         if ($weights[$itemid]) {
953                             $weights[$itemid] = 1.0 / $num;
954                         }
955                     }
956                 }
958                 if ($num == 0) {
959                     $agg_grade = $sum; // only extra credits or wrong coefs
961                 } else {
962                     $agg_grade = $sum / $num;
963                 }
964                 break;
966             case GRADE_AGGREGATE_SUM:    // Add up all the items.
967                 $num = count($grade_values);
968                 $sum = 0;
969                 $sumweights = 0;
970                 $grademin = 0;
971                 $grademax = 0;
972                 foreach ($grade_values as $itemid => $gradevalue) {
973                     // We need to check if the grademax/min was adjusted per user because of excluded items.
974                     $usergrademin = $items[$itemid]->grademin;
975                     $usergrademax = $items[$itemid]->grademax;
976                     if (isset($grademinoverrides[$itemid])) {
977                         $usergrademin = $grademinoverrides[$itemid];
978                     }
979                     if (isset($grademaxoverrides[$itemid])) {
980                         $usergrademax = $grademaxoverrides[$itemid];
981                     }
982                     $gradeitemrange = $usergrademax - $usergrademin;
984                     // Extra credit.
985                     if (!($items[$itemid]->aggregationcoef > 0)) {
986                         $grademax += $gradeitemrange;
987                         $sumweights += $items[$itemid]->aggregationcoef2;
988                     }
989                 }
990                 $userweights = array();
991                 $totaloverriddenweight = 0;
992                 $totaloverriddengrademax = 0;
993                 // We first need to rescale all manually assigned weights down by the
994                 // percentage of weights missing from the category.
995                 foreach ($grade_values as $itemid => $gradevalue) {
996                     if ($items[$itemid]->weightoverride) {
997                         $userweights[$itemid] = $items[$itemid]->aggregationcoef2 / $sumweights;
998                         $totaloverriddenweight += $userweights[$itemid];
999                         $usergrademax = $items[$itemid]->grademax;
1000                         if (isset($grademaxoverrides[$itemid])) {
1001                             $usergrademax = $grademaxoverrides[$itemid];
1002                         }
1003                         $totaloverriddengrademax += $usergrademax;
1004                     }
1005                 }
1006                 $nonoverriddenpoints = $grademax - $totaloverriddengrademax;
1008                 // Then we need to recalculate the automatic weights.
1009                 foreach ($grade_values as $itemid => $gradevalue) {
1010                     if (!$items[$itemid]->weightoverride) {
1011                         if ($nonoverriddenpoints > 0) {
1012                             $usergrademax = $items[$itemid]->grademax;
1013                             if (isset($grademaxoverrides[$itemid])) {
1014                                 $usergrademax = $grademaxoverrides[$itemid];
1015                             }
1016                             $userweights[$itemid] = ($usergrademax/$nonoverriddenpoints) * (1 - $totaloverriddenweight);
1017                         } else {
1018                             $userweights[$itemid] = 0;
1019                         }
1020                     }
1021                 }
1023                 // We can use our freshly corrected weights below.
1024                 foreach ($grade_values as $itemid => $gradevalue) {
1025                     $sum += $gradevalue * $userweights[$itemid] * $grademax;
1026                     if ($weights !== null) {
1027                         $weights[$itemid] = $userweights[$itemid];
1028                     }
1029                 }
1030                 if ($grademax > 0) {
1031                     $agg_grade = $sum / $grademax; // Re-normalize score.
1032                 } else {
1033                     // Every item in the category is extra credit.
1034                     $agg_grade = $sum;
1035                     $grademax = $sum;
1036                 }
1038                 break;
1040             case GRADE_AGGREGATE_MEAN:    // Arithmetic average of all grade items (if ungraded aggregated, NULL counted as minimum)
1041             default:
1042                 $num = count($grade_values);
1043                 $sum = array_sum($grade_values);
1044                 $agg_grade = $sum / $num;
1045                 // Record the weights evenly.
1046                 if ($weights !== null && $num > 0) {
1047                     foreach ($grade_values as $itemid=>$grade_value) {
1048                         $weights[$itemid] = 1.0 / $num;
1049                     }
1050                 }
1051                 break;
1052         }
1054         return array('grade' => $agg_grade, 'grademin' => $grademin, 'grademax' => $grademax);
1055     }
1057     /**
1058      * Internal function that calculates the aggregated grade for this grade category
1059      *
1060      * Must be public as it is used by grade_grade::get_hiding_affected()
1061      *
1062      * @deprecated since Moodle 2.8
1063      * @param array $grade_values An array of values to be aggregated
1064      * @param array $items The array of grade_items
1065      * @return float The aggregate grade for this grade category
1066      */
1067     public function aggregate_values($grade_values, $items) {
1068         debugging('grade_category::aggregate_values() is deprecated.
1069                    Call grade_category::aggregate_values_and_adjust_bounds() instead.', DEBUG_DEVELOPER);
1070         $result = $this->aggregate_values_and_adjust_bounds($grade_values, $items);
1071         return $result['grade'];
1072     }
1074     /**
1075      * Some aggregation types may need to update their max grade.
1076      *
1077      * @return void
1078      */
1079     private function auto_update_max() {
1080         global $DB;
1081         if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1082             // not needed at all
1083             return;
1084         }
1086         // Find grade items of immediate children (category or grade items) and force site settings.
1087         $this->load_grade_item();
1088         $depends_on = $this->grade_item->depends_on();
1090         $items = false;
1091         if (!empty($depends_on)) {
1092             list($usql, $params) = $DB->get_in_or_equal($depends_on);
1093             $sql = "SELECT *
1094                       FROM {grade_items}
1095                      WHERE id $usql";
1096             $items = $DB->get_records_sql($sql, $params);
1097         }
1099         if (!$items) {
1101             if ($this->grade_item->grademax != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1102                 $this->grade_item->grademax  = 0;
1103                 $this->grade_item->grademin  = 0;
1104                 $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1105                 $this->grade_item->update('aggregation');
1106             }
1107             return;
1108         }
1110         //find max grade possible
1111         $maxes = array();
1113         foreach ($items as $item) {
1115             if ($item->aggregationcoef > 0) {
1116                 // extra credit from this activity - does not affect total
1117                 continue;
1118             }
1120             if ($item->gradetype == GRADE_TYPE_VALUE) {
1121                 $maxes[$item->id] = $item->grademax;
1123             } else if ($item->gradetype == GRADE_TYPE_SCALE) {
1124                 $maxes[$item->id] = $item->grademax; // 0 = nograde, 1 = first scale item, 2 = second scale item
1125             }
1126         }
1127         // apply droplow and keephigh
1128         $this->apply_limit_rules($maxes, $items);
1129         $max = array_sum($maxes);
1131         // update db if anything changed
1132         if ($this->grade_item->grademax != $max or $this->grade_item->grademin != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1133             $this->grade_item->grademax  = $max;
1134             $this->grade_item->grademin  = 0;
1135             $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1136             $this->grade_item->update('aggregation');
1137         }
1138     }
1140     /**
1141      * Recalculate the weights of the grade items in this category.
1142      *
1143      * @return void
1144      */
1145     private function auto_update_weights() {
1146         if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1147             // This is only required if we are using natural weights.
1148             return;
1149         }
1150         $children = $this->get_children();
1152         $grade_item = null;
1154         // Calculate the sum of the grademax's of all the items within this category.
1155         $totalgrademax = 0;
1157         // Out of 1, how much weight has been manually overriden by a user?
1158         $totaloverriddenweight  = 0;
1159         $totaloverriddengrademax  = 0;
1160         foreach ($children as $sortorder => $child) {
1161             $grade_item = null;
1163             if ($child['type'] == 'item') {
1164                 $grade_item = $child['object'];
1165             } else if ($child['type'] == 'category') {
1166                 $grade_item = $child['object']->load_grade_item();
1167             }
1169             // An extra credit grade item doesn't contribute to $totaloverriddengrademax.
1170             if ($grade_item->aggregationcoef > 0) {
1171                 continue;
1172             }
1174             $totalgrademax += $grade_item->grademax;
1175             if ($grade_item->weightoverride) {
1176                 $totaloverriddenweight += $grade_item->aggregationcoef2;
1177                 $totaloverriddengrademax += $grade_item->grademax;
1178             }
1179         }
1181         $totalgrademax -= $totaloverriddengrademax;
1183         reset($children);
1184         foreach ($children as $sortorder => $child) {
1185             $grade_item = null;
1187             if ($child['type'] == 'item') {
1188                 $grade_item = $child['object'];
1189             } else if ($child['type'] == 'category') {
1190                 $grade_item = $child['object']->load_grade_item();
1191             }
1192             if (!$grade_item->weightoverride) {
1193                 // Calculate this item's weight as a percentage of the non-overridden total grade maxes
1194                 // then convert it to a proportion of the available non-overriden weight.
1195                 $grade_item->aggregationcoef2 = ($grade_item->grademax/$totalgrademax) * (1 - $totaloverriddenweight);
1196                 $grade_item->update();
1197             }
1198         }
1199     }
1201     /**
1202      * Given an array of grade values (numerical indices) applies droplow or keephigh rules to limit the final array.
1203      *
1204      * @param array $grade_values itemid=>$grade_value float
1205      * @param array $items grade item objects
1206      * @return array Limited grades.
1207      */
1208     public function apply_limit_rules(&$grade_values, $items) {
1209         $extraused = $this->is_extracredit_used();
1211         if (!empty($this->droplow)) {
1212             asort($grade_values, SORT_NUMERIC);
1213             $dropped = 0;
1215             // If we have fewer grade items available to drop than $this->droplow, use this flag to escape the loop
1216             // May occur because of "extra credit" or if droplow is higher than the number of grade items
1217             $droppedsomething = true;
1219             while ($dropped < $this->droplow && $droppedsomething) {
1220                 $droppedsomething = false;
1222                 $grade_keys = array_keys($grade_values);
1223                 $gradekeycount = count($grade_keys);
1225                 if ($gradekeycount === 0) {
1226                     //We've dropped all grade items
1227                     break;
1228                 }
1230                 $originalindex = $founditemid = $foundmax = null;
1232                 // Find the first remaining grade item that is available to be dropped
1233                 foreach ($grade_keys as $gradekeyindex=>$gradekey) {
1234                     if (!$extraused || $items[$gradekey]->aggregationcoef <= 0) {
1235                         // Found a non-extra credit grade item that is eligible to be dropped
1236                         $originalindex = $gradekeyindex;
1237                         $founditemid = $grade_keys[$originalindex];
1238                         $foundmax = $items[$founditemid]->grademax;
1239                         break;
1240                     }
1241                 }
1243                 if (empty($founditemid)) {
1244                     // No grade items available to drop
1245                     break;
1246                 }
1248                 // Now iterate over the remaining grade items
1249                 // We're looking for other grade items with the same grade value but a higher grademax
1250                 $i = 1;
1251                 while ($originalindex + $i < $gradekeycount) {
1253                     $possibleitemid = $grade_keys[$originalindex+$i];
1254                     $i++;
1256                     if ($grade_values[$founditemid] != $grade_values[$possibleitemid]) {
1257                         // The next grade item has a different grade value. Stop looking.
1258                         break;
1259                     }
1261                     if ($extraused && $items[$possibleitemid]->aggregationcoef > 0) {
1262                         // Don't drop extra credit grade items. Continue the search.
1263                         continue;
1264                     }
1266                     if ($foundmax < $items[$possibleitemid]->grademax) {
1267                         // Found a grade item with the same grade value and a higher grademax
1268                         $foundmax = $items[$possibleitemid]->grademax;
1269                         $founditemid = $possibleitemid;
1270                         // Continue searching to see if there is an even higher grademax
1271                     }
1272                 }
1274                 // Now drop whatever grade item we have found
1275                 unset($grade_values[$founditemid]);
1276                 $dropped++;
1277                 $droppedsomething = true;
1278             }
1280         } else if (!empty($this->keephigh)) {
1281             arsort($grade_values, SORT_NUMERIC);
1282             $kept = 0;
1284             foreach ($grade_values as $itemid=>$value) {
1286                 if ($extraused and $items[$itemid]->aggregationcoef > 0) {
1287                     // we keep all extra credits
1289                 } else if ($kept < $this->keephigh) {
1290                     $kept++;
1292                 } else {
1293                     unset($grade_values[$itemid]);
1294                 }
1295             }
1296         }
1297     }
1299     /**
1300      * Returns true if category uses extra credit of any kind
1301      *
1302      * @return bool True if extra credit used
1303      */
1304     public function is_extracredit_used() {
1305         return self::aggregation_uses_extracredit($this->aggregation);
1306     }
1308     /**
1309      * Returns true if aggregation passed is using extracredit.
1310      *
1311      * @param int $aggregation Aggregation const.
1312      * @return bool True if extra credit used
1313      */
1314     public static function aggregation_uses_extracredit($aggregation) {
1315         return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1316              or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1317              or $aggregation == GRADE_AGGREGATE_SUM);
1318     }
1320     /**
1321      * Returns true if category uses special aggregation coefficient
1322      *
1323      * @return bool True if an aggregation coefficient is being used
1324      */
1325     public function is_aggregationcoef_used() {
1326         return self::aggregation_uses_aggregationcoef($this->aggregation);
1328     }
1330     /**
1331      * Returns true if aggregation uses aggregationcoef
1332      *
1333      * @param int $aggregation Aggregation const.
1334      * @return bool True if an aggregation coefficient is being used
1335      */
1336     public static function aggregation_uses_aggregationcoef($aggregation) {
1337         return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN
1338              or $aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1339              or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1340              or $aggregation == GRADE_AGGREGATE_SUM);
1342     }
1344     /**
1345      * Recursive function to find which weight/extra credit field to use in the grade item form.
1346      *
1347      * Inherits from a parent category if that category has aggregatesubcats set to true.
1348      *
1349      * @param string $first Whether or not this is the first item in the recursion
1350      * @return string
1351      */
1352     public function get_coefstring($first=true) {
1353         if (!is_null($this->coefstring)) {
1354             return $this->coefstring;
1355         }
1357         $overriding_coefstring = null;
1359         // Stop recursing upwards if this category aggregates subcats or has no parent
1360         if (!$first && !$this->aggregatesubcats) {
1362             if ($parent_category = $this->load_parent_category()) {
1363                 return $parent_category->get_coefstring(false);
1365             } else {
1366                 return null;
1367             }
1369         } else if ($first) {
1371             if (!$this->aggregatesubcats) {
1373                 if ($parent_category = $this->load_parent_category()) {
1374                     $overriding_coefstring = $parent_category->get_coefstring(false);
1375                 }
1376             }
1377         }
1379         // If an overriding coefstring has trickled down from one of the parent categories, return it. Otherwise, return self.
1380         if (!is_null($overriding_coefstring)) {
1381             return $overriding_coefstring;
1382         }
1384         // No parent category is overriding this category's aggregation, return its string
1385         if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN) {
1386             $this->coefstring = 'aggregationcoefweight';
1388         } else if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2) {
1389             $this->coefstring = 'aggregationcoefextrasum';
1391         } else if ($this->aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN) {
1392             $this->coefstring = 'aggregationcoefextraweight';
1394         } else if ($this->aggregation == GRADE_AGGREGATE_SUM) {
1395             $this->coefstring = 'aggregationcoefextrasum';
1397         } else {
1398             $this->coefstring = 'aggregationcoef';
1399         }
1400         return $this->coefstring;
1401     }
1403     /**
1404      * Returns tree with all grade_items and categories as elements
1405      *
1406      * @param int $courseid The course ID
1407      * @param bool $include_category_items as category children
1408      * @return array
1409      */
1410     public static function fetch_course_tree($courseid, $include_category_items=false) {
1411         $course_category = grade_category::fetch_course_category($courseid);
1412         $category_array = array('object'=>$course_category, 'type'=>'category', 'depth'=>1,
1413                                 'children'=>$course_category->get_children($include_category_items));
1415         $course_category->sortorder = $course_category->get_sortorder();
1416         $sortorder = $course_category->get_sortorder();
1417         return grade_category::_fetch_course_tree_recursion($category_array, $sortorder);
1418     }
1420     /**
1421      * An internal function that recursively sorts grade categories within a course
1422      *
1423      * @param array $category_array The seed of the recursion
1424      * @param int   $sortorder The current sortorder
1425      * @return array An array containing 'object', 'type', 'depth' and optionally 'children'
1426      */
1427     static private function _fetch_course_tree_recursion($category_array, &$sortorder) {
1428         // update the sortorder in db if needed
1429         //NOTE: This leads to us resetting sort orders every time the categories and items page is viewed :(
1430         //if ($category_array['object']->sortorder != $sortorder) {
1431             //$category_array['object']->set_sortorder($sortorder);
1432         //}
1434         if (isset($category_array['object']->gradetype) && $category_array['object']->gradetype==GRADE_TYPE_NONE) {
1435             return null;
1436         }
1438         // store the grade_item or grade_category instance with extra info
1439         $result = array('object'=>$category_array['object'], 'type'=>$category_array['type'], 'depth'=>$category_array['depth']);
1441         // reuse final grades if there
1442         if (array_key_exists('finalgrades', $category_array)) {
1443             $result['finalgrades'] = $category_array['finalgrades'];
1444         }
1446         // recursively resort children
1447         if (!empty($category_array['children'])) {
1448             $result['children'] = array();
1449             //process the category item first
1450             $child = null;
1452             foreach ($category_array['children'] as $oldorder=>$child_array) {
1454                 if ($child_array['type'] == 'courseitem' or $child_array['type'] == 'categoryitem') {
1455                     $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
1456                     if (!empty($child)) {
1457                         $result['children'][$sortorder] = $child;
1458                     }
1459                 }
1460             }
1462             foreach ($category_array['children'] as $oldorder=>$child_array) {
1464                 if ($child_array['type'] != 'courseitem' and $child_array['type'] != 'categoryitem') {
1465                     $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
1466                     if (!empty($child)) {
1467                         $result['children'][++$sortorder] = $child;
1468                     }
1469                 }
1470             }
1471         }
1473         return $result;
1474     }
1476     /**
1477      * Fetches and returns all the children categories and/or grade_items belonging to this category.
1478      * By default only returns the immediate children (depth=1), but deeper levels can be requested,
1479      * as well as all levels (0). The elements are indexed by sort order.
1480      *
1481      * @param bool $include_category_items Whether or not to include category grade_items in the children array
1482      * @return array Array of child objects (grade_category and grade_item).
1483      */
1484     public function get_children($include_category_items=false) {
1485         global $DB;
1487         // This function must be as fast as possible ;-)
1488         // fetch all course grade items and categories into memory - we do not expect hundreds of these in course
1489         // we have to limit the number of queries though, because it will be used often in grade reports
1491         $cats  = $DB->get_records('grade_categories', array('courseid' => $this->courseid));
1492         $items = $DB->get_records('grade_items', array('courseid' => $this->courseid));
1494         // init children array first
1495         foreach ($cats as $catid=>$cat) {
1496             $cats[$catid]->children = array();
1497         }
1499         //first attach items to cats and add category sortorder
1500         foreach ($items as $item) {
1502             if ($item->itemtype == 'course' or $item->itemtype == 'category') {
1503                 $cats[$item->iteminstance]->sortorder = $item->sortorder;
1505                 if (!$include_category_items) {
1506                     continue;
1507                 }
1508                 $categoryid = $item->iteminstance;
1510             } else {
1511                 $categoryid = $item->categoryid;
1512                 if (empty($categoryid)) {
1513                     debugging('Found a grade item that isnt in a category');
1514                 }
1515             }
1517             // prevent problems with duplicate sortorders in db
1518             $sortorder = $item->sortorder;
1520             while (array_key_exists($categoryid, $cats)
1521                 && array_key_exists($sortorder, $cats[$categoryid]->children)) {
1523                 $sortorder++;
1524             }
1526             $cats[$categoryid]->children[$sortorder] = $item;
1528         }
1530         // now find the requested category and connect categories as children
1531         $category = false;
1533         foreach ($cats as $catid=>$cat) {
1535             if (empty($cat->parent)) {
1537                 if ($cat->path !== '/'.$cat->id.'/') {
1538                     $grade_category = new grade_category($cat, false);
1539                     $grade_category->path  = '/'.$cat->id.'/';
1540                     $grade_category->depth = 1;
1541                     $grade_category->update('system');
1542                     return $this->get_children($include_category_items);
1543                 }
1545             } else {
1547                 if (empty($cat->path) or !preg_match('|/'.$cat->parent.'/'.$cat->id.'/$|', $cat->path)) {
1548                     //fix paths and depts
1549                     static $recursioncounter = 0; // prevents infinite recursion
1550                     $recursioncounter++;
1552                     if ($recursioncounter < 5) {
1553                         // fix paths and depths!
1554                         $grade_category = new grade_category($cat, false);
1555                         $grade_category->depth = 0;
1556                         $grade_category->path  = null;
1557                         $grade_category->update('system');
1558                         return $this->get_children($include_category_items);
1559                     }
1560                 }
1561                 // prevent problems with duplicate sortorders in db
1562                 $sortorder = $cat->sortorder;
1564                 while (array_key_exists($sortorder, $cats[$cat->parent]->children)) {
1565                     //debugging("$sortorder exists in cat loop");
1566                     $sortorder++;
1567                 }
1569                 $cats[$cat->parent]->children[$sortorder] = &$cats[$catid];
1570             }
1572             if ($catid == $this->id) {
1573                 $category = &$cats[$catid];
1574             }
1575         }
1577         unset($items); // not needed
1578         unset($cats); // not needed
1580         $children_array = grade_category::_get_children_recursion($category);
1582         ksort($children_array);
1584         return $children_array;
1586     }
1588     /**
1589      * Private method used to retrieve all children of this category recursively
1590      *
1591      * @param grade_category $category Source of current recursion
1592      * @return array An array of child grade categories
1593      */
1594     private static function _get_children_recursion($category) {
1596         $children_array = array();
1597         foreach ($category->children as $sortorder=>$child) {
1599             if (array_key_exists('itemtype', $child)) {
1600                 $grade_item = new grade_item($child, false);
1602                 if (in_array($grade_item->itemtype, array('course', 'category'))) {
1603                     $type  = $grade_item->itemtype.'item';
1604                     $depth = $category->depth;
1606                 } else {
1607                     $type  = 'item';
1608                     $depth = $category->depth; // we use this to set the same colour
1609                 }
1610                 $children_array[$sortorder] = array('object'=>$grade_item, 'type'=>$type, 'depth'=>$depth);
1612             } else {
1613                 $children = grade_category::_get_children_recursion($child);
1614                 $grade_category = new grade_category($child, false);
1616                 if (empty($children)) {
1617                     $children = array();
1618                 }
1619                 $children_array[$sortorder] = array('object'=>$grade_category, 'type'=>'category', 'depth'=>$grade_category->depth, 'children'=>$children);
1620             }
1621         }
1623         // sort the array
1624         ksort($children_array);
1626         return $children_array;
1627     }
1629     /**
1630      * Uses {@link get_grade_item()} to load or create a grade_item, then saves it as $this->grade_item.
1631      *
1632      * @return grade_item
1633      */
1634     public function load_grade_item() {
1635         if (empty($this->grade_item)) {
1636             $this->grade_item = $this->get_grade_item();
1637         }
1638         return $this->grade_item;
1639     }
1641     /**
1642      * Retrieves this grade categories' associated grade_item from the database
1643      *
1644      * If no grade_item exists yet, creates one.
1645      *
1646      * @return grade_item
1647      */
1648     public function get_grade_item() {
1649         if (empty($this->id)) {
1650             debugging("Attempt to obtain a grade_category's associated grade_item without the category's ID being set.");
1651             return false;
1652         }
1654         if (empty($this->parent)) {
1655             $params = array('courseid'=>$this->courseid, 'itemtype'=>'course', 'iteminstance'=>$this->id);
1657         } else {
1658             $params = array('courseid'=>$this->courseid, 'itemtype'=>'category', 'iteminstance'=>$this->id);
1659         }
1661         if (!$grade_items = grade_item::fetch_all($params)) {
1662             // create a new one
1663             $grade_item = new grade_item($params, false);
1664             $grade_item->gradetype = GRADE_TYPE_VALUE;
1665             $grade_item->insert('system');
1667         } else if (count($grade_items) == 1) {
1668             // found existing one
1669             $grade_item = reset($grade_items);
1671         } else {
1672             debugging("Found more than one grade_item attached to category id:".$this->id);
1673             // return first one
1674             $grade_item = reset($grade_items);
1675         }
1677         return $grade_item;
1678     }
1680     /**
1681      * Uses $this->parent to instantiate $this->parent_category based on the referenced record in the DB
1682      *
1683      * @return grade_category The parent category
1684      */
1685     public function load_parent_category() {
1686         if (empty($this->parent_category) && !empty($this->parent)) {
1687             $this->parent_category = $this->get_parent_category();
1688         }
1689         return $this->parent_category;
1690     }
1692     /**
1693      * Uses $this->parent to instantiate and return a grade_category object
1694      *
1695      * @return grade_category Returns the parent category or null if this category has no parent
1696      */
1697     public function get_parent_category() {
1698         if (!empty($this->parent)) {
1699             $parent_category = new grade_category(array('id' => $this->parent));
1700             return $parent_category;
1701         } else {
1702             return null;
1703         }
1704     }
1706     /**
1707      * Returns the most descriptive field for this grade category
1708      *
1709      * @return string name
1710      */
1711     public function get_name() {
1712         global $DB;
1713         // For a course category, we return the course name if the fullname is set to '?' in the DB (empty in the category edit form)
1714         if (empty($this->parent) && $this->fullname == '?') {
1715             $course = $DB->get_record('course', array('id'=> $this->courseid));
1716             return format_string($course->fullname);
1718         } else {
1719             return $this->fullname;
1720         }
1721     }
1723     /**
1724      * Describe the aggregation settings for this category so the reports make more sense.
1725      *
1726      * @return string description
1727      */
1728     public function get_description() {
1729         $allhelp = array();
1730         $aggrstrings = grade_helper::get_aggregation_strings();
1731         $allhelp[] = $aggrstrings[$this->aggregation];
1733         if ($this->droplow) {
1734             $allhelp[] = get_string('droplowestvalues', 'grades', $this->droplow);
1735         }
1736         if ($this->keephigh) {
1737             $allhelp[] = get_string('keephighestvalues', 'grades', $this->keephigh);
1738         }
1739         if (!$this->aggregateonlygraded) {
1740             $allhelp[] = get_string('aggregatenotonlygraded', 'grades');
1741         }
1742         if ($this->aggregatesubcats) {
1743             $allhelp[] = get_string('aggregatesubcatsshort', 'grades');
1744         }
1745         return implode('. ', $allhelp) . '.';
1746     }
1748     /**
1749      * Sets this category's parent id
1750      *
1751      * @param int $parentid The ID of the category that is the new parent to $this
1752      * @param string $source From where was the object updated (mod/forum, manual, etc.)
1753      * @return bool success
1754      */
1755     public function set_parent($parentid, $source=null) {
1756         if ($this->parent == $parentid) {
1757             return true;
1758         }
1760         if ($parentid == $this->id) {
1761             print_error('cannotassignselfasparent');
1762         }
1764         if (empty($this->parent) and $this->is_course_category()) {
1765             print_error('cannothaveparentcate');
1766         }
1768         // find parent and check course id
1769         if (!$parent_category = grade_category::fetch(array('id'=>$parentid, 'courseid'=>$this->courseid))) {
1770             return false;
1771         }
1773         $this->force_regrading();
1775         // set new parent category
1776         $this->parent          = $parent_category->id;
1777         $this->parent_category =& $parent_category;
1778         $this->path            = null;       // remove old path and depth - will be recalculated in update()
1779         $this->depth           = 0;          // remove old path and depth - will be recalculated in update()
1780         $this->update($source);
1782         return $this->update($source);
1783     }
1785     /**
1786      * Returns the final grade values for this grade category.
1787      *
1788      * @param int $userid Optional user ID to retrieve a single user's final grade
1789      * @return mixed An array of all final_grades (stdClass objects) for this grade_item, or a single final_grade.
1790      */
1791     public function get_final($userid=null) {
1792         $this->load_grade_item();
1793         return $this->grade_item->get_final($userid);
1794     }
1796     /**
1797      * Returns the sortorder of the grade categories' associated grade_item
1798      *
1799      * This method is also available in grade_item for cases where the object type is not known.
1800      *
1801      * @return int Sort order
1802      */
1803     public function get_sortorder() {
1804         $this->load_grade_item();
1805         return $this->grade_item->get_sortorder();
1806     }
1808     /**
1809      * Returns the idnumber of the grade categories' associated grade_item.
1810      *
1811      * This method is also available in grade_item for cases where the object type is not known.
1812      *
1813      * @return string idnumber
1814      */
1815     public function get_idnumber() {
1816         $this->load_grade_item();
1817         return $this->grade_item->get_idnumber();
1818     }
1820     /**
1821      * Sets the sortorder variable for this category.
1822      *
1823      * This method is also available in grade_item, for cases where the object type is not know.
1824      *
1825      * @param int $sortorder The sortorder to assign to this category
1826      */
1827     public function set_sortorder($sortorder) {
1828         $this->load_grade_item();
1829         $this->grade_item->set_sortorder($sortorder);
1830     }
1832     /**
1833      * Move this category after the given sortorder
1834      *
1835      * Does not change the parent
1836      *
1837      * @param int $sortorder to place after.
1838      * @return void
1839      */
1840     public function move_after_sortorder($sortorder) {
1841         $this->load_grade_item();
1842         $this->grade_item->move_after_sortorder($sortorder);
1843     }
1845     /**
1846      * Return true if this is the top most category that represents the total course grade.
1847      *
1848      * @return bool
1849      */
1850     public function is_course_category() {
1851         $this->load_grade_item();
1852         return $this->grade_item->is_course_item();
1853     }
1855     /**
1856      * Return the course level grade_category object
1857      *
1858      * @param int $courseid The Course ID
1859      * @return grade_category Returns the course level grade_category instance
1860      */
1861     public static function fetch_course_category($courseid) {
1862         if (empty($courseid)) {
1863             debugging('Missing course id!');
1864             return false;
1865         }
1867         // course category has no parent
1868         if ($course_category = grade_category::fetch(array('courseid'=>$courseid, 'parent'=>null))) {
1869             return $course_category;
1870         }
1872         // create a new one
1873         $course_category = new grade_category();
1874         $course_category->insert_course_category($courseid);
1876         return $course_category;
1877     }
1879     /**
1880      * Is grading object editable?
1881      *
1882      * @return bool
1883      */
1884     public function is_editable() {
1885         return true;
1886     }
1888     /**
1889      * Returns the locked state/date of the grade categories' associated grade_item.
1890      *
1891      * This method is also available in grade_item, for cases where the object type is not known.
1892      *
1893      * @return bool
1894      */
1895     public function is_locked() {
1896         $this->load_grade_item();
1897         return $this->grade_item->is_locked();
1898     }
1900     /**
1901      * Sets the grade_item's locked variable and updates the grade_item.
1902      *
1903      * Calls set_locked() on the categories' grade_item
1904      *
1905      * @param int  $lockedstate 0, 1 or a timestamp int(10) after which date the item will be locked.
1906      * @param bool $cascade lock/unlock child objects too
1907      * @param bool $refresh refresh grades when unlocking
1908      * @return bool success if category locked (not all children mayb be locked though)
1909      */
1910     public function set_locked($lockedstate, $cascade=false, $refresh=true) {
1911         $this->load_grade_item();
1913         $result = $this->grade_item->set_locked($lockedstate, $cascade, true);
1915         if ($cascade) {
1916             //process all children - items and categories
1917             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
1919                 foreach ($children as $child) {
1920                     $child->set_locked($lockedstate, true, false);
1922                     if (empty($lockedstate) and $refresh) {
1923                         //refresh when unlocking
1924                         $child->refresh_grades();
1925                     }
1926                 }
1927             }
1929             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
1931                 foreach ($children as $child) {
1932                     $child->set_locked($lockedstate, true, true);
1933                 }
1934             }
1935         }
1937         return $result;
1938     }
1940     /**
1941      * Overrides grade_object::set_properties() to add special handling for changes to category aggregation types
1942      *
1943      * @param stdClass $instance the object to set the properties on
1944      * @param array|stdClass $params Either an associative array or an object containing property name, property value pairs
1945      */
1946     public static function set_properties(&$instance, $params) {
1947         global $DB;
1949         parent::set_properties($instance, $params);
1951         //if they've changed aggregation type we made need to do some fiddling to provide appropriate defaults
1952         if (!empty($params->aggregation)) {
1954             //weight and extra credit share a column :( Would like a default of 1 for weight and 0 for extra credit
1955             //Flip from the default of 0 to 1 (or vice versa) if ALL items in the category are still set to the old default.
1956             if (self::aggregation_uses_aggregationcoef($params->aggregation)) {
1957                 $sql = $defaultaggregationcoef = null;
1959                 if (!self::aggregation_uses_extracredit($params->aggregation)) {
1960                     //if all items in this category have aggregation coefficient of 0 we can change it to 1 ie evenly weighted
1961                     $sql = "select count(id) from {grade_items} where categoryid=:categoryid and aggregationcoef!=0";
1962                     $defaultaggregationcoef = 1;
1963                 } else {
1964                     //if all items in this category have aggregation coefficient of 1 we can change it to 0 ie no extra credit
1965                     $sql = "select count(id) from {grade_items} where categoryid=:categoryid and aggregationcoef!=1";
1966                     $defaultaggregationcoef = 0;
1967                 }
1969                 $params = array('categoryid'=>$instance->id);
1970                 $count = $DB->count_records_sql($sql, $params);
1971                 if ($count===0) { //category is either empty or all items are set to a default value so we can switch defaults
1972                     $params['aggregationcoef'] = $defaultaggregationcoef;
1973                     $DB->execute("update {grade_items} set aggregationcoef=:aggregationcoef where categoryid=:categoryid",$params);
1974                 }
1975             }
1976         }
1977     }
1979     /**
1980      * Sets the grade_item's hidden variable and updates the grade_item.
1981      *
1982      * Overrides grade_item::set_hidden() to add cascading of the hidden value to grade items in this grade category
1983      *
1984      * @param int $hidden 0 mean always visible, 1 means always hidden and a number > 1 is a timestamp to hide until
1985      * @param bool $cascade apply to child objects too
1986      */
1987     public function set_hidden($hidden, $cascade=false) {
1988         $this->load_grade_item();
1989         //this hides the associated grade item (the course total)
1990         $this->grade_item->set_hidden($hidden, $cascade);
1991         //this hides the category itself and everything it contains
1992         parent::set_hidden($hidden, $cascade);
1994         if ($cascade) {
1996             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
1998                 foreach ($children as $child) {
1999                     if ($child->can_control_visibility()) {
2000                         $child->set_hidden($hidden, $cascade);
2001                     }
2002                 }
2003             }
2005             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
2007                 foreach ($children as $child) {
2008                     $child->set_hidden($hidden, $cascade);
2009                 }
2010             }
2011         }
2013         //if marking category visible make sure parent category is visible MDL-21367
2014         if( !$hidden ) {
2015             $category_array = grade_category::fetch_all(array('id'=>$this->parent));
2016             if ($category_array && array_key_exists($this->parent, $category_array)) {
2017                 $category = $category_array[$this->parent];
2018                 //call set_hidden on the category regardless of whether it is hidden as its parent might be hidden
2019                 //if($category->is_hidden()) {
2020                     $category->set_hidden($hidden, false);
2021                 //}
2022             }
2023         }
2024     }
2026     /**
2027      * Applies default settings on this category
2028      *
2029      * @return bool True if anything changed
2030      */
2031     public function apply_default_settings() {
2032         global $CFG;
2034         foreach ($this->forceable as $property) {
2036             if (isset($CFG->{"grade_$property"})) {
2038                 if ($CFG->{"grade_$property"} == -1) {
2039                     continue; //temporary bc before version bump
2040                 }
2041                 $this->$property = $CFG->{"grade_$property"};
2042             }
2043         }
2044     }
2046     /**
2047      * Applies forced settings on this category
2048      *
2049      * @return bool True if anything changed
2050      */
2051     public function apply_forced_settings() {
2052         global $CFG;
2054         $updated = false;
2056         foreach ($this->forceable as $property) {
2058             if (isset($CFG->{"grade_$property"}) and isset($CFG->{"grade_{$property}_flag"}) and
2059                                                     ((int) $CFG->{"grade_{$property}_flag"} & 1)) {
2061                 if ($CFG->{"grade_$property"} == -1) {
2062                     continue; //temporary bc before version bump
2063                 }
2064                 $this->$property = $CFG->{"grade_$property"};
2065                 $updated = true;
2066             }
2067         }
2069         return $updated;
2070     }
2072     /**
2073      * Notification of change in forced category settings.
2074      *
2075      * Causes all course and category grade items to be marked as needing to be updated
2076      */
2077     public static function updated_forced_settings() {
2078         global $CFG, $DB;
2079         $params = array(1, 'course', 'category');
2080         $sql = "UPDATE {grade_items} SET needsupdate=? WHERE itemtype=? or itemtype=?";
2081         $DB->execute($sql, $params);
2082     }