MDL-53301 grades: Update gradeitems aggregationcoef2 only when required
[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                                  '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         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;
408         return ($aggregationdiff || $keephighdiff || $droplowdiff || $aggonlygrddiff || $aggoutcomesdiff);
409     }
411     /**
412      * Marks this grade categories' associated grade item as needing regrading
413      */
414     public function force_regrading() {
415         $grade_item = $this->load_grade_item();
416         $grade_item->force_regrading();
417     }
419     /**
420      * Something that should be called before we start regrading the whole course.
421      *
422      * @return void
423      */
424     public function pre_regrade_final_grades() {
425         $this->auto_update_weights();
426         $this->auto_update_max();
427     }
429     /**
430      * Generates and saves final grades in associated category grade item.
431      * These immediate children must already have their own final grades.
432      * The category's aggregation method is used to generate final grades.
433      *
434      * Please note that category grade is either calculated or aggregated, not both at the same time.
435      *
436      * This method must be used ONLY from grade_item::regrade_final_grades(),
437      * because the calculation must be done in correct order!
438      *
439      * Steps to follow:
440      *  1. Get final grades from immediate children
441      *  3. Aggregate these grades
442      *  4. Save them in final grades of associated category grade item
443      *
444      * @param int $userid The user ID if final grade generation should be limited to a single user
445      * @return bool
446      */
447     public function generate_grades($userid=null) {
448         global $CFG, $DB;
450         $this->load_grade_item();
452         if ($this->grade_item->is_locked()) {
453             return true; // no need to recalculate locked items
454         }
456         // find grade items of immediate children (category or grade items) and force site settings
457         $depends_on = $this->grade_item->depends_on();
459         if (empty($depends_on)) {
460             $items = false;
462         } else {
463             list($usql, $params) = $DB->get_in_or_equal($depends_on);
464             $sql = "SELECT *
465                       FROM {grade_items}
466                      WHERE id $usql";
467             $items = $DB->get_records_sql($sql, $params);
468             foreach ($items as $id => $item) {
469                 $items[$id] = new grade_item($item, false);
470             }
471         }
473         $grade_inst = new grade_grade();
474         $fields = 'g.'.implode(',g.', $grade_inst->required_fields);
476         // where to look for final grades - include grade of this item too, we will store the results there
477         $gis = array_merge($depends_on, array($this->grade_item->id));
478         list($usql, $params) = $DB->get_in_or_equal($gis);
480         if ($userid) {
481             $usersql = "AND g.userid=?";
482             $params[] = $userid;
484         } else {
485             $usersql = "";
486         }
488         $sql = "SELECT $fields
489                   FROM {grade_grades} g, {grade_items} gi
490                  WHERE gi.id = g.itemid AND gi.id $usql $usersql
491               ORDER BY g.userid";
493         // group the results by userid and aggregate the grades for this user
494         $rs = $DB->get_recordset_sql($sql, $params);
495         if ($rs->valid()) {
496             $prevuser = 0;
497             $grade_values = array();
498             $excluded     = array();
499             $oldgrade     = null;
500             $grademaxoverrides = array();
501             $grademinoverrides = array();
503             foreach ($rs as $used) {
504                 $grade = new grade_grade($used, false);
505                 if (isset($items[$grade->itemid])) {
506                     // Prevent grade item to be fetched from DB.
507                     $grade->grade_item =& $items[$grade->itemid];
508                 } else if ($grade->itemid == $this->grade_item->id) {
509                     // This grade's grade item is not in $items.
510                     $grade->grade_item =& $this->grade_item;
511                 }
512                 if ($grade->userid != $prevuser) {
513                     $this->aggregate_grades($prevuser,
514                                             $items,
515                                             $grade_values,
516                                             $oldgrade,
517                                             $excluded,
518                                             $grademinoverrides,
519                                             $grademaxoverrides);
520                     $prevuser = $grade->userid;
521                     $grade_values = array();
522                     $excluded     = array();
523                     $oldgrade     = null;
524                     $grademaxoverrides = array();
525                     $grademinoverrides = array();
526                 }
527                 $grade_values[$grade->itemid] = $grade->finalgrade;
528                 $grademaxoverrides[$grade->itemid] = $grade->get_grade_max();
529                 $grademinoverrides[$grade->itemid] = $grade->get_grade_min();
531                 if ($grade->excluded) {
532                     $excluded[] = $grade->itemid;
533                 }
535                 if ($this->grade_item->id == $grade->itemid) {
536                     $oldgrade = $grade;
537                 }
538             }
539             $this->aggregate_grades($prevuser,
540                                     $items,
541                                     $grade_values,
542                                     $oldgrade,
543                                     $excluded,
544                                     $grademinoverrides,
545                                     $grademaxoverrides);//the last one
546         }
547         $rs->close();
549         return true;
550     }
552     /**
553      * Internal function for grade category grade aggregation
554      *
555      * @param int    $userid The User ID
556      * @param array  $items Grade items
557      * @param array  $grade_values Array of grade values
558      * @param object $oldgrade Old grade
559      * @param array  $excluded Excluded
560      * @param array  $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
561      * @param array  $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
562      */
563     private function aggregate_grades($userid,
564                                       $items,
565                                       $grade_values,
566                                       $oldgrade,
567                                       $excluded,
568                                       $grademinoverrides,
569                                       $grademaxoverrides) {
570         global $CFG, $DB;
572         // Remember these so we can set flags on them to describe how they were used in the aggregation.
573         $novalue = array();
574         $dropped = array();
575         $extracredit = array();
576         $usedweights = array();
578         if (empty($userid)) {
579             //ignore first call
580             return;
581         }
583         if ($oldgrade) {
584             $oldfinalgrade = $oldgrade->finalgrade;
585             $grade = new grade_grade($oldgrade, false);
586             $grade->grade_item =& $this->grade_item;
588         } else {
589             // insert final grade - it will be needed later anyway
590             $grade = new grade_grade(array('itemid'=>$this->grade_item->id, 'userid'=>$userid), false);
591             $grade->grade_item =& $this->grade_item;
592             $grade->insert('system');
593             $oldfinalgrade = null;
594         }
596         // no need to recalculate locked or overridden grades
597         if ($grade->is_locked() or $grade->is_overridden()) {
598             return;
599         }
601         // can not use own final category grade in calculation
602         unset($grade_values[$this->grade_item->id]);
604         // Make sure a grade_grade exists for every grade_item.
605         // We need to do this so we can set the aggregationstatus
606         // with a set_field call instead of checking if each one exists and creating/updating.
607         if (!empty($items)) {
608             list($ggsql, $params) = $DB->get_in_or_equal(array_keys($items), SQL_PARAMS_NAMED, 'g');
611             $params['userid'] = $userid;
612             $sql = "SELECT itemid
613                       FROM {grade_grades}
614                      WHERE itemid $ggsql AND userid = :userid";
615             $existingitems = $DB->get_records_sql($sql, $params);
617             $notexisting = array_diff(array_keys($items), array_keys($existingitems));
618             foreach ($notexisting as $itemid) {
619                 $gradeitem = $items[$itemid];
620                 $gradegrade = new grade_grade(array('itemid' => $itemid,
621                                                     'userid' => $userid,
622                                                     'rawgrademin' => $gradeitem->grademin,
623                                                     'rawgrademax' => $gradeitem->grademax), false);
624                 $gradegrade->grade_item = $gradeitem;
625                 $gradegrade->insert('system');
626             }
627         }
629         // if no grades calculation possible or grading not allowed clear final grade
630         if (empty($grade_values) or empty($items) or ($this->grade_item->gradetype != GRADE_TYPE_VALUE and $this->grade_item->gradetype != GRADE_TYPE_SCALE)) {
631             $grade->finalgrade = null;
633             if (!is_null($oldfinalgrade)) {
634                 $grade->timemodified = time();
635                 $success = $grade->update('aggregation');
637                 // If successful trigger a user_graded event.
638                 if ($success) {
639                     \core\event\user_graded::create_from_grade($grade)->trigger();
640                 }
641             }
642             $dropped = $grade_values;
643             $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
644             return;
645         }
647         // Normalize the grades first - all will have value 0...1
648         // ungraded items are not used in aggregation.
649         foreach ($grade_values as $itemid=>$v) {
650             if (is_null($v)) {
651                 // If null, it means no grade.
652                 if ($this->aggregateonlygraded) {
653                     unset($grade_values[$itemid]);
654                     // Mark this item as "excluded empty" because it has no grade.
655                     $novalue[$itemid] = 0;
656                     continue;
657                 }
658             }
659             if (in_array($itemid, $excluded)) {
660                 unset($grade_values[$itemid]);
661                 $dropped[$itemid] = 0;
662                 continue;
663             }
664             // Check for user specific grade min/max overrides.
665             $usergrademin = $items[$itemid]->grademin;
666             $usergrademax = $items[$itemid]->grademax;
667             if (isset($grademinoverrides[$itemid])) {
668                 $usergrademin = $grademinoverrides[$itemid];
669             }
670             if (isset($grademaxoverrides[$itemid])) {
671                 $usergrademax = $grademaxoverrides[$itemid];
672             }
673             if ($this->aggregation == GRADE_AGGREGATE_SUM) {
674                 // Assume that the grademin is 0 when standardising the score, to preserve negative grades.
675                 $grade_values[$itemid] = grade_grade::standardise_score($v, 0, $usergrademax, 0, 1);
676             } else {
677                 $grade_values[$itemid] = grade_grade::standardise_score($v, $usergrademin, $usergrademax, 0, 1);
678             }
680         }
682         // For items with no value, and not excluded - either set their grade to 0 or exclude them.
683         foreach ($items as $itemid=>$value) {
684             if (!isset($grade_values[$itemid]) and !in_array($itemid, $excluded)) {
685                 if (!$this->aggregateonlygraded) {
686                     $grade_values[$itemid] = 0;
687                 } else {
688                     // We are specifically marking these items as "excluded empty".
689                     $novalue[$itemid] = 0;
690                 }
691             }
692         }
694         // limit and sort
695         $allvalues = $grade_values;
696         if ($this->can_apply_limit_rules()) {
697             $this->apply_limit_rules($grade_values, $items);
698         }
700         $moredropped = array_diff($allvalues, $grade_values);
701         foreach ($moredropped as $drop => $unused) {
702             $dropped[$drop] = 0;
703         }
705         foreach ($grade_values as $itemid => $val) {
706             if (self::is_extracredit_used() && ($items[$itemid]->aggregationcoef > 0)) {
707                 $extracredit[$itemid] = 0;
708             }
709         }
711         asort($grade_values, SORT_NUMERIC);
713         // let's see we have still enough grades to do any statistics
714         if (count($grade_values) == 0) {
715             // not enough attempts yet
716             $grade->finalgrade = null;
718             if (!is_null($oldfinalgrade)) {
719                 $grade->timemodified = time();
720                 $success = $grade->update('aggregation');
722                 // If successful trigger a user_graded event.
723                 if ($success) {
724                     \core\event\user_graded::create_from_grade($grade)->trigger();
725                 }
726             }
727             $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
728             return;
729         }
731         // do the maths
732         $result = $this->aggregate_values_and_adjust_bounds($grade_values,
733                                                             $items,
734                                                             $usedweights,
735                                                             $grademinoverrides,
736                                                             $grademaxoverrides);
737         $agg_grade = $result['grade'];
739         // Set the actual grademin and max to bind the grade properly.
740         $this->grade_item->grademin = $result['grademin'];
741         $this->grade_item->grademax = $result['grademax'];
743         if ($this->aggregation == GRADE_AGGREGATE_SUM) {
744             // The natural aggregation always displays the range as coming from 0 for categories.
745             // However, when we bind the grade we allow for negative values.
746             $result['grademin'] = 0;
747         }
749         // Recalculate the grade back to requested range.
750         $finalgrade = grade_grade::standardise_score($agg_grade, 0, 1, $result['grademin'], $result['grademax']);
751         $grade->finalgrade = $this->grade_item->bounded_grade($finalgrade);
753         $oldrawgrademin = $grade->rawgrademin;
754         $oldrawgrademax = $grade->rawgrademax;
755         $grade->rawgrademin = $result['grademin'];
756         $grade->rawgrademax = $result['grademax'];
758         // Update in db if changed.
759         if (grade_floats_different($grade->finalgrade, $oldfinalgrade) ||
760                 grade_floats_different($grade->rawgrademax, $oldrawgrademax) ||
761                 grade_floats_different($grade->rawgrademin, $oldrawgrademin)) {
762             $grade->timemodified = time();
763             $success = $grade->update('aggregation');
765             // If successful trigger a user_graded event.
766             if ($success) {
767                 \core\event\user_graded::create_from_grade($grade)->trigger();
768             }
769         }
771         $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
773         return;
774     }
776     /**
777      * Set the flags on the grade_grade items to indicate how individual grades are used
778      * in the aggregation.
779      *
780      * WARNING: This function is called a lot during gradebook recalculation, be very performance considerate.
781      *
782      * @param int $userid The user we have aggregated the grades for.
783      * @param array $usedweights An array with keys for each of the grade_item columns included in the aggregation. The value are the relative weight.
784      * @param array $novalue An array with keys for each of the grade_item columns skipped because
785      *                       they had no value in the aggregation.
786      * @param array $dropped An array with keys for each of the grade_item columns dropped
787      *                       because of any drop lowest/highest settings in the aggregation.
788      * @param array $extracredit An array with keys for each of the grade_item columns
789      *                       considered extra credit by the aggregation.
790      */
791     private function set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit) {
792         global $DB;
794         // Reset aggregation to unknown and 0 for all grade items for this user and category.
795         $params = array('categoryid' => $this->id, 'userid' => $userid);
797         switch ($DB->get_dbfamily()) {
798             case 'mysql':
799                 // Optimize the query for MySQL by using a join rather than a sub-query.
800                 $sql = "UPDATE {grade_grades} g
801                           JOIN {grade_items} gi ON (g.itemid = gi.id)
802                            SET g.aggregationstatus = 'unknown',
803                                g.aggregationweight = 0
804                          WHERE g.userid = :userid
805                            AND gi.categoryid = :categoryid";
806                 break;
807             default:
808                 $itemssql = "SELECT id
809                                FROM {grade_items}
810                               WHERE categoryid = :categoryid";
812                 $sql = "UPDATE {grade_grades}
813                            SET aggregationstatus = 'unknown',
814                                aggregationweight = 0
815                          WHERE userid = :userid
816                            AND itemid IN ($itemssql)";
817         }
819         $DB->execute($sql, $params);
821         // Included with weights.
822         if (!empty($usedweights)) {
823             // The usedweights items are updated individually to record the weights.
824             foreach ($usedweights as $gradeitemid => $contribution) {
825                 $sql = "UPDATE {grade_grades}
826                            SET aggregationstatus = 'used',
827                                aggregationweight = :contribution
828                          WHERE itemid = :itemid AND userid = :userid";
830                 $params = array('contribution' => $contribution, 'itemid' => $gradeitemid, 'userid' => $userid);
831                 $DB->execute($sql, $params);
832             }
833         }
835         // No value.
836         if (!empty($novalue)) {
837             list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($novalue), SQL_PARAMS_NAMED, 'g');
839             $itemlist['userid'] = $userid;
841             $sql = "UPDATE {grade_grades}
842                        SET aggregationstatus = 'novalue',
843                            aggregationweight = 0
844                      WHERE itemid $itemsql AND userid = :userid";
846             $DB->execute($sql, $itemlist);
847         }
849         // Dropped.
850         if (!empty($dropped)) {
851             list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($dropped), SQL_PARAMS_NAMED, 'g');
853             $itemlist['userid'] = $userid;
855             $sql = "UPDATE {grade_grades}
856                        SET aggregationstatus = 'dropped',
857                            aggregationweight = 0
858                      WHERE itemid $itemsql AND userid = :userid";
860             $DB->execute($sql, $itemlist);
861         }
863         // Extra credit.
864         if (!empty($extracredit)) {
865             list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($extracredit), SQL_PARAMS_NAMED, 'g');
867             $itemlist['userid'] = $userid;
869             $DB->set_field_select('grade_grades',
870                                   'aggregationstatus',
871                                   'extra',
872                                   "itemid $itemsql AND userid = :userid",
873                                   $itemlist);
874         }
875     }
877     /**
878      * Internal function that calculates the aggregated grade and new min/max for this grade category
879      *
880      * Must be public as it is used by grade_grade::get_hiding_affected()
881      *
882      * @param array $grade_values An array of values to be aggregated
883      * @param array $items The array of grade_items
884      * @since Moodle 2.6.5, 2.7.2
885      * @param array & $weights If provided, will be filled with the normalized weights
886      *                         for each grade_item as used in the aggregation.
887      *                         Some rules for the weights are:
888      *                         1. The weights must add up to 1 (unless there are extra credit)
889      *                         2. The contributed points column must add up to the course
890      *                         final grade and this column is calculated from these weights.
891      * @param array  $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
892      * @param array  $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
893      * @return array containing values for:
894      *                'grade' => the new calculated grade
895      *                'grademin' => the new calculated min grade for the category
896      *                'grademax' => the new calculated max grade for the category
897      */
898     public function aggregate_values_and_adjust_bounds($grade_values,
899                                                        $items,
900                                                        & $weights = null,
901                                                        $grademinoverrides = array(),
902                                                        $grademaxoverrides = array()) {
903         $category_item = $this->load_grade_item();
904         $grademin = $category_item->grademin;
905         $grademax = $category_item->grademax;
907         switch ($this->aggregation) {
909             case GRADE_AGGREGATE_MEDIAN: // Middle point value in the set: ignores frequencies
910                 $num = count($grade_values);
911                 $grades = array_values($grade_values);
913                 // The median gets 100% - others get 0.
914                 if ($weights !== null && $num > 0) {
915                     $count = 0;
916                     foreach ($grade_values as $itemid=>$grade_value) {
917                         if (($num % 2 == 0) && ($count == intval($num/2)-1 || $count == intval($num/2))) {
918                             $weights[$itemid] = 0.5;
919                         } else if (($num % 2 != 0) && ($count == intval(($num/2)-0.5))) {
920                             $weights[$itemid] = 1.0;
921                         } else {
922                             $weights[$itemid] = 0;
923                         }
924                         $count++;
925                     }
926                 }
927                 if ($num % 2 == 0) {
928                     $agg_grade = ($grades[intval($num/2)-1] + $grades[intval($num/2)]) / 2;
929                 } else {
930                     $agg_grade = $grades[intval(($num/2)-0.5)];
931                 }
933                 break;
935             case GRADE_AGGREGATE_MIN:
936                 $agg_grade = reset($grade_values);
937                 // Record the weights as used.
938                 if ($weights !== null) {
939                     foreach ($grade_values as $itemid=>$grade_value) {
940                         $weights[$itemid] = 0;
941                     }
942                 }
943                 // Set the first item to 1.
944                 $itemids = array_keys($grade_values);
945                 $weights[reset($itemids)] = 1;
946                 break;
948             case GRADE_AGGREGATE_MAX:
949                 // Record the weights as used.
950                 if ($weights !== null) {
951                     foreach ($grade_values as $itemid=>$grade_value) {
952                         $weights[$itemid] = 0;
953                     }
954                 }
955                 // Set the last item to 1.
956                 $itemids = array_keys($grade_values);
957                 $weights[end($itemids)] = 1;
958                 $agg_grade = end($grade_values);
959                 break;
961             case GRADE_AGGREGATE_MODE:       // the most common value
962                 // array_count_values only counts INT and STRING, so if grades are floats we must convert them to string
963                 $converted_grade_values = array();
965                 foreach ($grade_values as $k => $gv) {
967                     if (!is_int($gv) && !is_string($gv)) {
968                         $converted_grade_values[$k] = (string) $gv;
970                     } else {
971                         $converted_grade_values[$k] = $gv;
972                     }
973                     if ($weights !== null) {
974                         $weights[$k] = 0;
975                     }
976                 }
978                 $freq = array_count_values($converted_grade_values);
979                 arsort($freq);                      // sort by frequency keeping keys
980                 $top = reset($freq);               // highest frequency count
981                 $modes = array_keys($freq, $top);  // search for all modes (have the same highest count)
982                 rsort($modes, SORT_NUMERIC);       // get highest mode
983                 $agg_grade = reset($modes);
984                 // Record the weights as used.
985                 if ($weights !== null && $top > 0) {
986                     foreach ($grade_values as $k => $gv) {
987                         if ($gv == $agg_grade) {
988                             $weights[$k] = 1.0 / $top;
989                         }
990                     }
991                 }
992                 break;
994             case GRADE_AGGREGATE_WEIGHTED_MEAN: // Weighted average of all existing final grades, weight specified in coef
995                 $weightsum = 0;
996                 $sum       = 0;
998                 foreach ($grade_values as $itemid=>$grade_value) {
999                     if ($weights !== null) {
1000                         $weights[$itemid] = $items[$itemid]->aggregationcoef;
1001                     }
1002                     if ($items[$itemid]->aggregationcoef <= 0) {
1003                         continue;
1004                     }
1005                     $weightsum += $items[$itemid]->aggregationcoef;
1006                     $sum       += $items[$itemid]->aggregationcoef * $grade_value;
1007                 }
1008                 if ($weightsum == 0) {
1009                     $agg_grade = null;
1011                 } else {
1012                     $agg_grade = $sum / $weightsum;
1013                     if ($weights !== null) {
1014                         // Normalise the weights.
1015                         foreach ($weights as $itemid => $weight) {
1016                             $weights[$itemid] = $weight / $weightsum;
1017                         }
1018                     }
1020                 }
1021                 break;
1023             case GRADE_AGGREGATE_WEIGHTED_MEAN2:
1024                 // Weighted average of all existing final grades with optional extra credit flag,
1025                 // weight is the range of grade (usually grademax)
1026                 $this->load_grade_item();
1027                 $weightsum = 0;
1028                 $sum       = null;
1030                 foreach ($grade_values as $itemid=>$grade_value) {
1031                     if ($items[$itemid]->aggregationcoef > 0) {
1032                         continue;
1033                     }
1035                     $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
1036                     if ($weight <= 0) {
1037                         continue;
1038                     }
1040                     $weightsum += $weight;
1041                     $sum += $weight * $grade_value;
1042                 }
1044                 // Handle the extra credit items separately to calculate their weight accurately.
1045                 foreach ($grade_values as $itemid => $grade_value) {
1046                     if ($items[$itemid]->aggregationcoef <= 0) {
1047                         continue;
1048                     }
1050                     $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
1051                     if ($weight <= 0) {
1052                         $weights[$itemid] = 0;
1053                         continue;
1054                     }
1056                     $oldsum = $sum;
1057                     $weightedgrade = $weight * $grade_value;
1058                     $sum += $weightedgrade;
1060                     if ($weights !== null) {
1061                         if ($weightsum <= 0) {
1062                             $weights[$itemid] = 0;
1063                             continue;
1064                         }
1066                         $oldgrade = $oldsum / $weightsum;
1067                         $grade = $sum / $weightsum;
1068                         $normoldgrade = grade_grade::standardise_score($oldgrade, 0, 1, $grademin, $grademax);
1069                         $normgrade = grade_grade::standardise_score($grade, 0, 1, $grademin, $grademax);
1070                         $boundedoldgrade = $this->grade_item->bounded_grade($normoldgrade);
1071                         $boundedgrade = $this->grade_item->bounded_grade($normgrade);
1073                         if ($boundedgrade - $boundedoldgrade <= 0) {
1074                             // Nothing new was added to the grade.
1075                             $weights[$itemid] = 0;
1076                         } else if ($boundedgrade < $normgrade) {
1077                             // The grade has been bounded, the extra credit item needs to have a different weight.
1078                             $gradediff = $boundedgrade - $normoldgrade;
1079                             $gradediffnorm = grade_grade::standardise_score($gradediff, $grademin, $grademax, 0, 1);
1080                             $weights[$itemid] = $gradediffnorm / $grade_value;
1081                         } else {
1082                             // Default weighting.
1083                             $weights[$itemid] = $weight / $weightsum;
1084                         }
1085                     }
1086                 }
1088                 if ($weightsum == 0) {
1089                     $agg_grade = $sum; // only extra credits
1091                 } else {
1092                     $agg_grade = $sum / $weightsum;
1093                 }
1095                 // Record the weights as used.
1096                 if ($weights !== null) {
1097                     foreach ($grade_values as $itemid=>$grade_value) {
1098                         if ($items[$itemid]->aggregationcoef > 0) {
1099                             // Ignore extra credit items, the weights have already been computed.
1100                             continue;
1101                         }
1102                         if ($weightsum > 0) {
1103                             $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
1104                             $weights[$itemid] = $weight / $weightsum;
1105                         } else {
1106                             $weights[$itemid] = 0;
1107                         }
1108                     }
1109                 }
1110                 break;
1112             case GRADE_AGGREGATE_EXTRACREDIT_MEAN: // special average
1113                 $this->load_grade_item();
1114                 $num = 0;
1115                 $sum = null;
1117                 foreach ($grade_values as $itemid=>$grade_value) {
1118                     if ($items[$itemid]->aggregationcoef == 0) {
1119                         $num += 1;
1120                         $sum += $grade_value;
1121                         if ($weights !== null) {
1122                             $weights[$itemid] = 1;
1123                         }
1124                     }
1125                 }
1127                 // Treating the extra credit items separately to get a chance to calculate their effective weights.
1128                 foreach ($grade_values as $itemid=>$grade_value) {
1129                     if ($items[$itemid]->aggregationcoef > 0) {
1130                         $oldsum = $sum;
1131                         $sum += $items[$itemid]->aggregationcoef * $grade_value;
1133                         if ($weights !== null) {
1134                             if ($num <= 0) {
1135                                 // The category only contains extra credit items, not setting the weight.
1136                                 continue;
1137                             }
1139                             $oldgrade = $oldsum / $num;
1140                             $grade = $sum / $num;
1141                             $normoldgrade = grade_grade::standardise_score($oldgrade, 0, 1, $grademin, $grademax);
1142                             $normgrade = grade_grade::standardise_score($grade, 0, 1, $grademin, $grademax);
1143                             $boundedoldgrade = $this->grade_item->bounded_grade($normoldgrade);
1144                             $boundedgrade = $this->grade_item->bounded_grade($normgrade);
1146                             if ($boundedgrade - $boundedoldgrade <= 0) {
1147                                 // Nothing new was added to the grade.
1148                                 $weights[$itemid] = 0;
1149                             } else if ($boundedgrade < $normgrade) {
1150                                 // The grade has been bounded, the extra credit item needs to have a different weight.
1151                                 $gradediff = $boundedgrade - $normoldgrade;
1152                                 $gradediffnorm = grade_grade::standardise_score($gradediff, $grademin, $grademax, 0, 1);
1153                                 $weights[$itemid] = $gradediffnorm / $grade_value;
1154                             } else {
1155                                 // Default weighting.
1156                                 $weights[$itemid] = 1.0 / $num;
1157                             }
1158                         }
1159                     }
1160                 }
1162                 if ($weights !== null && $num > 0) {
1163                     foreach ($grade_values as $itemid=>$grade_value) {
1164                         if ($items[$itemid]->aggregationcoef > 0) {
1165                             // Extra credit weights were already calculated.
1166                             continue;
1167                         }
1168                         if ($weights[$itemid]) {
1169                             $weights[$itemid] = 1.0 / $num;
1170                         }
1171                     }
1172                 }
1174                 if ($num == 0) {
1175                     $agg_grade = $sum; // only extra credits or wrong coefs
1177                 } else {
1178                     $agg_grade = $sum / $num;
1179                 }
1181                 break;
1183             case GRADE_AGGREGATE_SUM:    // Add up all the items.
1184                 $this->load_grade_item();
1185                 $num = count($grade_values);
1186                 $sum = 0;
1188                 // This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
1189                 // Even though old algorith has bugs in it, we need to preserve existing grades.
1190                 $gradebookcalculationfreeze = (int)get_config('core', 'gradebook_calculations_freeze_' . $this->courseid);
1191                 $oldextracreditcalculation = $gradebookcalculationfreeze && ($gradebookcalculationfreeze <= 20150619);
1193                 $sumweights = 0;
1194                 $grademin = 0;
1195                 $grademax = 0;
1196                 $extracredititems = array();
1197                 foreach ($grade_values as $itemid => $gradevalue) {
1198                     // We need to check if the grademax/min was adjusted per user because of excluded items.
1199                     $usergrademin = $items[$itemid]->grademin;
1200                     $usergrademax = $items[$itemid]->grademax;
1201                     if (isset($grademinoverrides[$itemid])) {
1202                         $usergrademin = $grademinoverrides[$itemid];
1203                     }
1204                     if (isset($grademaxoverrides[$itemid])) {
1205                         $usergrademax = $grademaxoverrides[$itemid];
1206                     }
1208                     // Keep track of the extra credit items, we will need them later on.
1209                     if ($items[$itemid]->aggregationcoef > 0) {
1210                         $extracredititems[$itemid] = $items[$itemid];
1211                     }
1213                     // Ignore extra credit and items with a weight of 0.
1214                     if (!isset($extracredititems[$itemid]) && $items[$itemid]->aggregationcoef2 > 0) {
1215                         $grademin += $usergrademin;
1216                         $grademax += $usergrademax;
1217                         $sumweights += $items[$itemid]->aggregationcoef2;
1218                     }
1219                 }
1220                 $userweights = array();
1221                 $totaloverriddenweight = 0;
1222                 $totaloverriddengrademax = 0;
1223                 // We first need to rescale all manually assigned weights down by the
1224                 // percentage of weights missing from the category.
1225                 foreach ($grade_values as $itemid => $gradevalue) {
1226                     if ($items[$itemid]->weightoverride) {
1227                         if ($items[$itemid]->aggregationcoef2 <= 0) {
1228                             // Records the weight of 0 and continue.
1229                             $userweights[$itemid] = 0;
1230                             continue;
1231                         }
1232                         $userweights[$itemid] = $sumweights ? ($items[$itemid]->aggregationcoef2 / $sumweights) : 0;
1233                         if (!$oldextracreditcalculation && isset($extracredititems[$itemid])) {
1234                             // Extra credit items do not affect totals.
1235                             continue;
1236                         }
1237                         $totaloverriddenweight += $userweights[$itemid];
1238                         $usergrademax = $items[$itemid]->grademax;
1239                         if (isset($grademaxoverrides[$itemid])) {
1240                             $usergrademax = $grademaxoverrides[$itemid];
1241                         }
1242                         $totaloverriddengrademax += $usergrademax;
1243                     }
1244                 }
1245                 $nonoverriddenpoints = $grademax - $totaloverriddengrademax;
1247                 // Then we need to recalculate the automatic weights except for extra credit items.
1248                 foreach ($grade_values as $itemid => $gradevalue) {
1249                     if (!$items[$itemid]->weightoverride && ($oldextracreditcalculation || !isset($extracredititems[$itemid]))) {
1250                         $usergrademax = $items[$itemid]->grademax;
1251                         if (isset($grademaxoverrides[$itemid])) {
1252                             $usergrademax = $grademaxoverrides[$itemid];
1253                         }
1254                         if ($nonoverriddenpoints > 0) {
1255                             $userweights[$itemid] = ($usergrademax/$nonoverriddenpoints) * (1 - $totaloverriddenweight);
1256                         } else {
1257                             $userweights[$itemid] = 0;
1258                             if ($items[$itemid]->aggregationcoef2 > 0) {
1259                                 // Items with a weight of 0 should not count for the grade max,
1260                                 // though this only applies if the weight was changed to 0.
1261                                 $grademax -= $usergrademax;
1262                             }
1263                         }
1264                     }
1265                 }
1267                 // Now when we finally know the grademax we can adjust the automatic weights of extra credit items.
1268                 if (!$oldextracreditcalculation) {
1269                     foreach ($grade_values as $itemid => $gradevalue) {
1270                         if (!$items[$itemid]->weightoverride && isset($extracredititems[$itemid])) {
1271                             $usergrademax = $items[$itemid]->grademax;
1272                             if (isset($grademaxoverrides[$itemid])) {
1273                                 $usergrademax = $grademaxoverrides[$itemid];
1274                             }
1275                             $userweights[$itemid] = $grademax ? ($usergrademax / $grademax) : 0;
1276                         }
1277                     }
1278                 }
1280                 // We can use our freshly corrected weights below.
1281                 foreach ($grade_values as $itemid => $gradevalue) {
1282                     if (isset($extracredititems[$itemid])) {
1283                         // We skip the extra credit items first.
1284                         continue;
1285                     }
1286                     $sum += $gradevalue * $userweights[$itemid] * $grademax;
1287                     if ($weights !== null) {
1288                         $weights[$itemid] = $userweights[$itemid];
1289                     }
1290                 }
1292                 // No we proceed with the extra credit items. They might have a different final
1293                 // weight in case the final grade was bounded. So we need to treat them different.
1294                 // Also, as we need to use the bounded_grade() method, we have to inject the
1295                 // right values there, and restore them afterwards.
1296                 $oldgrademax = $this->grade_item->grademax;
1297                 $oldgrademin = $this->grade_item->grademin;
1298                 foreach ($grade_values as $itemid => $gradevalue) {
1299                     if (!isset($extracredititems[$itemid])) {
1300                         continue;
1301                     }
1302                     $oldsum = $sum;
1303                     $weightedgrade = $gradevalue * $userweights[$itemid] * $grademax;
1304                     $sum += $weightedgrade;
1306                     // Only go through this when we need to record the weights.
1307                     if ($weights !== null) {
1308                         if ($grademax <= 0) {
1309                             // There are only extra credit items in this category,
1310                             // all the weights should be accurate (and be 0).
1311                             $weights[$itemid] = $userweights[$itemid];
1312                             continue;
1313                         }
1315                         $oldfinalgrade = $this->grade_item->bounded_grade($oldsum);
1316                         $newfinalgrade = $this->grade_item->bounded_grade($sum);
1317                         $finalgradediff = $newfinalgrade - $oldfinalgrade;
1318                         if ($finalgradediff <= 0) {
1319                             // This item did not contribute to the category total at all.
1320                             $weights[$itemid] = 0;
1321                         } else if ($finalgradediff < $weightedgrade) {
1322                             // The weight needs to be adjusted because only a portion of the
1323                             // extra credit item contributed to the category total.
1324                             $weights[$itemid] = $finalgradediff / ($gradevalue * $grademax);
1325                         } else {
1326                             // The weight was accurate.
1327                             $weights[$itemid] = $userweights[$itemid];
1328                         }
1329                     }
1330                 }
1331                 $this->grade_item->grademax = $oldgrademax;
1332                 $this->grade_item->grademin = $oldgrademin;
1334                 if ($grademax > 0) {
1335                     $agg_grade = $sum / $grademax; // Re-normalize score.
1336                 } else {
1337                     // Every item in the category is extra credit.
1338                     $agg_grade = $sum;
1339                     $grademax = $sum;
1340                 }
1342                 break;
1344             case GRADE_AGGREGATE_MEAN:    // Arithmetic average of all grade items (if ungraded aggregated, NULL counted as minimum)
1345             default:
1346                 $num = count($grade_values);
1347                 $sum = array_sum($grade_values);
1348                 $agg_grade = $sum / $num;
1349                 // Record the weights evenly.
1350                 if ($weights !== null && $num > 0) {
1351                     foreach ($grade_values as $itemid=>$grade_value) {
1352                         $weights[$itemid] = 1.0 / $num;
1353                     }
1354                 }
1355                 break;
1356         }
1358         return array('grade' => $agg_grade, 'grademin' => $grademin, 'grademax' => $grademax);
1359     }
1361     /**
1362      * Internal function that calculates the aggregated grade for this grade category
1363      *
1364      * Must be public as it is used by grade_grade::get_hiding_affected()
1365      *
1366      * @deprecated since Moodle 2.8
1367      * @param array $grade_values An array of values to be aggregated
1368      * @param array $items The array of grade_items
1369      * @return float The aggregate grade for this grade category
1370      */
1371     public function aggregate_values($grade_values, $items) {
1372         debugging('grade_category::aggregate_values() is deprecated.
1373                    Call grade_category::aggregate_values_and_adjust_bounds() instead.', DEBUG_DEVELOPER);
1374         $result = $this->aggregate_values_and_adjust_bounds($grade_values, $items);
1375         return $result['grade'];
1376     }
1378     /**
1379      * Some aggregation types may need to update their max grade.
1380      *
1381      * This must be executed after updating the weights as it relies on them.
1382      *
1383      * @return void
1384      */
1385     private function auto_update_max() {
1386         global $DB;
1387         if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1388             // not needed at all
1389             return;
1390         }
1392         // Find grade items of immediate children (category or grade items) and force site settings.
1393         $this->load_grade_item();
1394         $depends_on = $this->grade_item->depends_on();
1396         // Check to see if the gradebook is frozen. This allows grades to not be altered at all until a user verifies that they
1397         // wish to update the grades.
1398         $gradebookcalculationsfreeze = get_config('core', 'gradebook_calculations_freeze_' . $this->courseid);
1399         // Only run if the gradebook isn't frozen.
1400         if ($gradebookcalculationsfreeze && (int)$gradebookcalculationsfreeze <= 20150627) {
1401             // Do nothing.
1402         } else{
1403             // Don't automatically update the max for calculated items.
1404             if ($this->grade_item->is_calculated()) {
1405                 return;
1406             }
1407         }
1409         $items = false;
1410         if (!empty($depends_on)) {
1411             list($usql, $params) = $DB->get_in_or_equal($depends_on);
1412             $sql = "SELECT *
1413                       FROM {grade_items}
1414                      WHERE id $usql";
1415             $items = $DB->get_records_sql($sql, $params);
1416         }
1418         if (!$items) {
1420             if ($this->grade_item->grademax != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1421                 $this->grade_item->grademax  = 0;
1422                 $this->grade_item->grademin  = 0;
1423                 $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1424                 $this->grade_item->update('aggregation');
1425             }
1426             return;
1427         }
1429         //find max grade possible
1430         $maxes = array();
1432         foreach ($items as $item) {
1434             if ($item->aggregationcoef > 0) {
1435                 // extra credit from this activity - does not affect total
1436                 continue;
1437             } else if ($item->aggregationcoef2 <= 0) {
1438                 // Items with a weight of 0 do not affect the total.
1439                 continue;
1440             }
1442             if ($item->gradetype == GRADE_TYPE_VALUE) {
1443                 $maxes[$item->id] = $item->grademax;
1445             } else if ($item->gradetype == GRADE_TYPE_SCALE) {
1446                 $maxes[$item->id] = $item->grademax; // 0 = nograde, 1 = first scale item, 2 = second scale item
1447             }
1448         }
1450         if ($this->can_apply_limit_rules()) {
1451             // Apply droplow and keephigh.
1452             $this->apply_limit_rules($maxes, $items);
1453         }
1454         $max = array_sum($maxes);
1456         // update db if anything changed
1457         if ($this->grade_item->grademax != $max or $this->grade_item->grademin != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1458             $this->grade_item->grademax  = $max;
1459             $this->grade_item->grademin  = 0;
1460             $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1461             $this->grade_item->update('aggregation');
1462         }
1463     }
1465     /**
1466      * Recalculate the weights of the grade items in this category.
1467      *
1468      * The category total is not updated here, a further call to
1469      * {@link self::auto_update_max()} is required.
1470      *
1471      * @return void
1472      */
1473     private function auto_update_weights() {
1474         global $CFG;
1475         if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1476             // This is only required if we are using natural weights.
1477             return;
1478         }
1479         $children = $this->get_children();
1481         $gradeitem = null;
1483         // Calculate the sum of the grademax's of all the items within this category.
1484         $totalnonoverriddengrademax = 0;
1485         $totalgrademax = 0;
1487         // Out of 1, how much weight has been manually overriden by a user?
1488         $totaloverriddenweight  = 0;
1489         $totaloverriddengrademax  = 0;
1491         // Has every assessment in this category been overridden?
1492         $automaticgradeitemspresent = false;
1493         // Does the grade item require normalising?
1494         $requiresnormalising = false;
1496         // This array keeps track of the id and weight of every grade item that has been overridden.
1497         $overridearray = array();
1498         foreach ($children as $sortorder => $child) {
1499             $gradeitem = null;
1501             if ($child['type'] == 'item') {
1502                 $gradeitem = $child['object'];
1503             } else if ($child['type'] == 'category') {
1504                 $gradeitem = $child['object']->load_grade_item();
1505             }
1507             if ($gradeitem->gradetype == GRADE_TYPE_NONE || $gradeitem->gradetype == GRADE_TYPE_TEXT) {
1508                 // Text items and none items do not have a weight.
1509                 continue;
1510             } else if (!$this->aggregateoutcomes && $gradeitem->is_outcome_item()) {
1511                 // We will not aggregate outcome items, so we can ignore them.
1512                 continue;
1513             } else if (empty($CFG->grade_includescalesinaggregation) && $gradeitem->gradetype == GRADE_TYPE_SCALE) {
1514                 // The scales are not included in the aggregation, ignore them.
1515                 continue;
1516             }
1518             // Record the ID and the weight for this grade item.
1519             $overridearray[$gradeitem->id] = array();
1520             $overridearray[$gradeitem->id]['extracredit'] = intval($gradeitem->aggregationcoef);
1521             $overridearray[$gradeitem->id]['weight'] = $gradeitem->aggregationcoef2;
1522             $overridearray[$gradeitem->id]['weightoverride'] = intval($gradeitem->weightoverride);
1523             // If this item has had its weight overridden then set the flag to true, but
1524             // only if all previous items were also overridden. Note that extra credit items
1525             // are counted as overridden grade items.
1526             if (!$gradeitem->weightoverride && $gradeitem->aggregationcoef == 0) {
1527                 $automaticgradeitemspresent = true;
1528             }
1530             if ($gradeitem->aggregationcoef > 0) {
1531                 // An extra credit grade item doesn't contribute to $totaloverriddengrademax.
1532                 continue;
1533             } else if ($gradeitem->weightoverride > 0 && $gradeitem->aggregationcoef2 <= 0) {
1534                 // An overriden item that defines a weight of 0 does not contribute to $totaloverriddengrademax.
1535                 continue;
1536             }
1538             $totalgrademax += $gradeitem->grademax;
1539             if ($gradeitem->weightoverride > 0) {
1540                 $totaloverriddenweight += $gradeitem->aggregationcoef2;
1541                 $totaloverriddengrademax += $gradeitem->grademax;
1542             }
1543         }
1545         // Initialise this variable (used to keep track of the weight override total).
1546         $normalisetotal = 0;
1547         // 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
1548         // other weights to zero and normalise the others.
1549         $overriddentotal = 0;
1550         // If the overridden weight total is higher than 1 then set the other untouched weights to zero.
1551         $setotherweightstozero = false;
1552         // Total up all of the weights.
1553         foreach ($overridearray as $gradeitemdetail) {
1554             // If the grade item has extra credit, then don't add it to the normalisetotal.
1555             if (!$gradeitemdetail['extracredit']) {
1556                 $normalisetotal += $gradeitemdetail['weight'];
1557             }
1558             // The overridden total comprises of items that are set as overridden, that aren't extra credit and have a value
1559             // greater than zero.
1560             if ($gradeitemdetail['weightoverride'] && !$gradeitemdetail['extracredit'] && $gradeitemdetail['weight'] > 0) {
1561                 // Add overriden weights up to see if they are greater than 1.
1562                 $overriddentotal += $gradeitemdetail['weight'];
1563             }
1564         }
1565         if ($overriddentotal > 1) {
1566             // Make sure that this catergory of weights gets normalised.
1567             $requiresnormalising = true;
1568             // The normalised weights are only the overridden weights, so we just use the total of those.
1569             $normalisetotal = $overriddentotal;
1570         }
1572         $totalnonoverriddengrademax = $totalgrademax - $totaloverriddengrademax;
1574         // This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
1575         // Even though old algorith has bugs in it, we need to preserve existing grades.
1576         $gradebookcalculationfreeze = (int)get_config('core', 'gradebook_calculations_freeze_' . $this->courseid);
1577         $oldextracreditcalculation = $gradebookcalculationfreeze && ($gradebookcalculationfreeze <= 20150619);
1579         reset($children);
1580         foreach ($children as $sortorder => $child) {
1581             $gradeitem = null;
1583             if ($child['type'] == 'item') {
1584                 $gradeitem = $child['object'];
1585             } else if ($child['type'] == 'category') {
1586                 $gradeitem = $child['object']->load_grade_item();
1587             }
1589             if ($gradeitem->gradetype == GRADE_TYPE_NONE || $gradeitem->gradetype == GRADE_TYPE_TEXT) {
1590                 // Text items and none items do not have a weight, no need to set their weight to
1591                 // zero as they must never be used during aggregation.
1592                 continue;
1593             } else if (!$this->aggregateoutcomes && $gradeitem->is_outcome_item()) {
1594                 // We will not aggregate outcome items, so we can ignore updating their weights.
1595                 continue;
1596             } else if (empty($CFG->grade_includescalesinaggregation) && $gradeitem->gradetype == GRADE_TYPE_SCALE) {
1597                 // We will not aggregate the scales, so we can ignore upating their weights.
1598                 continue;
1599             } else if (!$oldextracreditcalculation && $gradeitem->aggregationcoef > 0 && $gradeitem->weightoverride) {
1600                 // For an item with extra credit ignore other weigths and overrides but do not change anything at all
1601                 // if it's weight was already overridden.
1602                 continue;
1603             }
1605             // Store the previous value here, no need to update if it is the same value.
1606             $prevaggregationcoef2 = $gradeitem->aggregationcoef2;
1608             if (!$oldextracreditcalculation && $gradeitem->aggregationcoef > 0 && !$gradeitem->weightoverride) {
1609                 // For an item with extra credit ignore other weigths and overrides.
1610                 $gradeitem->aggregationcoef2 = $totalgrademax ? ($gradeitem->grademax / $totalgrademax) : 0;
1612             } else if (!$gradeitem->weightoverride) {
1613                 // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.
1614                 if ($totaloverriddenweight >= 1 || $totalnonoverriddengrademax == 0 || $gradeitem->grademax == 0) {
1615                     // There is no more weight to distribute.
1616                     $gradeitem->aggregationcoef2 = 0;
1617                 } else {
1618                     // Calculate this item's weight as a percentage of the non-overridden total grade maxes
1619                     // then convert it to a proportion of the available non-overriden weight.
1620                     $gradeitem->aggregationcoef2 = ($gradeitem->grademax/$totalnonoverriddengrademax) *
1621                             (1 - $totaloverriddenweight);
1622                 }
1624             } else if ((!$automaticgradeitemspresent && $normalisetotal != 1) || ($requiresnormalising)
1625                     || $overridearray[$gradeitem->id]['weight'] < 0) {
1626                 // Just divide the overriden weight for this item against the total weight override of all
1627                 // items in this category.
1628                 if ($normalisetotal == 0 || $overridearray[$gradeitem->id]['weight'] < 0) {
1629                     // If the normalised total equals zero, or the weight value is less than zero,
1630                     // set the weight for the grade item to zero.
1631                     $gradeitem->aggregationcoef2 = 0;
1632                 } else {
1633                     $gradeitem->aggregationcoef2 = $overridearray[$gradeitem->id]['weight'] / $normalisetotal;
1634                 }
1635             }
1637             if (grade_floatval($prevaggregationcoef2) !== grade_floatval($gradeitem->aggregationcoef2)) {
1638                 // Update the grade item to reflect these changes.
1639                 $gradeitem->update();
1640             }
1641         }
1642     }
1644     /**
1645      * Given an array of grade values (numerical indices) applies droplow or keephigh rules to limit the final array.
1646      *
1647      * @param array $grade_values itemid=>$grade_value float
1648      * @param array $items grade item objects
1649      * @return array Limited grades.
1650      */
1651     public function apply_limit_rules(&$grade_values, $items) {
1652         $extraused = $this->is_extracredit_used();
1654         if (!empty($this->droplow)) {
1655             asort($grade_values, SORT_NUMERIC);
1656             $dropped = 0;
1658             // If we have fewer grade items available to drop than $this->droplow, use this flag to escape the loop
1659             // May occur because of "extra credit" or if droplow is higher than the number of grade items
1660             $droppedsomething = true;
1662             while ($dropped < $this->droplow && $droppedsomething) {
1663                 $droppedsomething = false;
1665                 $grade_keys = array_keys($grade_values);
1666                 $gradekeycount = count($grade_keys);
1668                 if ($gradekeycount === 0) {
1669                     //We've dropped all grade items
1670                     break;
1671                 }
1673                 $originalindex = $founditemid = $foundmax = null;
1675                 // Find the first remaining grade item that is available to be dropped
1676                 foreach ($grade_keys as $gradekeyindex=>$gradekey) {
1677                     if (!$extraused || $items[$gradekey]->aggregationcoef <= 0) {
1678                         // Found a non-extra credit grade item that is eligible to be dropped
1679                         $originalindex = $gradekeyindex;
1680                         $founditemid = $grade_keys[$originalindex];
1681                         $foundmax = $items[$founditemid]->grademax;
1682                         break;
1683                     }
1684                 }
1686                 if (empty($founditemid)) {
1687                     // No grade items available to drop
1688                     break;
1689                 }
1691                 // Now iterate over the remaining grade items
1692                 // We're looking for other grade items with the same grade value but a higher grademax
1693                 $i = 1;
1694                 while ($originalindex + $i < $gradekeycount) {
1696                     $possibleitemid = $grade_keys[$originalindex+$i];
1697                     $i++;
1699                     if ($grade_values[$founditemid] != $grade_values[$possibleitemid]) {
1700                         // The next grade item has a different grade value. Stop looking.
1701                         break;
1702                     }
1704                     if ($extraused && $items[$possibleitemid]->aggregationcoef > 0) {
1705                         // Don't drop extra credit grade items. Continue the search.
1706                         continue;
1707                     }
1709                     if ($foundmax < $items[$possibleitemid]->grademax) {
1710                         // Found a grade item with the same grade value and a higher grademax
1711                         $foundmax = $items[$possibleitemid]->grademax;
1712                         $founditemid = $possibleitemid;
1713                         // Continue searching to see if there is an even higher grademax
1714                     }
1715                 }
1717                 // Now drop whatever grade item we have found
1718                 unset($grade_values[$founditemid]);
1719                 $dropped++;
1720                 $droppedsomething = true;
1721             }
1723         } else if (!empty($this->keephigh)) {
1724             arsort($grade_values, SORT_NUMERIC);
1725             $kept = 0;
1727             foreach ($grade_values as $itemid=>$value) {
1729                 if ($extraused and $items[$itemid]->aggregationcoef > 0) {
1730                     // we keep all extra credits
1732                 } else if ($kept < $this->keephigh) {
1733                     $kept++;
1735                 } else {
1736                     unset($grade_values[$itemid]);
1737                 }
1738             }
1739         }
1740     }
1742     /**
1743      * Returns whether or not we can apply the limit rules.
1744      *
1745      * There are cases where drop lowest or keep highest should not be used
1746      * at all. This method will determine whether or not this logic can be
1747      * applied considering the current setup of the category.
1748      *
1749      * @return bool
1750      */
1751     public function can_apply_limit_rules() {
1752         if ($this->canapplylimitrules !== null) {
1753             return $this->canapplylimitrules;
1754         }
1756         // Set it to be supported by default.
1757         $this->canapplylimitrules = true;
1759         // Natural aggregation.
1760         if ($this->aggregation == GRADE_AGGREGATE_SUM) {
1761             $canapply = true;
1763             // Check until one child breaks the rules.
1764             $gradeitems = $this->get_children();
1765             $validitems = 0;
1766             $lastweight = null;
1767             $lastmaxgrade = null;
1768             foreach ($gradeitems as $gradeitem) {
1769                 $gi = $gradeitem['object'];
1771                 if ($gradeitem['type'] == 'category') {
1772                     // Sub categories are not allowed because they can have dynamic weights/maxgrades.
1773                     $canapply = false;
1774                     break;
1775                 }
1777                 if ($gi->aggregationcoef > 0) {
1778                     // Extra credit items are not allowed.
1779                     $canapply = false;
1780                     break;
1781                 }
1783                 if ($lastweight !== null && $lastweight != $gi->aggregationcoef2) {
1784                     // One of the weight differs from another item.
1785                     $canapply = false;
1786                     break;
1787                 }
1789                 if ($lastmaxgrade !== null && $lastmaxgrade != $gi->grademax) {
1790                     // One of the max grade differ from another item. This is not allowed for now
1791                     // because we could be end up with different max grade between users for this category.
1792                     $canapply = false;
1793                     break;
1794                 }
1796                 $lastweight = $gi->aggregationcoef2;
1797                 $lastmaxgrade = $gi->grademax;
1798             }
1800             $this->canapplylimitrules = $canapply;
1801         }
1803         return $this->canapplylimitrules;
1804     }
1806     /**
1807      * Returns true if category uses extra credit of any kind
1808      *
1809      * @return bool True if extra credit used
1810      */
1811     public function is_extracredit_used() {
1812         return self::aggregation_uses_extracredit($this->aggregation);
1813     }
1815     /**
1816      * Returns true if aggregation passed is using extracredit.
1817      *
1818      * @param int $aggregation Aggregation const.
1819      * @return bool True if extra credit used
1820      */
1821     public static function aggregation_uses_extracredit($aggregation) {
1822         return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1823              or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1824              or $aggregation == GRADE_AGGREGATE_SUM);
1825     }
1827     /**
1828      * Returns true if category uses special aggregation coefficient
1829      *
1830      * @return bool True if an aggregation coefficient is being used
1831      */
1832     public function is_aggregationcoef_used() {
1833         return self::aggregation_uses_aggregationcoef($this->aggregation);
1835     }
1837     /**
1838      * Returns true if aggregation uses aggregationcoef
1839      *
1840      * @param int $aggregation Aggregation const.
1841      * @return bool True if an aggregation coefficient is being used
1842      */
1843     public static function aggregation_uses_aggregationcoef($aggregation) {
1844         return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN
1845              or $aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1846              or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1847              or $aggregation == GRADE_AGGREGATE_SUM);
1849     }
1851     /**
1852      * Recursive function to find which weight/extra credit field to use in the grade item form.
1853      *
1854      * @param string $first Whether or not this is the first item in the recursion
1855      * @return string
1856      */
1857     public function get_coefstring($first=true) {
1858         if (!is_null($this->coefstring)) {
1859             return $this->coefstring;
1860         }
1862         $overriding_coefstring = null;
1864         // Stop recursing upwards if this category has no parent
1865         if (!$first) {
1867             if ($parent_category = $this->load_parent_category()) {
1868                 return $parent_category->get_coefstring(false);
1870             } else {
1871                 return null;
1872             }
1874         } else if ($first) {
1876             if ($parent_category = $this->load_parent_category()) {
1877                 $overriding_coefstring = $parent_category->get_coefstring(false);
1878             }
1879         }
1881         // If an overriding coefstring has trickled down from one of the parent categories, return it. Otherwise, return self.
1882         if (!is_null($overriding_coefstring)) {
1883             return $overriding_coefstring;
1884         }
1886         // No parent category is overriding this category's aggregation, return its string
1887         if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN) {
1888             $this->coefstring = 'aggregationcoefweight';
1890         } else if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2) {
1891             $this->coefstring = 'aggregationcoefextrasum';
1893         } else if ($this->aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN) {
1894             $this->coefstring = 'aggregationcoefextraweight';
1896         } else if ($this->aggregation == GRADE_AGGREGATE_SUM) {
1897             $this->coefstring = 'aggregationcoefextraweightsum';
1899         } else {
1900             $this->coefstring = 'aggregationcoef';
1901         }
1902         return $this->coefstring;
1903     }
1905     /**
1906      * Returns tree with all grade_items and categories as elements
1907      *
1908      * @param int $courseid The course ID
1909      * @param bool $include_category_items as category children
1910      * @return array
1911      */
1912     public static function fetch_course_tree($courseid, $include_category_items=false) {
1913         $course_category = grade_category::fetch_course_category($courseid);
1914         $category_array = array('object'=>$course_category, 'type'=>'category', 'depth'=>1,
1915                                 'children'=>$course_category->get_children($include_category_items));
1917         $course_category->sortorder = $course_category->get_sortorder();
1918         $sortorder = $course_category->get_sortorder();
1919         return grade_category::_fetch_course_tree_recursion($category_array, $sortorder);
1920     }
1922     /**
1923      * An internal function that recursively sorts grade categories within a course
1924      *
1925      * @param array $category_array The seed of the recursion
1926      * @param int   $sortorder The current sortorder
1927      * @return array An array containing 'object', 'type', 'depth' and optionally 'children'
1928      */
1929     static private function _fetch_course_tree_recursion($category_array, &$sortorder) {
1930         if (isset($category_array['object']->gradetype) && $category_array['object']->gradetype==GRADE_TYPE_NONE) {
1931             return null;
1932         }
1934         // store the grade_item or grade_category instance with extra info
1935         $result = array('object'=>$category_array['object'], 'type'=>$category_array['type'], 'depth'=>$category_array['depth']);
1937         // reuse final grades if there
1938         if (array_key_exists('finalgrades', $category_array)) {
1939             $result['finalgrades'] = $category_array['finalgrades'];
1940         }
1942         // recursively resort children
1943         if (!empty($category_array['children'])) {
1944             $result['children'] = array();
1945             //process the category item first
1946             $child = null;
1948             foreach ($category_array['children'] as $oldorder=>$child_array) {
1950                 if ($child_array['type'] == 'courseitem' or $child_array['type'] == 'categoryitem') {
1951                     $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
1952                     if (!empty($child)) {
1953                         $result['children'][$sortorder] = $child;
1954                     }
1955                 }
1956             }
1958             foreach ($category_array['children'] as $oldorder=>$child_array) {
1960                 if ($child_array['type'] != 'courseitem' and $child_array['type'] != 'categoryitem') {
1961                     $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
1962                     if (!empty($child)) {
1963                         $result['children'][++$sortorder] = $child;
1964                     }
1965                 }
1966             }
1967         }
1969         return $result;
1970     }
1972     /**
1973      * Fetches and returns all the children categories and/or grade_items belonging to this category.
1974      * By default only returns the immediate children (depth=1), but deeper levels can be requested,
1975      * as well as all levels (0). The elements are indexed by sort order.
1976      *
1977      * @param bool $include_category_items Whether or not to include category grade_items in the children array
1978      * @return array Array of child objects (grade_category and grade_item).
1979      */
1980     public function get_children($include_category_items=false) {
1981         global $DB;
1983         // This function must be as fast as possible ;-)
1984         // fetch all course grade items and categories into memory - we do not expect hundreds of these in course
1985         // we have to limit the number of queries though, because it will be used often in grade reports
1987         $cats  = $DB->get_records('grade_categories', array('courseid' => $this->courseid));
1988         $items = $DB->get_records('grade_items', array('courseid' => $this->courseid));
1990         // init children array first
1991         foreach ($cats as $catid=>$cat) {
1992             $cats[$catid]->children = array();
1993         }
1995         //first attach items to cats and add category sortorder
1996         foreach ($items as $item) {
1998             if ($item->itemtype == 'course' or $item->itemtype == 'category') {
1999                 $cats[$item->iteminstance]->sortorder = $item->sortorder;
2001                 if (!$include_category_items) {
2002                     continue;
2003                 }
2004                 $categoryid = $item->iteminstance;
2006             } else {
2007                 $categoryid = $item->categoryid;
2008                 if (empty($categoryid)) {
2009                     debugging('Found a grade item that isnt in a category');
2010                 }
2011             }
2013             // prevent problems with duplicate sortorders in db
2014             $sortorder = $item->sortorder;
2016             while (array_key_exists($categoryid, $cats)
2017                 && array_key_exists($sortorder, $cats[$categoryid]->children)) {
2019                 $sortorder++;
2020             }
2022             $cats[$categoryid]->children[$sortorder] = $item;
2024         }
2026         // now find the requested category and connect categories as children
2027         $category = false;
2029         foreach ($cats as $catid=>$cat) {
2031             if (empty($cat->parent)) {
2033                 if ($cat->path !== '/'.$cat->id.'/') {
2034                     $grade_category = new grade_category($cat, false);
2035                     $grade_category->path  = '/'.$cat->id.'/';
2036                     $grade_category->depth = 1;
2037                     $grade_category->update('system');
2038                     return $this->get_children($include_category_items);
2039                 }
2041             } else {
2043                 if (empty($cat->path) or !preg_match('|/'.$cat->parent.'/'.$cat->id.'/$|', $cat->path)) {
2044                     //fix paths and depts
2045                     static $recursioncounter = 0; // prevents infinite recursion
2046                     $recursioncounter++;
2048                     if ($recursioncounter < 5) {
2049                         // fix paths and depths!
2050                         $grade_category = new grade_category($cat, false);
2051                         $grade_category->depth = 0;
2052                         $grade_category->path  = null;
2053                         $grade_category->update('system');
2054                         return $this->get_children($include_category_items);
2055                     }
2056                 }
2057                 // prevent problems with duplicate sortorders in db
2058                 $sortorder = $cat->sortorder;
2060                 while (array_key_exists($sortorder, $cats[$cat->parent]->children)) {
2061                     //debugging("$sortorder exists in cat loop");
2062                     $sortorder++;
2063                 }
2065                 $cats[$cat->parent]->children[$sortorder] = &$cats[$catid];
2066             }
2068             if ($catid == $this->id) {
2069                 $category = &$cats[$catid];
2070             }
2071         }
2073         unset($items); // not needed
2074         unset($cats); // not needed
2076         $children_array = array();
2077         if (is_object($category)) {
2078             $children_array = grade_category::_get_children_recursion($category);
2079             ksort($children_array);
2080         }
2082         return $children_array;
2084     }
2086     /**
2087      * Private method used to retrieve all children of this category recursively
2088      *
2089      * @param grade_category $category Source of current recursion
2090      * @return array An array of child grade categories
2091      */
2092     private static function _get_children_recursion($category) {
2094         $children_array = array();
2095         foreach ($category->children as $sortorder=>$child) {
2097             if (array_key_exists('itemtype', $child)) {
2098                 $grade_item = new grade_item($child, false);
2100                 if (in_array($grade_item->itemtype, array('course', 'category'))) {
2101                     $type  = $grade_item->itemtype.'item';
2102                     $depth = $category->depth;
2104                 } else {
2105                     $type  = 'item';
2106                     $depth = $category->depth; // we use this to set the same colour
2107                 }
2108                 $children_array[$sortorder] = array('object'=>$grade_item, 'type'=>$type, 'depth'=>$depth);
2110             } else {
2111                 $children = grade_category::_get_children_recursion($child);
2112                 $grade_category = new grade_category($child, false);
2114                 if (empty($children)) {
2115                     $children = array();
2116                 }
2117                 $children_array[$sortorder] = array('object'=>$grade_category, 'type'=>'category', 'depth'=>$grade_category->depth, 'children'=>$children);
2118             }
2119         }
2121         // sort the array
2122         ksort($children_array);
2124         return $children_array;
2125     }
2127     /**
2128      * Uses {@link get_grade_item()} to load or create a grade_item, then saves it as $this->grade_item.
2129      *
2130      * @return grade_item
2131      */
2132     public function load_grade_item() {
2133         if (empty($this->grade_item)) {
2134             $this->grade_item = $this->get_grade_item();
2135         }
2136         return $this->grade_item;
2137     }
2139     /**
2140      * Retrieves this grade categories' associated grade_item from the database
2141      *
2142      * If no grade_item exists yet, creates one.
2143      *
2144      * @return grade_item
2145      */
2146     public function get_grade_item() {
2147         if (empty($this->id)) {
2148             debugging("Attempt to obtain a grade_category's associated grade_item without the category's ID being set.");
2149             return false;
2150         }
2152         if (empty($this->parent)) {
2153             $params = array('courseid'=>$this->courseid, 'itemtype'=>'course', 'iteminstance'=>$this->id);
2155         } else {
2156             $params = array('courseid'=>$this->courseid, 'itemtype'=>'category', 'iteminstance'=>$this->id);
2157         }
2159         if (!$grade_items = grade_item::fetch_all($params)) {
2160             // create a new one
2161             $grade_item = new grade_item($params, false);
2162             $grade_item->gradetype = GRADE_TYPE_VALUE;
2163             $grade_item->insert('system');
2165         } else if (count($grade_items) == 1) {
2166             // found existing one
2167             $grade_item = reset($grade_items);
2169         } else {
2170             debugging("Found more than one grade_item attached to category id:".$this->id);
2171             // return first one
2172             $grade_item = reset($grade_items);
2173         }
2175         return $grade_item;
2176     }
2178     /**
2179      * Uses $this->parent to instantiate $this->parent_category based on the referenced record in the DB
2180      *
2181      * @return grade_category The parent category
2182      */
2183     public function load_parent_category() {
2184         if (empty($this->parent_category) && !empty($this->parent)) {
2185             $this->parent_category = $this->get_parent_category();
2186         }
2187         return $this->parent_category;
2188     }
2190     /**
2191      * Uses $this->parent to instantiate and return a grade_category object
2192      *
2193      * @return grade_category Returns the parent category or null if this category has no parent
2194      */
2195     public function get_parent_category() {
2196         if (!empty($this->parent)) {
2197             $parent_category = new grade_category(array('id' => $this->parent));
2198             return $parent_category;
2199         } else {
2200             return null;
2201         }
2202     }
2204     /**
2205      * Returns the most descriptive field for this grade category
2206      *
2207      * @return string name
2208      */
2209     public function get_name() {
2210         global $DB;
2211         // For a course category, we return the course name if the fullname is set to '?' in the DB (empty in the category edit form)
2212         if (empty($this->parent) && $this->fullname == '?') {
2213             $course = $DB->get_record('course', array('id'=> $this->courseid));
2214             return format_string($course->fullname);
2216         } else {
2217             return $this->fullname;
2218         }
2219     }
2221     /**
2222      * Describe the aggregation settings for this category so the reports make more sense.
2223      *
2224      * @return string description
2225      */
2226     public function get_description() {
2227         $allhelp = array();
2228         if ($this->aggregation != GRADE_AGGREGATE_SUM) {
2229             $aggrstrings = grade_helper::get_aggregation_strings();
2230             $allhelp[] = $aggrstrings[$this->aggregation];
2231         }
2233         if ($this->droplow && $this->can_apply_limit_rules()) {
2234             $allhelp[] = get_string('droplowestvalues', 'grades', $this->droplow);
2235         }
2236         if ($this->keephigh && $this->can_apply_limit_rules()) {
2237             $allhelp[] = get_string('keephighestvalues', 'grades', $this->keephigh);
2238         }
2239         if (!$this->aggregateonlygraded) {
2240             $allhelp[] = get_string('aggregatenotonlygraded', 'grades');
2241         }
2242         if ($allhelp) {
2243             return implode('. ', $allhelp) . '.';
2244         }
2245         return '';
2246     }
2248     /**
2249      * Sets this category's parent id
2250      *
2251      * @param int $parentid The ID of the category that is the new parent to $this
2252      * @param string $source From where was the object updated (mod/forum, manual, etc.)
2253      * @return bool success
2254      */
2255     public function set_parent($parentid, $source=null) {
2256         if ($this->parent == $parentid) {
2257             return true;
2258         }
2260         if ($parentid == $this->id) {
2261             print_error('cannotassignselfasparent');
2262         }
2264         if (empty($this->parent) and $this->is_course_category()) {
2265             print_error('cannothaveparentcate');
2266         }
2268         // find parent and check course id
2269         if (!$parent_category = grade_category::fetch(array('id'=>$parentid, 'courseid'=>$this->courseid))) {
2270             return false;
2271         }
2273         $this->force_regrading();
2275         // set new parent category
2276         $this->parent          = $parent_category->id;
2277         $this->parent_category =& $parent_category;
2278         $this->path            = null;       // remove old path and depth - will be recalculated in update()
2279         $this->depth           = 0;          // remove old path and depth - will be recalculated in update()
2280         $this->update($source);
2282         return $this->update($source);
2283     }
2285     /**
2286      * Returns the final grade values for this grade category.
2287      *
2288      * @param int $userid Optional user ID to retrieve a single user's final grade
2289      * @return mixed An array of all final_grades (stdClass objects) for this grade_item, or a single final_grade.
2290      */
2291     public function get_final($userid=null) {
2292         $this->load_grade_item();
2293         return $this->grade_item->get_final($userid);
2294     }
2296     /**
2297      * Returns the sortorder of the grade categories' associated grade_item
2298      *
2299      * This method is also available in grade_item for cases where the object type is not known.
2300      *
2301      * @return int Sort order
2302      */
2303     public function get_sortorder() {
2304         $this->load_grade_item();
2305         return $this->grade_item->get_sortorder();
2306     }
2308     /**
2309      * Returns the idnumber of the grade categories' associated grade_item.
2310      *
2311      * This method is also available in grade_item for cases where the object type is not known.
2312      *
2313      * @return string idnumber
2314      */
2315     public function get_idnumber() {
2316         $this->load_grade_item();
2317         return $this->grade_item->get_idnumber();
2318     }
2320     /**
2321      * Sets the sortorder variable for this category.
2322      *
2323      * This method is also available in grade_item, for cases where the object type is not know.
2324      *
2325      * @param int $sortorder The sortorder to assign to this category
2326      */
2327     public function set_sortorder($sortorder) {
2328         $this->load_grade_item();
2329         $this->grade_item->set_sortorder($sortorder);
2330     }
2332     /**
2333      * Move this category after the given sortorder
2334      *
2335      * Does not change the parent
2336      *
2337      * @param int $sortorder to place after.
2338      * @return void
2339      */
2340     public function move_after_sortorder($sortorder) {
2341         $this->load_grade_item();
2342         $this->grade_item->move_after_sortorder($sortorder);
2343     }
2345     /**
2346      * Return true if this is the top most category that represents the total course grade.
2347      *
2348      * @return bool
2349      */
2350     public function is_course_category() {
2351         $this->load_grade_item();
2352         return $this->grade_item->is_course_item();
2353     }
2355     /**
2356      * Return the course level grade_category object
2357      *
2358      * @param int $courseid The Course ID
2359      * @return grade_category Returns the course level grade_category instance
2360      */
2361     public static function fetch_course_category($courseid) {
2362         if (empty($courseid)) {
2363             debugging('Missing course id!');
2364             return false;
2365         }
2367         // course category has no parent
2368         if ($course_category = grade_category::fetch(array('courseid'=>$courseid, 'parent'=>null))) {
2369             return $course_category;
2370         }
2372         // create a new one
2373         $course_category = new grade_category();
2374         $course_category->insert_course_category($courseid);
2376         return $course_category;
2377     }
2379     /**
2380      * Is grading object editable?
2381      *
2382      * @return bool
2383      */
2384     public function is_editable() {
2385         return true;
2386     }
2388     /**
2389      * Returns the locked state/date of the grade categories' associated grade_item.
2390      *
2391      * This method is also available in grade_item, for cases where the object type is not known.
2392      *
2393      * @return bool
2394      */
2395     public function is_locked() {
2396         $this->load_grade_item();
2397         return $this->grade_item->is_locked();
2398     }
2400     /**
2401      * Sets the grade_item's locked variable and updates the grade_item.
2402      *
2403      * Calls set_locked() on the categories' grade_item
2404      *
2405      * @param int  $lockedstate 0, 1 or a timestamp int(10) after which date the item will be locked.
2406      * @param bool $cascade lock/unlock child objects too
2407      * @param bool $refresh refresh grades when unlocking
2408      * @return bool success if category locked (not all children mayb be locked though)
2409      */
2410     public function set_locked($lockedstate, $cascade=false, $refresh=true) {
2411         $this->load_grade_item();
2413         $result = $this->grade_item->set_locked($lockedstate, $cascade, true);
2415         if ($cascade) {
2416             //process all children - items and categories
2417             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
2419                 foreach ($children as $child) {
2420                     $child->set_locked($lockedstate, true, false);
2422                     if (empty($lockedstate) and $refresh) {
2423                         //refresh when unlocking
2424                         $child->refresh_grades();
2425                     }
2426                 }
2427             }
2429             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
2431                 foreach ($children as $child) {
2432                     $child->set_locked($lockedstate, true, true);
2433                 }
2434             }
2435         }
2437         return $result;
2438     }
2440     /**
2441      * Overrides grade_object::set_properties() to add special handling for changes to category aggregation types
2442      *
2443      * @param stdClass $instance the object to set the properties on
2444      * @param array|stdClass $params Either an associative array or an object containing property name, property value pairs
2445      */
2446     public static function set_properties(&$instance, $params) {
2447         global $DB;
2449         $fromaggregation = $instance->aggregation;
2451         parent::set_properties($instance, $params);
2453         // The aggregation method is changing and this category has already been saved.
2454         if (isset($params->aggregation) && !empty($instance->id)) {
2455             $achildwasdupdated = false;
2457             // Get all its children.
2458             $children = $instance->get_children();
2459             foreach ($children as $child) {
2460                 $item = $child['object'];
2461                 if ($child['type'] == 'category') {
2462                     $item = $item->load_grade_item();
2463                 }
2465                 // Set the new aggregation fields.
2466                 if ($item->set_aggregation_fields_for_aggregation($fromaggregation, $params->aggregation)) {
2467                     $item->update();
2468                     $achildwasdupdated = true;
2469                 }
2470             }
2472             // If this is the course category, it is possible that its grade item was set as needsupdate
2473             // by one of its children. If we keep a reference to that stale object we might cause the
2474             // needsupdate flag to be lost. It's safer to just reload the grade_item from the database.
2475             if ($achildwasdupdated && !empty($instance->grade_item) && $instance->is_course_category()) {
2476                 $instance->grade_item = null;
2477                 $instance->load_grade_item();
2478             }
2479         }
2480     }
2482     /**
2483      * Sets the grade_item's hidden variable and updates the grade_item.
2484      *
2485      * Overrides grade_item::set_hidden() to add cascading of the hidden value to grade items in this grade category
2486      *
2487      * @param int $hidden 0 mean always visible, 1 means always hidden and a number > 1 is a timestamp to hide until
2488      * @param bool $cascade apply to child objects too
2489      */
2490     public function set_hidden($hidden, $cascade=false) {
2491         $this->load_grade_item();
2492         //this hides the associated grade item (the course total)
2493         $this->grade_item->set_hidden($hidden, $cascade);
2494         //this hides the category itself and everything it contains
2495         parent::set_hidden($hidden, $cascade);
2497         if ($cascade) {
2499             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
2501                 foreach ($children as $child) {
2502                     if ($child->can_control_visibility()) {
2503                         $child->set_hidden($hidden, $cascade);
2504                     }
2505                 }
2506             }
2508             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
2510                 foreach ($children as $child) {
2511                     $child->set_hidden($hidden, $cascade);
2512                 }
2513             }
2514         }
2516         //if marking category visible make sure parent category is visible MDL-21367
2517         if( !$hidden ) {
2518             $category_array = grade_category::fetch_all(array('id'=>$this->parent));
2519             if ($category_array && array_key_exists($this->parent, $category_array)) {
2520                 $category = $category_array[$this->parent];
2521                 //call set_hidden on the category regardless of whether it is hidden as its parent might be hidden
2522                 //if($category->is_hidden()) {
2523                     $category->set_hidden($hidden, false);
2524                 //}
2525             }
2526         }
2527     }
2529     /**
2530      * Applies default settings on this category
2531      *
2532      * @return bool True if anything changed
2533      */
2534     public function apply_default_settings() {
2535         global $CFG;
2537         foreach ($this->forceable as $property) {
2539             if (isset($CFG->{"grade_$property"})) {
2541                 if ($CFG->{"grade_$property"} == -1) {
2542                     continue; //temporary bc before version bump
2543                 }
2544                 $this->$property = $CFG->{"grade_$property"};
2545             }
2546         }
2547     }
2549     /**
2550      * Applies forced settings on this category
2551      *
2552      * @return bool True if anything changed
2553      */
2554     public function apply_forced_settings() {
2555         global $CFG;
2557         $updated = false;
2559         foreach ($this->forceable as $property) {
2561             if (isset($CFG->{"grade_$property"}) and isset($CFG->{"grade_{$property}_flag"}) and
2562                                                     ((int) $CFG->{"grade_{$property}_flag"} & 1)) {
2564                 if ($CFG->{"grade_$property"} == -1) {
2565                     continue; //temporary bc before version bump
2566                 }
2567                 $this->$property = $CFG->{"grade_$property"};
2568                 $updated = true;
2569             }
2570         }
2572         return $updated;
2573     }
2575     /**
2576      * Notification of change in forced category settings.
2577      *
2578      * Causes all course and category grade items to be marked as needing to be updated
2579      */
2580     public static function updated_forced_settings() {
2581         global $CFG, $DB;
2582         $params = array(1, 'course', 'category');
2583         $sql = "UPDATE {grade_items} SET needsupdate=? WHERE itemtype=? or itemtype=?";
2584         $DB->execute($sql, $params);
2585     }
2587     /**
2588      * Determine the default aggregation values for a given aggregation method.
2589      *
2590      * @param int $aggregationmethod The aggregation method constant value.
2591      * @return array Containing the keys 'aggregationcoef', 'aggregationcoef2' and 'weightoverride'.
2592      */
2593     public static function get_default_aggregation_coefficient_values($aggregationmethod) {
2594         $defaultcoefficients = array(
2595             'aggregationcoef' => 0,
2596             'aggregationcoef2' => 0,
2597             'weightoverride' => 0
2598         );
2600         switch ($aggregationmethod) {
2601             case GRADE_AGGREGATE_WEIGHTED_MEAN:
2602                 $defaultcoefficients['aggregationcoef'] = 1;
2603                 break;
2604             case GRADE_AGGREGATE_SUM:
2605                 $defaultcoefficients['aggregationcoef2'] = 1;
2606                 break;
2607         }
2609         return $defaultcoefficients;
2610     }