MDL-17248 fixed incorrect sum when scales involved; merged from MOODLE_19_STABLE...
[moodle.git] / lib / grade / grade_category.php
1 <?php // $Id$
3 ///////////////////////////////////////////////////////////////////////////
4 //                                                                       //
5 // NOTICE OF COPYRIGHT                                                   //
6 //                                                                       //
7 // Moodle - Modular Object-Oriented Dynamic Learning Environment         //
8 //          http://moodle.com                                            //
9 //                                                                       //
10 // Copyright (C) 1999 onwards Martin Dougiamas  http://dougiamas.com     //
11 //                                                                       //
12 // This program is free software; you can redistribute it and/or modify  //
13 // it under the terms of the GNU General Public License as published by  //
14 // the Free Software Foundation; either version 2 of the License, or     //
15 // (at your option) any later version.                                   //
16 //                                                                       //
17 // This program is distributed in the hope that it will be useful,       //
18 // but WITHOUT ANY WARRANTY; without even the implied warranty of        //
19 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         //
20 // GNU General Public License for more details:                          //
21 //                                                                       //
22 //          http://www.gnu.org/copyleft/gpl.html                         //
23 //                                                                       //
24 ///////////////////////////////////////////////////////////////////////////
26 require_once('grade_object.php');
28 class grade_category extends grade_object {
29     /**
30      * The DB table.
31      * @var string $table
32      */
33     public $table = 'grade_categories';
35     /**
36      * Array of required table fields, must start with 'id'.
37      * @var array $required_fields
38      */
39     public $required_fields = array('id', 'courseid', 'parent', 'depth', 'path', 'fullname', 'aggregation',
40                                  'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes',
41                                  'aggregatesubcats', 'timecreated', 'timemodified');
43     /**
44      * The course this category belongs to.
45      * @var int $courseid
46      */
47     public $courseid;
49     /**
50      * The category this category belongs to (optional).
51      * @var int $parent
52      */
53     public $parent;
55     /**
56      * The grade_category object referenced by $this->parent (PK).
57      * @var object $parent_category
58      */
59     public $parent_category;
61     /**
62      * The number of parents this category has.
63      * @var int $depth
64      */
65     public $depth = 0;
67     /**
68      * Shows the hierarchical path for this category as /1/2/3/ (like course_categories), the last number being
69      * this category's autoincrement ID number.
70      * @var string $path
71      */
72     public $path;
74     /**
75      * The name of this category.
76      * @var string $fullname
77      */
78     public $fullname;
80     /**
81      * A constant pointing to one of the predefined aggregation strategies (none, mean, median, sum etc) .
82      * @var int $aggregation
83      */
84     public $aggregation = GRADE_AGGREGATE_MEAN;
86     /**
87      * Keep only the X highest items.
88      * @var int $keephigh
89      */
90     public $keephigh = 0;
92     /**
93      * Drop the X lowest items.
94      * @var int $droplow
95      */
96     public $droplow = 0;
98     /**
99      * Aggregate only graded items
100      * @var int $aggregateonlygraded
101      */
102     public $aggregateonlygraded = 0;
104     /**
105      * Aggregate outcomes together with normal items
106      * @var int $aggregateoutcomes
107      */
108     public $aggregateoutcomes = 0;
110     /**
111      * Ignore subcategories when aggregating
112      * @var int $aggregatesubcats
113      */
114     public $aggregatesubcats = 0;
116     /**
117      * Array of grade_items or grade_categories nested exactly 1 level below this category
118      * @var array $children
119      */
120     public $children;
122     /**
123      * A hierarchical array of all children below this category. This is stored separately from
124      * $children because it is more memory-intensive and may not be used as often.
125      * @var array $all_children
126      */
127     public $all_children;
129     /**
130      * An associated grade_item object, with itemtype=category, used to calculate and cache a set of grade values
131      * for this category.
132      * @var object $grade_item
133      */
134     public $grade_item;
136     /**
137      * Temporary sortorder for speedup of children resorting
138      */
139     public $sortorder;
141     /**
142      * List of options which can be "forced" from site settings.
143      */
144     public $forceable = array('aggregation', 'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes', 'aggregatesubcats');
146     /**
147      * String representing the aggregation coefficient. Variable is used as cache.
148      */
149     var $coefstring = null;
151     /**
152      * Builds this category's path string based on its parents (if any) and its own id number.
153      * This is typically done just before inserting this object in the DB for the first time,
154      * or when a new parent is added or changed. It is a recursive function: once the calling
155      * object no longer has a parent, the path is complete.
156      *
157      * @static
158      * @param object $grade_category
159      * @return int The depth of this category (2 means there is one parent)
160      */
161     public function build_path($grade_category) {
162         global $DB;
164         if (empty($grade_category->parent)) {
165             return '/'.$grade_category->id.'/';
166         } else {
167             $parent = $DB->get_record('grade_categories', array('id' => $grade_category->parent));
168             return grade_category::build_path($parent).$grade_category->id.'/';
169         }
170     }
172     /**
173      * Finds and returns a grade_category instance based on params.
174      * @static
175      *
176      * @param array $params associative arrays varname=>value
177      * @return object grade_category instance or false if none found.
178      */
179     public static function fetch($params) {
180         return grade_object::fetch_helper('grade_categories', 'grade_category', $params);
181     }
183     /**
184      * Finds and returns all grade_category instances based on params.
185      * @static
186      *
187      * @param array $params associative arrays varname=>value
188      * @return array array of grade_category insatnces or false if none found.
189      */
190     public static function fetch_all($params) {
191         return grade_object::fetch_all_helper('grade_categories', 'grade_category', $params);
192     }
194     /**
195      * In addition to update() as defined in grade_object, call force_regrading of parent categories, if applicable.
196      * @param string $source from where was the object updated (mod/forum, manual, etc.)
197      * @return boolean success
198      */
199     public function update($source=null) {
200         // load the grade item or create a new one
201         $this->load_grade_item();
203         // force recalculation of path;
204         if (empty($this->path)) {
205             $this->path  = grade_category::build_path($this);
206             $this->depth = substr_count($this->path, '/') - 1;
207             $updatechildren = true;
208         } else {
209             $updatechildren = false;
210         }
212         $this->apply_forced_settings();
214                 // these are exclusive
215         if ($this->droplow > 0) {
216             $this->keephigh = 0;
217         } else if ($this->keephigh > 0) {
218             $this->droplow = 0;
219         }
221         // Recalculate grades if needed
222         if ($this->qualifies_for_regrading()) {
223             $this->force_regrading();
224         }
226         $this->timemodified = time();
228         $result = parent::update($source);
230         // now update paths in all child categories
231         if ($result and $updatechildren) {
232             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
233                 foreach ($children as $child) {
234                     $child->path  = null;
235                     $child->depth = 0;
236                     $child->update($source);
237                 }
238             }
239         }
241         return $result;
242     }
244     /**
245      * If parent::delete() is successful, send force_regrading message to parent category.
246      * @param string $source from where was the object deleted (mod/forum, manual, etc.)
247      * @return boolean success
248      */
249     public function delete($source=null) {
250         $grade_item = $this->load_grade_item();
252         if ($this->is_course_category()) {
253             if ($categories = grade_category::fetch_all(array('courseid'=>$this->courseid))) {
254                 foreach ($categories as $category) {
255                     if ($category->id == $this->id) {
256                         continue; // do not delete course category yet
257                     }
258                     $category->delete($source);
259                 }
260             }
262             if ($items = grade_item::fetch_all(array('courseid'=>$this->courseid))) {
263                 foreach ($items as $item) {
264                     if ($item->id == $grade_item->id) {
265                         continue; // do not delete course item yet
266                     }
267                     $item->delete($source);
268                 }
269             }
271         } else {
272             $this->force_regrading();
274             $parent = $this->load_parent_category();
276             // Update children's categoryid/parent field first
277             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
278                 foreach ($children as $child) {
279                     $child->set_parent($parent->id);
280                 }
281             }
282             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
283                 foreach ($children as $child) {
284                     $child->set_parent($parent->id);
285                 }
286             }
287         }
289         // first delete the attached grade item and grades
290         $grade_item->delete($source);
292         // delete category itself
293         return parent::delete($source);
294     }
296     /**
297      * In addition to the normal insert() defined in grade_object, this method sets the depth
298      * and path for this object, and update the record accordingly. The reason why this must
299      * be done here instead of in the constructor, is that they both need to know the record's
300      * id number, which only gets created at insertion time.
301      * This method also creates an associated grade_item if this wasn't done during construction.
302      * @param string $source from where was the object inserted (mod/forum, manual, etc.)
303      * @return int PK ID if successful, false otherwise
304      */
305     public function insert($source=null) {
307         if (empty($this->courseid)) {
308             print_error('cannotinsertgrade');
309         }
311         if (empty($this->parent)) {
312             $course_category = grade_category::fetch_course_category($this->courseid);
313             $this->parent = $course_category->id;
314         }
316         $this->path = null;
318         $this->timecreated = $this->timemodified = time();
320         if (!parent::insert($source)) {
321             debugging("Could not insert this category: " . print_r($this, true));
322             return false;
323         }
325         $this->force_regrading();
327         // build path and depth
328         $this->update($source);
330         return $this->id;
331     }
333     /**
334      * Internal function - used only from fetch_course_category()
335      * Normal insert() can not be used for course category
336      * @param int $courseid
337      * @return bool success
338      */
339     public function insert_course_category($courseid) {
340         $this->courseid    = $courseid;
341         $this->fullname    = '?';
342         $this->path        = null;
343         $this->parent      = null;
344         $this->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
346         $this->apply_default_settings();
347         $this->apply_forced_settings();
349         $this->timecreated = $this->timemodified = time();
351         if (!parent::insert('system')) {
352             debugging("Could not insert this category: " . print_r($this, true));
353             return false;
354         }
356         // build path and depth
357         $this->update('system');
359         return $this->id;
360     }
362     /**
363      * Compares the values held by this object with those of the matching record in DB, and returns
364      * whether or not these differences are sufficient to justify an update of all parent objects.
365      * This assumes that this object has an id number and a matching record in DB. If not, it will return false.
366      * @return boolean
367      */
368     public function qualifies_for_regrading() {
369         if (empty($this->id)) {
370             debugging("Can not regrade non existing category");
371             return false;
372         }
374         $db_item = grade_category::fetch(array('id'=>$this->id));
376         $aggregationdiff = $db_item->aggregation         != $this->aggregation;
377         $keephighdiff    = $db_item->keephigh            != $this->keephigh;
378         $droplowdiff     = $db_item->droplow             != $this->droplow;
379         $aggonlygrddiff  = $db_item->aggregateonlygraded != $this->aggregateonlygraded;
380         $aggoutcomesdiff = $db_item->aggregateoutcomes   != $this->aggregateoutcomes;
381         $aggsubcatsdiff  = $db_item->aggregatesubcats    != $this->aggregatesubcats;
383         return ($aggregationdiff || $keephighdiff || $droplowdiff || $aggonlygrddiff || $aggoutcomesdiff || $aggsubcatsdiff);
384     }
386     /**
387      * Marks the category and course item as needing update - categories are always regraded.
388      * @return void
389      */
390     public function force_regrading() {
391         $grade_item = $this->load_grade_item();
392         $grade_item->force_regrading();
393     }
395     /**
396      * Generates and saves final grades in associated category grade item.
397      * These immediate children must already have their own final grades.
398      * The category's aggregation method is used to generate final grades.
399      *
400      * Please note that category grade is either calculated or aggregated - not both at the same time.
401      *
402      * This method must be used ONLY from grade_item::regrade_final_grades(),
403      * because the calculation must be done in correct order!
404      *
405      * Steps to follow:
406      *  1. Get final grades from immediate children
407      *  3. Aggregate these grades
408      *  4. Save them in final grades of associated category grade item
409      */
410     public function generate_grades($userid=null) {
411         global $CFG, $DB;
413         $this->load_grade_item();
415         if ($this->grade_item->is_locked()) {
416             return true; // no need to recalculate locked items
417         }
419         // find grade items of immediate children (category or grade items) and force site settings
420         $depends_on = $this->grade_item->depends_on();
422         if (empty($depends_on)) {
423             $items = false;
424         } else {
425             list($usql, $params) = $DB->get_in_or_equal($depends_on);
426             $sql = "SELECT *
427                       FROM {grade_items}
428                      WHERE id $usql";
429             $items = $DB->get_records_sql($sql, $params);
430         }
432         // needed mostly for SUM agg type
433         $this->auto_update_max($items);
435         $grade_inst = new grade_grade();
436         $fields = 'g.'.implode(',g.', $grade_inst->required_fields);
438         // where to look for final grades - include grade of this item too, we will store the results there
439         $gis = array_merge($depends_on, array($this->grade_item->id));
440         list($usql, $params) = $DB->get_in_or_equal($gis);
442         if ($userid) {
443             $usersql = "AND g.userid=?";
444             $params[] = $userid;
445         } else {
446             $usersql = "";
447         }
449         $sql = "SELECT $fields
450                   FROM {grade_grades} g, {grade_items} gi
451                  WHERE gi.id = g.itemid AND gi.id $usql $usersql
452               ORDER BY g.userid";
454         // group the results by userid and aggregate the grades for this user
455         if ($rs = $DB->get_recordset_sql($sql, $params)) {
456             $prevuser = 0;
457             $grade_values = array();
458             $excluded     = array();
459             $oldgrade     = null;
460             foreach ($rs as $used) {
461                 if ($used->userid != $prevuser) {
462                     $this->aggregate_grades($prevuser, $items, $grade_values, $oldgrade, $excluded);
463                     $prevuser = $used->userid;
464                     $grade_values = array();
465                     $excluded     = array();
466                     $oldgrade     = null;
467                 }
468                 $grade_values[$used->itemid] = $used->finalgrade;
469                 if ($used->excluded) {
470                     $excluded[] = $used->itemid;
471                 }
472                 if ($this->grade_item->id == $used->itemid) {
473                     $oldgrade = $used;
474                 }
475             }
476             $rs->close();
477             $this->aggregate_grades($prevuser, $items, $grade_values, $oldgrade, $excluded);//the last one
478         }
480         return true;
481     }
483     /**
484      * internal function for category grades aggregation
485      *
486      * @param int $userid
487      * @param array $items
488      * @param array $grade_values
489      * @param object $oldgrade
490      * @param bool $excluded
491      * @return boolean (just plain return;)
492      */
493     private function aggregate_grades($userid, $items, $grade_values, $oldgrade, $excluded) {
494         global $CFG;
495         if (empty($userid)) {
496             //ignore first call
497             return;
498         }
500         if ($oldgrade) {
501             $oldfinalgrade = $oldgrade->finalgrade;
502             $grade = new grade_grade($oldgrade, false);
503             $grade->grade_item =& $this->grade_item;
505         } else {
506             // insert final grade - it will be needed later anyway
507             $grade = new grade_grade(array('itemid'=>$this->grade_item->id, 'userid'=>$userid), false);
508             $grade->grade_item =& $this->grade_item;
509             $grade->insert('system');
510             $oldfinalgrade = null;
511         }
513         // no need to recalculate locked or overridden grades
514         if ($grade->is_locked() or $grade->is_overridden()) {
515             return;
516         }
518         // can not use own final category grade in calculation
519         unset($grade_values[$this->grade_item->id]);
522     /// sum is a special aggregation types - it adjusts the min max, does not use relative values
523         if ($this->aggregation == GRADE_AGGREGATE_SUM) {
524             $this->sum_grades($grade, $oldfinalgrade, $items, $grade_values, $excluded);
525             return;
526         }
528         // if no grades calculation possible or grading not allowed clear final grade
529         if (empty($grade_values) or empty($items) or ($this->grade_item->gradetype != GRADE_TYPE_VALUE and $this->grade_item->gradetype != GRADE_TYPE_SCALE)) {
530             $grade->finalgrade = null;
531             if (!is_null($oldfinalgrade)) {
532                 $grade->update('aggregation');
533             }
534             return;
535         }
537     /// normalize the grades first - all will have value 0...1
538         // ungraded items are not used in aggregation
539         foreach ($grade_values as $itemid=>$v) {
540             if (is_null($v)) {
541                 // null means no grade
542                 unset($grade_values[$itemid]);
543                 continue;
544             } else if (in_array($itemid, $excluded)) {
545                 unset($grade_values[$itemid]);
546                 continue;
547             }
548             $grade_values[$itemid] = grade_grade::standardise_score($v, $items[$itemid]->grademin, $items[$itemid]->grademax, 0, 1);
549         }
551         // use min grade if grade missing for these types
552         if (!$this->aggregateonlygraded) {
553             foreach($items as $itemid=>$value) {
554                 if (!isset($grade_values[$itemid]) and !in_array($itemid, $excluded)) {
555                     $grade_values[$itemid] = 0;
556                 }
557             }
558         }
560         // limit and sort
561         $this->apply_limit_rules($grade_values);
562         asort($grade_values, SORT_NUMERIC);
564         // let's see we have still enough grades to do any statistics
565         if (count($grade_values) == 0) {
566             // not enough attempts yet
567             $grade->finalgrade = null;
568             if (!is_null($oldfinalgrade)) {
569                 $grade->update('aggregation');
570             }
571             return;
572         }
574         // do the maths
575         $agg_grade = $this->aggregate_values($grade_values, $items);
577         // recalculate the grade back to requested range
578         $finalgrade = grade_grade::standardise_score($agg_grade, 0, 1, $this->grade_item->grademin, $this->grade_item->grademax);
580         $grade->finalgrade = $this->grade_item->bounded_grade($finalgrade);
582         // update in db if changed
583         if (grade_floats_different($grade->finalgrade, $oldfinalgrade)) {
584             $grade->update('aggregation');
585         }
587         return;
588     }
590     /**
591      * Internal function - aggregation maths.
592      * Must be public: used by grade_grade::get_hiding_affected()
593      */
594     public function aggregate_values($grade_values, $items) {
595         switch ($this->aggregation) {
596             case GRADE_AGGREGATE_MEDIAN: // Middle point value in the set: ignores frequencies
597                 $num = count($grade_values);
598                 $grades = array_values($grade_values);
599                 if ($num % 2 == 0) {
600                     $agg_grade = ($grades[intval($num/2)-1] + $grades[intval($num/2)]) / 2;
601                 } else {
602                     $agg_grade = $grades[intval(($num/2)-0.5)];
603                 }
604                 break;
606             case GRADE_AGGREGATE_MIN:
607                 $agg_grade = reset($grade_values);
608                 break;
610             case GRADE_AGGREGATE_MAX:
611                 $agg_grade = array_pop($grade_values);
612                 break;
614             case GRADE_AGGREGATE_MODE:       // the most common value, average used if multimode
615                 // array_count_values only counts INT and STRING, so if grades are floats we must convert them to string
616                 $converted_grade_values = array();
618                 foreach ($grade_values as $k => $gv) {
619                     if (!is_int($gv) && !is_string($gv)) {
620                         $converted_grade_values[$k] = (string) $gv;
621                     } else {
622                         $converted_grade_values[$k] = $gv;
623                     }
624                 }
626                 $freq = array_count_values($converted_grade_values);
627                 arsort($freq);                      // sort by frequency keeping keys
628                 $top = reset($freq);               // highest frequency count
629                 $modes = array_keys($freq, $top);  // search for all modes (have the same highest count)
630                 rsort($modes, SORT_NUMERIC);       // get highes mode
631                 $agg_grade = reset($modes);
632                 break;
634             case GRADE_AGGREGATE_WEIGHTED_MEAN: // Weighted average of all existing final grades, weight specified in coef
635                 $weightsum = 0;
636                 $sum       = 0;
637                 foreach($grade_values as $itemid=>$grade_value) {
638                     if ($items[$itemid]->aggregationcoef <= 0) {
639                         continue;
640                     }
641                     $weightsum += $items[$itemid]->aggregationcoef;
642                     $sum       += $items[$itemid]->aggregationcoef * $grade_value;
643                 }
644                 if ($weightsum == 0) {
645                     $agg_grade = null;
646                 } else {
647                     $agg_grade = $sum / $weightsum;
648                 }
649                 break;
651             case GRADE_AGGREGATE_WEIGHTED_MEAN2:
652                 // Weighted average of all existing final grades with optional extra credit flag,
653                 // weight is the range of grade (ususally grademax)
654                 $weightsum = 0;
655                 $sum       = null;
656                 foreach($grade_values as $itemid=>$grade_value) {
657                     $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
658                     if ($weight <= 0) {
659                         continue;
660                     }
661                     if ($items[$itemid]->aggregationcoef == 0) {
662                         $weightsum += $weight;
663                     }
664                     $sum += $weight * $grade_value;
665                 }
666                 if ($weightsum == 0) {
667                     $agg_grade = $sum; // only extra credits
668                 } else {
669                     $agg_grade = $sum / $weightsum;
670                 }
671                 break;
673             case GRADE_AGGREGATE_EXTRACREDIT_MEAN: // special average
674                 $num = 0;
675                 $sum = null;
676                 foreach($grade_values as $itemid=>$grade_value) {
677                     if ($items[$itemid]->aggregationcoef == 0) {
678                         $num += 1;
679                         $sum += $grade_value;
680                     } else if ($items[$itemid]->aggregationcoef > 0) {
681                         $sum += $items[$itemid]->aggregationcoef * $grade_value;
682                     }
683                 }
684                 if ($num == 0) {
685                     $agg_grade = $sum; // only extra credits or wrong coefs
686                 } else {
687                     $agg_grade = $sum / $num;
688                 }
689                 break;
691             case GRADE_AGGREGATE_MEAN:    // Arithmetic average of all grade items (if ungraded aggregated, NULL counted as minimum)
692             default:
693                 $num = count($grade_values);
694                 $sum = array_sum($grade_values);
695                 $agg_grade = $sum / $num;
696                 break;
697         }
699         return $agg_grade;
700     }
702     /**
703      * Some aggregation tpyes may update max grade
704      * @param array $items sub items
705      * @return void
706      */
707     private function auto_update_max($items) {
708         if ($this->aggregation != GRADE_AGGREGATE_SUM) {
709             // not needed at all
710             return;
711         }
713         if (!$items) {
714             if ($this->grade_item->grademax != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
715                 $this->grade_item->grademax  = 0;
716                 $this->grade_item->grademin  = 0;
717                 $this->grade_item->gradetype = GRADE_TYPE_VALUE;
718                 $this->grade_item->update('aggregation');
719             }
720             return;
721         }
723         $max = 0;
725         //find max grade
726         foreach ($items as $item) {
727             if ($item->aggregationcoef > 0) {
728                 // extra credit from this activity - does not affect total
729                 continue;
730             }
731             if ($item->gradetype == GRADE_TYPE_VALUE) {
732                 $max += $item->grademax;
733             } else if ($item->gradetype == GRADE_TYPE_SCALE) {
734                 $max += $item->grademax; // 0 = nograde, 1 = first scale item, 2 = second scale item
735             }
736         }
738         if ($this->grade_item->grademax != $max or $this->grade_item->grademin != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE){
739             $this->grade_item->grademax  = $max;
740             $this->grade_item->grademin  = 0;
741             $this->grade_item->gradetype = GRADE_TYPE_VALUE;
742             $this->grade_item->update('aggregation');
743         }
744     }
746     /**
747      * internal function for category grades summing
748      *
749      * @param object $grade
750      * @param int $userid
751      * @param float $oldfinalgrade
752      * @param array $items
753      * @param array $grade_values
754      * @param bool $excluded
755      * @return boolean (just plain return;)
756      */
757     private function sum_grades(&$grade, $oldfinalgrade, $items, $grade_values, $excluded) {
758         if (empty($items)) {
759             return null;
760         }
762         // ungraded and exluded items are not used in aggregation
763         foreach ($grade_values as $itemid=>$v) {
764             if (is_null($v)) {
765                 unset($grade_values[$itemid]);
766             } else if (in_array($itemid, $excluded)) {
767                 unset($grade_values[$itemid]);
768             }
769         }
771         // use 0 if grade missing, droplow used and aggregating all items
772         if (!$this->aggregateonlygraded and !empty($this->droplow)) {
773             foreach($items as $itemid=>$value) {
774                 if (!isset($grade_values[$itemid]) and !in_array($itemid, $excluded)) {
775                     $grade_values[$itemid] = 0;
776                 }
777             }
778         }
780         $this->apply_limit_rules($grade_values);
782         $sum = array_sum($grade_values);
783         $grade->finalgrade = $this->grade_item->bounded_grade($sum);
785         // update in db if changed
786         if (grade_floats_different($grade->finalgrade, $oldfinalgrade)) {
787             $grade->update('aggregation');
788         }
790         return;
791     }
793     /**
794      * Given an array of grade values (numerical indices), applies droplow or keephigh
795      * rules to limit the final array.
796      * @param array $grade_values
797      * @return array Limited grades.
798      */
799     public function apply_limit_rules(&$grade_values) {
800         arsort($grade_values, SORT_NUMERIC);
801         if (!empty($this->droplow)) {
802             for ($i = 0; $i < $this->droplow; $i++) {
803                 array_pop($grade_values);
804             }
805         } elseif (!empty($this->keephigh)) {
806             while (count($grade_values) > $this->keephigh) {
807                 array_pop($grade_values);
808             }
809         }
810     }
813     /**
814      * Returns true if category uses special aggregation coeficient
815      * @return boolean true if coeficient used
816      */
817     public function is_aggregationcoef_used() {
818         return ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN
819              or $this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
820              or $this->aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
821              or $this->aggregation == GRADE_AGGREGATE_SUM);
823     }
825     /**
826      * Recursive function to find which weight/extra credit field to use in the grade item form. Inherits from a parent category
827      * if that category has aggregatesubcats set to true.
828      * @param string $coefstring
829      * @return string $coefstring
830      */
831     public function get_coefstring($first=true) {
832         if (!is_null($this->coefstring)) {
833             return $this->coefstring;
834         }
836         $overriding_coefstring = null;
838         // Stop recursing upwards if this category aggregates subcats or has no parent
839         if (!$first && !$this->aggregatesubcats) {
840             if ($parent_category = $this->load_parent_category()) {
841                 return $parent_category->get_coefstring(false);
842             } else {
843                 return null;
844             }
845         } elseif ($first) {
846             if (!$this->aggregatesubcats) {
847                 if ($parent_category = $this->load_parent_category()) {
848                     $overriding_coefstring = $parent_category->get_coefstring(false);
849                 }
850             }
851         }
853         // If an overriding coefstring has trickled down from one of the parent categories, return it. Otherwise, return self.
854         if (!is_null($overriding_coefstring)) {
855             return $overriding_coefstring;
856         }
858         // No parent category is overriding this category's aggregation, return its string
859         if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN) {
860             $this->coefstring = 'aggregationcoefweight';
861         } else if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2) {
862             $this->coefstring = 'aggregationcoefextrasum';
863         } else if ($this->aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN) {
864             $this->coefstring = 'aggregationcoefextra';
865         } else if ($this->aggregation == GRADE_AGGREGATE_SUM) {
866             $this->coefstring = 'aggregationcoefextrasum';
867         } else {
868             $this->coefstring = 'aggregationcoef';
869         }
870         return $this->coefstring;
871     }
873     /**
874      * Returns tree with all grade_items and categories as elements
875      * @static
876      * @param int $courseid
877      * @param boolean $include_category_items as category children
878      * @return array
879      */
880     public static function fetch_course_tree($courseid, $include_category_items=false) {
881         $course_category = grade_category::fetch_course_category($courseid);
882         $category_array = array('object'=>$course_category, 'type'=>'category', 'depth'=>1,
883                                 'children'=>$course_category->get_children($include_category_items));
884         $sortorder = 1;
885         $course_category->set_sortorder($sortorder);
886         $course_category->sortorder = $sortorder;
887         return grade_category::_fetch_course_tree_recursion($category_array, $sortorder);
888     }
890     private function _fetch_course_tree_recursion($category_array, &$sortorder) {
891         // update the sortorder in db if needed
892         if ($category_array['object']->sortorder != $sortorder) {
893             $category_array['object']->set_sortorder($sortorder);
894         }
896         // store the grade_item or grade_category instance with extra info
897         $result = array('object'=>$category_array['object'], 'type'=>$category_array['type'], 'depth'=>$category_array['depth']);
899         // reuse final grades if there
900         if (array_key_exists('finalgrades', $category_array)) {
901             $result['finalgrades'] = $category_array['finalgrades'];
902         }
904         // recursively resort children
905         if (!empty($category_array['children'])) {
906             $result['children'] = array();
907             //process the category item first
908             $cat_item_id = null;
909             foreach($category_array['children'] as $oldorder=>$child_array) {
910                 if ($child_array['type'] == 'courseitem' or $child_array['type'] == 'categoryitem') {
911                     $result['children'][$sortorder] = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
912                 }
913             }
914             foreach($category_array['children'] as $oldorder=>$child_array) {
915                 if ($child_array['type'] != 'courseitem' and $child_array['type'] != 'categoryitem') {
916                     $result['children'][++$sortorder] = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
917                 }
918             }
919         }
921         return $result;
922     }
924     /**
925      * Fetches and returns all the children categories and/or grade_items belonging to this category.
926      * By default only returns the immediate children (depth=1), but deeper levels can be requested,
927      * as well as all levels (0). The elements are indexed by sort order.
928      * @return array Array of child objects (grade_category and grade_item).
929      */
930     public function get_children($include_category_items=false) {
931         global $DB;
933         // This function must be as fast as possible ;-)
934         // fetch all course grade items and categories into memory - we do not expect hundreds of these in course
935         // we have to limit the number of queries though, because it will be used often in grade reports
937         $cats  = $DB->get_records('grade_categories', array('courseid' => $this->courseid));
938         $items = $DB->get_records('grade_items', array('courseid' => $this->courseid));
940         // init children array first
941         foreach ($cats as $catid=>$cat) {
942             $cats[$catid]->children = array();
943         }
945         //first attach items to cats and add category sortorder
946         foreach ($items as $item) {
947             if ($item->itemtype == 'course' or $item->itemtype == 'category') {
948                 $cats[$item->iteminstance]->sortorder = $item->sortorder;
950                 if (!$include_category_items) {
951                     continue;
952                 }
953                 $categoryid = $item->iteminstance;
954             } else {
955                 $categoryid = $item->categoryid;
956             }
958             // prevent problems with duplicate sortorders in db
959             $sortorder = $item->sortorder;
960             while(array_key_exists($sortorder, $cats[$categoryid]->children)) {
961                 //debugging("$sortorder exists in item loop");
962                 $sortorder++;
963             }
965             $cats[$categoryid]->children[$sortorder] = $item;
967         }
969         // now find the requested category and connect categories as children
970         $category = false;
971         foreach ($cats as $catid=>$cat) {
972             if (empty($cat->parent)) {
973                 if ($cat->path !== '/'.$cat->id.'/') {
974                     $grade_category = new grade_category($cat, false);
975                     $grade_category->path  = '/'.$cat->id.'/';
976                     $grade_category->depth = 1;
977                     $grade_category->update('system');
978                     return $this->get_children($include_category_items);
979                 }
980             } else {
981                 if (empty($cat->path) or !preg_match('|/'.$cat->parent.'/'.$cat->id.'/$|', $cat->path)) {
982                     //fix paths and depts
983                     static $recursioncounter = 0; // prevents infinite recursion
984                     $recursioncounter++;
985                     if ($recursioncounter < 5) {
986                         // fix paths and depths!
987                         $grade_category = new grade_category($cat, false);
988                         $grade_category->depth = 0;
989                         $grade_category->path  = null;
990                         $grade_category->update('system');
991                         return $this->get_children($include_category_items);
992                     }
993                 }
994                 // prevent problems with duplicate sortorders in db
995                 $sortorder = $cat->sortorder;
996                 while(array_key_exists($sortorder, $cats[$cat->parent]->children)) {
997                     //debugging("$sortorder exists in cat loop");
998                     $sortorder++;
999                 }
1001                 $cats[$cat->parent]->children[$sortorder] = &$cats[$catid];
1002             }
1004             if ($catid == $this->id) {
1005                 $category = &$cats[$catid];
1006             }
1007         }
1009         unset($items); // not needed
1010         unset($cats); // not needed
1012         $children_array = grade_category::_get_children_recursion($category);
1014         ksort($children_array);
1016         return $children_array;
1018     }
1020     private function _get_children_recursion($category) {
1022         $children_array = array();
1023         foreach($category->children as $sortorder=>$child) {
1024             if (array_key_exists('itemtype', $child)) {
1025                 $grade_item = new grade_item($child, false);
1026                 if (in_array($grade_item->itemtype, array('course', 'category'))) {
1027                     $type  = $grade_item->itemtype.'item';
1028                     $depth = $category->depth;
1029                 } else {
1030                     $type  = 'item';
1031                     $depth = $category->depth; // we use this to set the same colour
1032                 }
1033                 $children_array[$sortorder] = array('object'=>$grade_item, 'type'=>$type, 'depth'=>$depth);
1035             } else {
1036                 $children = grade_category::_get_children_recursion($child);
1037                 $grade_category = new grade_category($child, false);
1038                 if (empty($children)) {
1039                     $children = array();
1040                 }
1041                 $children_array[$sortorder] = array('object'=>$grade_category, 'type'=>'category', 'depth'=>$grade_category->depth, 'children'=>$children);
1042             }
1043         }
1045         // sort the array
1046         ksort($children_array);
1048         return $children_array;
1049     }
1051     /**
1052      * Uses get_grade_item to load or create a grade_item, then saves it as $this->grade_item.
1053      * @return object Grade_item
1054      */
1055     public function load_grade_item() {
1056         if (empty($this->grade_item)) {
1057             $this->grade_item = $this->get_grade_item();
1058         }
1059         return $this->grade_item;
1060     }
1062     /**
1063      * Retrieves from DB and instantiates the associated grade_item object.
1064      * If no grade_item exists yet, create one.
1065      * @return object Grade_item
1066      */
1067     public function get_grade_item() {
1068         if (empty($this->id)) {
1069             debugging("Attempt to obtain a grade_category's associated grade_item without the category's ID being set.");
1070             return false;
1071         }
1073         if (empty($this->parent)) {
1074             $params = array('courseid'=>$this->courseid, 'itemtype'=>'course', 'iteminstance'=>$this->id);
1076         } else {
1077             $params = array('courseid'=>$this->courseid, 'itemtype'=>'category', 'iteminstance'=>$this->id);
1078         }
1080         if (!$grade_items = grade_item::fetch_all($params)) {
1081             // create a new one
1082             $grade_item = new grade_item($params, false);
1083             $grade_item->gradetype = GRADE_TYPE_VALUE;
1084             $grade_item->insert('system');
1086         } else if (count($grade_items) == 1){
1087             // found existing one
1088             $grade_item = reset($grade_items);
1090         } else {
1091             debugging("Found more than one grade_item attached to category id:".$this->id);
1092             // return first one
1093             $grade_item = reset($grade_items);
1094         }
1096         return $grade_item;
1097     }
1099     /**
1100      * Uses $this->parent to instantiate $this->parent_category based on the
1101      * referenced record in the DB.
1102      * @return object Parent_category
1103      */
1104     public function load_parent_category() {
1105         if (empty($this->parent_category) && !empty($this->parent)) {
1106             $this->parent_category = $this->get_parent_category();
1107         }
1108         return $this->parent_category;
1109     }
1111     /**
1112      * Uses $this->parent to instantiate and return a grade_category object.
1113      * @return object Parent_category
1114      */
1115     public function get_parent_category() {
1116         if (!empty($this->parent)) {
1117             $parent_category = new grade_category(array('id' => $this->parent));
1118             return $parent_category;
1119         } else {
1120             return null;
1121         }
1122     }
1124     /**
1125      * Returns the most descriptive field for this object. This is a standard method used
1126      * when we do not know the exact type of an object.
1127      * @return string name
1128      */
1129     public function get_name() {
1130         global $DB;
1131         // For a course category, we return the course name if the fullname is set to '?' in the DB (empty in the category edit form)
1132         if (empty($this->parent) && $this->fullname == '?') {
1133             $course = $DB->get_record('course', array('id'=> $this->courseid));
1134             return format_string($course->fullname);
1135         } else {
1136             return $this->fullname;
1137         }
1138     }
1140     /**
1141      * Sets this category's parent id. A generic method shared by objects that have a parent id of some kind.
1142      * @param int parentid
1143      * @return boolean success
1144      */
1145     public function set_parent($parentid, $source=null) {
1146         if ($this->parent == $parentid) {
1147             return true;
1148         }
1150         if ($parentid == $this->id) {
1151             print_error('cannotassignselfasparent');
1152         }
1154         if (empty($this->parent) and $this->is_course_category()) {
1155             print_error('cannothaveparentcate');
1156         }
1158         // find parent and check course id
1159         if (!$parent_category = grade_category::fetch(array('id'=>$parentid, 'courseid'=>$this->courseid))) {
1160             return false;
1161         }
1163         $this->force_regrading();
1165         // set new parent category
1166         $this->parent          = $parent_category->id;
1167         $this->parent_category =& $parent_category;
1168         $this->path            = null;       // remove old path and depth - will be recalculated in update()
1169         $this->depth           = 0;          // remove old path and depth - will be recalculated in update()
1170         $this->update($source);
1172         return $this->update($source);
1173     }
1175     /**
1176      * Returns the final values for this grade category.
1177      * @param int $userid Optional: to retrieve a single final grade
1178      * @return mixed An array of all final_grades (stdClass objects) for this grade_item, or a single final_grade.
1179      */
1180     public function get_final($userid=NULL) {
1181         $this->load_grade_item();
1182         return $this->grade_item->get_final($userid);
1183     }
1185     /**
1186      * Returns the sortorder of the associated grade_item. This method is also available in
1187      * grade_item, for cases where the object type is not known.
1188      * @return int Sort order
1189      */
1190     public function get_sortorder() {
1191         $this->load_grade_item();
1192         return $this->grade_item->get_sortorder();
1193     }
1195     /**
1196      * Returns the idnumber of the associated grade_item. This method is also available in
1197      * grade_item, for cases where the object type is not known.
1198      * @return string idnumber
1199      */
1200     public function get_idnumber() {
1201         $this->load_grade_item();
1202         return $this->grade_item->get_idnumber();
1203     }
1205     /**
1206      * Sets sortorder variable for this category.
1207      * This method is also available in grade_item, for cases where the object type is not know.
1208      * @param int $sortorder
1209      * @return void
1210      */
1211     public function set_sortorder($sortorder) {
1212         $this->load_grade_item();
1213         $this->grade_item->set_sortorder($sortorder);
1214     }
1216     /**
1217      * Move this category after the given sortorder - does not change the parent
1218      * @param int $sortorder to place after
1219      */
1220     public function move_after_sortorder($sortorder) {
1221         $this->load_grade_item();
1222         $this->grade_item->move_after_sortorder($sortorder);
1223     }
1225     /**
1226      * Return true if this is the top most category that represents the total course grade.
1227      * @return boolean
1228      */
1229     public function is_course_category() {
1230         $this->load_grade_item();
1231         return $this->grade_item->is_course_item();
1232     }
1234     /**
1235      * Return the top most course category.
1236      * @static
1237      * @return object grade_category instance for course grade
1238      */
1239     public function fetch_course_category($courseid) {
1240         if (empty($courseid)) {
1241             debugging('Missing course id!');
1242             return false;
1243         }
1245         // course category has no parent
1246         if ($course_category = grade_category::fetch(array('courseid'=>$courseid, 'parent'=>null))) {
1247             return $course_category;
1248         }
1250         // create a new one
1251         $course_category = new grade_category();
1252         $course_category->insert_course_category($courseid);
1254         return $course_category;
1255     }
1257     /**
1258      * Is grading object editable?
1259      * @return boolean
1260      */
1261     public function is_editable() {
1262         return true;
1263     }
1265     /**
1266      * Returns the locked state/date of the associated grade_item. This method is also available in
1267      * grade_item, for cases where the object type is not known.
1268      * @return boolean
1269      */
1270     public function is_locked() {
1271         $this->load_grade_item();
1272         return $this->grade_item->is_locked();
1273     }
1275     /**
1276      * Sets the grade_item's locked variable and updates the grade_item.
1277      * Method named after grade_item::set_locked().
1278      * @param int $locked 0, 1 or a timestamp int(10) after which date the item will be locked.
1279      * @param boolean $cascade lock/unlock child objects too
1280      * @param boolean $refresh refresh grades when unlocking
1281      * @return boolean success if category locked (not all children mayb be locked though)
1282      */
1283     public function set_locked($lockedstate, $cascade=false, $refresh=true) {
1284         $this->load_grade_item();
1286         $result = $this->grade_item->set_locked($lockedstate, $cascade, true);
1288         if ($cascade) {
1289             //process all children - items and categories
1290             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
1291                 foreach($children as $child) {
1292                     $child->set_locked($lockedstate, true, false);
1293                     if (empty($lockedstate) and $refresh) {
1294                         //refresh when unlocking
1295                         $child->refresh_grades();
1296                     }
1297                 }
1298             }
1299             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
1300                 foreach($children as $child) {
1301                     $child->set_locked($lockedstate, true, true);
1302                 }
1303             }
1304         }
1306         return $result;
1307     }
1309     /**
1310      * Returns the hidden state/date of the associated grade_item. This method is also available in
1311      * grade_item.
1312      * @return boolean
1313      */
1314     public function is_hidden() {
1315         $this->load_grade_item();
1316         return $this->grade_item->is_hidden();
1317     }
1319     /**
1320      * Check grade hidden status. Uses data from both grade item and grade.
1321      * @return boolean true if hiddenuntil, false if not
1322      */
1323     public function is_hiddenuntil() {
1324         $this->load_grade_item();
1325         return $this->grade_item->is_hiddenuntil();
1326     }
1328     /**
1329      * Sets the grade_item's hidden variable and updates the grade_item.
1330      * Method named after grade_item::set_hidden().
1331      * @param int $hidden 0, 1 or a timestamp int(10) after which date the item will be hidden.
1332      * @param boolean $cascade apply to child objects too
1333      * @return void
1334      */
1335     public function set_hidden($hidden, $cascade=false) {
1336         $this->load_grade_item();
1337         $this->grade_item->set_hidden($hidden);
1338         if ($cascade) {
1339             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
1340                 foreach($children as $child) {
1341                     $child->set_hidden($hidden, $cascade);
1342                 }
1343             }
1344             if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
1345                 foreach($children as $child) {
1346                     $child->set_hidden($hidden, $cascade);
1347                 }
1348             }
1349         }
1350     }
1352     /**
1353      * Applies default settings on this category
1354      * @return bool true if anything changed
1355      */
1356     public function apply_default_settings() {
1357         global $CFG;
1359         foreach ($this->forceable as $property) {
1360             if (isset($CFG->{"grade_$property"})) {
1361                 if ($CFG->{"grade_$property"} == -1) {
1362                     continue; //temporary bc before version bump
1363                 }
1364                 $this->$property = $CFG->{"grade_$property"};
1365             }
1366         }
1367     }
1369     /**
1370      * Applies forced settings on this category
1371      * @return bool true if anything changed
1372      */
1373     public function apply_forced_settings() {
1374         global $CFG;
1376         $updated = false;
1377         foreach ($this->forceable as $property) {
1378             if (isset($CFG->{"grade_$property"}) and isset($CFG->{"grade_{$property}_flag"}) and ((int)$CFG->{"grade_{$property}_flag"} & 1)) {
1379                 if ($CFG->{"grade_$property"} == -1) {
1380                     continue; //temporary bc before version bump
1381                 }
1382                 $this->$property = $CFG->{"grade_$property"};
1383                 $updated = true;
1384             }
1385         }
1387         return $updated;
1388     }
1390     /**
1391      * Notification of change in forced category settings.
1392      * @static
1393      */
1394     public static function updated_forced_settings() {
1395         global $CFG, $DB;
1396         $params = array(1, 'course', 'category');
1397         $sql = "UPDATE {grade_items} SET needsupdate=? WHERE itemtype=? or itemtype=?";
1398         $DB->execute($sql, $params);
1399     }
1401 ?>