MDL-47541 core_grades: hints about aggregation settings on setup page
[moodle.git] / lib / grade / grade_category.php
CommitLineData
b79fe189 1<?php
b79fe189 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/>.
a153c9f2 16
b79fe189 17/**
a153c9f2 18 * Definition of a class to represent a grade category
b79fe189 19 *
a153c9f2
AD
20 * @package core_grades
21 * @copyright 2006 Nicolas Connault
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
b79fe189 23 */
7ad5a627
PS
24
25defined('MOODLE_INTERNAL') || die();
26
8a31e65c 27require_once('grade_object.php');
28
b79fe189 29/**
a153c9f2 30 * grade_category is an object mapped to DB table {prefix}grade_categories
b79fe189 31 *
a153c9f2
AD
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
b79fe189 36 */
3058964f 37class grade_category extends grade_object {
8a31e65c 38 /**
7c8a963f 39 * The DB table.
8a31e65c 40 * @var string $table
41 */
da3801e8 42 public $table = 'grade_categories';
4a490db0 43
8a31e65c 44 /**
3f2b0c8a 45 * Array of required table fields, must start with 'id'.
46 * @var array $required_fields
8a31e65c 47 */
da3801e8 48 public $required_fields = array('id', 'courseid', 'parent', 'depth', 'path', 'fullname', 'aggregation',
3f2b0c8a 49 'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes',
a25bb902 50 'aggregatesubcats', 'timecreated', 'timemodified', 'hidden');
4a490db0 51
8a31e65c 52 /**
53 * The course this category belongs to.
54 * @var int $courseid
55 */
da3801e8 56 public $courseid;
4a490db0 57
8a31e65c 58 /**
59 * The category this category belongs to (optional).
4a490db0 60 * @var int $parent
8a31e65c 61 */
da3801e8 62 public $parent;
4a490db0 63
8c846243 64 /**
65 * The grade_category object referenced by $this->parent (PK).
a153c9f2 66 * @var grade_category $parent_category
8c846243 67 */
da3801e8 68 public $parent_category;
27f95e9b 69
e5c674f1 70 /**
71 * The number of parents this category has.
72 * @var int $depth
73 */
da3801e8 74 public $depth = 0;
e5c674f1 75
76 /**
c2efb501 77 * Shows the hierarchical path for this category as /1/2/3/ (like course_categories), the last number being
e5c674f1 78 * this category's autoincrement ID number.
79 * @var string $path
80 */
da3801e8 81 public $path;
e5c674f1 82
8a31e65c 83 /**
84 * The name of this category.
85 * @var string $fullname
86 */
da3801e8 87 public $fullname;
4a490db0 88
8a31e65c 89 /**
90 * A constant pointing to one of the predefined aggregation strategies (none, mean, median, sum etc) .
4a490db0 91 * @var int $aggregation
8a31e65c 92 */
2be0d5c3 93 public $aggregation = GRADE_AGGREGATE_SUM;
4a490db0 94
8a31e65c 95 /**
96 * Keep only the X highest items.
97 * @var int $keephigh
98 */
da3801e8 99 public $keephigh = 0;
4a490db0 100
8a31e65c 101 /**
102 * Drop the X lowest items.
103 * @var int $droplow
104 */
da3801e8 105 public $droplow = 0;
4a490db0 106
c2efb501 107 /**
108 * Aggregate only graded items
109 * @var int $aggregateonlygraded
110 */
da3801e8 111 public $aggregateonlygraded = 0;
c2efb501 112
29d509f5 113 /**
114 * Aggregate outcomes together with normal items
c2efb501 115 * @var int $aggregateoutcomes
29d509f5 116 */
da3801e8 117 public $aggregateoutcomes = 0;
29d509f5 118
c2efb501 119 /**
120 * Ignore subcategories when aggregating
121 * @var int $aggregatesubcats
122 */
da3801e8 123 public $aggregatesubcats = 0;
c2efb501 124
8a31e65c 125 /**
126 * Array of grade_items or grade_categories nested exactly 1 level below this category
127 * @var array $children
128 */
da3801e8 129 public $children;
8a31e65c 130
7c8a963f 131 /**
4a490db0 132 * A hierarchical array of all children below this category. This is stored separately from
7c8a963f 133 * $children because it is more memory-intensive and may not be used as often.
134 * @var array $all_children
135 */
da3801e8 136 public $all_children;
7c8a963f 137
f151b073 138 /**
139 * An associated grade_item object, with itemtype=category, used to calculate and cache a set of grade values
140 * for this category.
a153c9f2 141 * @var grade_item $grade_item
f151b073 142 */
da3801e8 143 public $grade_item;
f151b073 144
b3ac6c3e 145 /**
146 * Temporary sortorder for speedup of children resorting
a153c9f2 147 * @var int $sortorder
b3ac6c3e 148 */
da3801e8 149 public $sortorder;
b3ac6c3e 150
89a5f827 151 /**
152 * List of options which can be "forced" from site settings.
a153c9f2 153 * @var array $forceable
89a5f827 154 */
da3801e8 155 public $forceable = array('aggregation', 'keephigh', 'droplow', 'aggregateonlygraded', 'aggregateoutcomes', 'aggregatesubcats');
89a5f827 156
653a8648 157 /**
158 * String representing the aggregation coefficient. Variable is used as cache.
a153c9f2 159 * @var string $coefstring
653a8648 160 */
b79fe189 161 public $coefstring = null;
653a8648 162
4272386a
FM
163 /**
164 * Static variable storing the result from {@link self::can_apply_limit_rules}.
165 * @var bool
166 */
167 protected $canapplylimitrules;
168
e5c674f1 169 /**
170 * Builds this category's path string based on its parents (if any) and its own id number.
171 * This is typically done just before inserting this object in the DB for the first time,
ce385eb4 172 * or when a new parent is added or changed. It is a recursive function: once the calling
173 * object no longer has a parent, the path is complete.
174 *
a153c9f2
AD
175 * @param grade_category $grade_category A Grade_Category object
176 * @return string The category's path string
e5c674f1 177 */
22a9b6d8 178 public static function build_path($grade_category) {
da3801e8 179 global $DB;
180
ce385eb4 181 if (empty($grade_category->parent)) {
c2efb501 182 return '/'.$grade_category->id.'/';
b79fe189 183
ce385eb4 184 } else {
da3801e8 185 $parent = $DB->get_record('grade_categories', array('id' => $grade_category->parent));
9a68cffc 186 return grade_category::build_path($parent).$grade_category->id.'/';
ce385eb4 187 }
e5c674f1 188 }
189
8a31e65c 190 /**
f92dcad8 191 * Finds and returns a grade_category instance based on params.
8a31e65c 192 *
f92dcad8 193 * @param array $params associative arrays varname=>value
a153c9f2 194 * @return grade_category The retrieved grade_category instance or false if none found.
f92dcad8 195 */
da3801e8 196 public static function fetch($params) {
f3ac8eb4 197 return grade_object::fetch_helper('grade_categories', 'grade_category', $params);
f92dcad8 198 }
199
200 /**
201 * Finds and returns all grade_category instances based on params.
f92dcad8 202 *
203 * @param array $params associative arrays varname=>value
204 * @return array array of grade_category insatnces or false if none found.
205 */
da3801e8 206 public static function fetch_all($params) {
f3ac8eb4 207 return grade_object::fetch_all_helper('grade_categories', 'grade_category', $params);
ce385eb4 208 }
209
8f4a626d 210 /**
2cc4b0f9 211 * In addition to update() as defined in grade_object, call force_regrading of parent categories, if applicable.
a153c9f2 212 *
aaff71da 213 * @param string $source from where was the object updated (mod/forum, manual, etc.)
a153c9f2 214 * @return bool success
8f4a626d 215 */
da3801e8 216 public function update($source=null) {
b3ac6c3e 217 // load the grade item or create a new one
218 $this->load_grade_item();
219
220 // force recalculation of path;
221 if (empty($this->path)) {
9a68cffc 222 $this->path = grade_category::build_path($this);
c2efb501 223 $this->depth = substr_count($this->path, '/') - 1;
1909a127 224 $updatechildren = true;
b79fe189 225
1909a127 226 } else {
227 $updatechildren = false;
4a490db0 228 }
0fc7f624 229
89a5f827 230 $this->apply_forced_settings();
231
b79fe189 232 // these are exclusive
89a5f827 233 if ($this->droplow > 0) {
234 $this->keephigh = 0;
b79fe189 235
89a5f827 236 } else if ($this->keephigh > 0) {
237 $this->droplow = 0;
238 }
4a490db0 239
b3ac6c3e 240 // Recalculate grades if needed
241 if ($this->qualifies_for_regrading()) {
f8e6e4db 242 $this->force_regrading();
4a490db0 243 }
f8e6e4db 244
ced5ee59 245 $this->timemodified = time();
246
1909a127 247 $result = parent::update($source);
248
249 // now update paths in all child categories
250 if ($result and $updatechildren) {
b79fe189 251
1909a127 252 if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
b79fe189 253
1909a127 254 foreach ($children as $child) {
1909a127 255 $child->path = null;
256 $child->depth = 0;
257 $child->update($source);
da3801e8 258 }
1909a127 259 }
260 }
261
262 return $result;
8f4a626d 263 }
4a490db0 264
8f4a626d 265 /**
2cc4b0f9 266 * If parent::delete() is successful, send force_regrading message to parent category.
a153c9f2 267 *
aaff71da 268 * @param string $source from where was the object deleted (mod/forum, manual, etc.)
a153c9f2 269 * @return bool success
8f4a626d 270 */
da3801e8 271 public function delete($source=null) {
f13002d5 272 $grade_item = $this->load_grade_item();
4a490db0 273
f615fbab 274 if ($this->is_course_category()) {
b79fe189 275
f3ac8eb4 276 if ($categories = grade_category::fetch_all(array('courseid'=>$this->courseid))) {
b79fe189 277
f615fbab 278 foreach ($categories as $category) {
b79fe189 279
f615fbab 280 if ($category->id == $this->id) {
281 continue; // do not delete course category yet
282 }
283 $category->delete($source);
284 }
aaff71da 285 }
2b0f65e2 286
f3ac8eb4 287 if ($items = grade_item::fetch_all(array('courseid'=>$this->courseid))) {
b79fe189 288
f615fbab 289 foreach ($items as $item) {
b79fe189 290
f615fbab 291 if ($item->id == $grade_item->id) {
292 continue; // do not delete course item yet
293 }
294 $item->delete($source);
295 }
296 }
297
298 } else {
299 $this->force_regrading();
2b0f65e2 300
f615fbab 301 $parent = $this->load_parent_category();
2b0f65e2 302
f615fbab 303 // Update children's categoryid/parent field first
f3ac8eb4 304 if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
f615fbab 305 foreach ($children as $child) {
306 $child->set_parent($parent->id);
307 }
308 }
b79fe189 309
f3ac8eb4 310 if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
f615fbab 311 foreach ($children as $child) {
312 $child->set_parent($parent->id);
313 }
aaff71da 314 }
315 }
f13002d5 316
aaff71da 317 // first delete the attached grade item and grades
318 $grade_item->delete($source);
f13002d5 319
320 // delete category itself
aaff71da 321 return parent::delete($source);
8f4a626d 322 }
4a490db0 323
ce385eb4 324 /**
325 * In addition to the normal insert() defined in grade_object, this method sets the depth
a153c9f2
AD
326 * and path for this object, and update the record accordingly.
327 *
328 * We do this here instead of in the constructor as they both need to know the record's
329 * ID number, which only gets created at insertion time.
f151b073 330 * This method also creates an associated grade_item if this wasn't done during construction.
a153c9f2 331 *
aaff71da 332 * @param string $source from where was the object inserted (mod/forum, manual, etc.)
333 * @return int PK ID if successful, false otherwise
ce385eb4 334 */
da3801e8 335 public function insert($source=null) {
b3ac6c3e 336
337 if (empty($this->courseid)) {
2f137aa1 338 print_error('cannotinsertgrade');
b8ff92b6 339 }
4a490db0 340
b3ac6c3e 341 if (empty($this->parent)) {
f3ac8eb4 342 $course_category = grade_category::fetch_course_category($this->courseid);
b3ac6c3e 343 $this->parent = $course_category->id;
ce385eb4 344 }
4a490db0 345
b3ac6c3e 346 $this->path = null;
347
ced5ee59 348 $this->timecreated = $this->timemodified = time();
349
aaff71da 350 if (!parent::insert($source)) {
b3ac6c3e 351 debugging("Could not insert this category: " . print_r($this, true));
352 return false;
353 }
354
f8e6e4db 355 $this->force_regrading();
356
b3ac6c3e 357 // build path and depth
aaff71da 358 $this->update($source);
4a490db0 359
aaff71da 360 return $this->id;
b3ac6c3e 361 }
362
f2c88356 363 /**
f615fbab 364 * Internal function - used only from fetch_course_category()
365 * Normal insert() can not be used for course category
b79fe189 366 *
367 * @param int $courseid The course ID
a153c9f2 368 * @return int The ID of the new course category
f2c88356 369 */
da3801e8 370 public function insert_course_category($courseid) {
1f0e4921 371 $this->courseid = $courseid;
8f6fdf43 372 $this->fullname = '?';
1f0e4921 373 $this->path = null;
374 $this->parent = null;
0c87b5aa 375 $this->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN2;
4a490db0 376
190af29f 377 $this->apply_default_settings();
89a5f827 378 $this->apply_forced_settings();
379
ced5ee59 380 $this->timecreated = $this->timemodified = time();
381
aaff71da 382 if (!parent::insert('system')) {
b3ac6c3e 383 debugging("Could not insert this category: " . print_r($this, true));
384 return false;
f151b073 385 }
4a490db0 386
b3ac6c3e 387 // build path and depth
aaff71da 388 $this->update('system');
b3ac6c3e 389
aaff71da 390 return $this->id;
ce385eb4 391 }
4a490db0 392
8f4a626d 393 /**
394 * Compares the values held by this object with those of the matching record in DB, and returns
395 * whether or not these differences are sufficient to justify an update of all parent objects.
a153c9f2
AD
396 * This assumes that this object has an ID number and a matching record in DB. If not, it will return false.
397 *
398 * @return bool
8f4a626d 399 */
da3801e8 400 public function qualifies_for_regrading() {
8f4a626d 401 if (empty($this->id)) {
6639ead3 402 debugging("Can not regrade non existing category");
8f4a626d 403 return false;
404 }
f3ac8eb4 405
406 $db_item = grade_category::fetch(array('id'=>$this->id));
4a490db0 407
c2efb501 408 $aggregationdiff = $db_item->aggregation != $this->aggregation;
409 $keephighdiff = $db_item->keephigh != $this->keephigh;
410 $droplowdiff = $db_item->droplow != $this->droplow;
411 $aggonlygrddiff = $db_item->aggregateonlygraded != $this->aggregateonlygraded;
412 $aggoutcomesdiff = $db_item->aggregateoutcomes != $this->aggregateoutcomes;
413 $aggsubcatsdiff = $db_item->aggregatesubcats != $this->aggregatesubcats;
8f4a626d 414
c2efb501 415 return ($aggregationdiff || $keephighdiff || $droplowdiff || $aggonlygrddiff || $aggoutcomesdiff || $aggsubcatsdiff);
8f4a626d 416 }
8c846243 417
418 /**
a153c9f2 419 * Marks this grade categories' associated grade item as needing regrading
8c846243 420 */
da3801e8 421 public function force_regrading() {
f8e6e4db 422 $grade_item = $this->load_grade_item();
423 $grade_item->force_regrading();
8c846243 424 }
425
fcf6e015
FM
426 /**
427 * Something that should be called before we start regrading the whole course.
428 *
429 * @return void
430 */
431 public function pre_regrade_final_grades() {
fcf6e015 432 $this->auto_update_weights();
64055d50 433 $this->auto_update_max();
fcf6e015
FM
434 }
435
0aa32279 436 /**
0758a08e 437 * Generates and saves final grades in associated category grade item.
1994d890 438 * These immediate children must already have their own final grades.
0758a08e 439 * The category's aggregation method is used to generate final grades.
ac9b0805 440 *
a153c9f2 441 * Please note that category grade is either calculated or aggregated, not both at the same time.
ac9b0805 442 *
c86caae7 443 * This method must be used ONLY from grade_item::regrade_final_grades(),
ac9b0805 444 * because the calculation must be done in correct order!
b8ff92b6 445 *
4a490db0 446 * Steps to follow:
ac9b0805 447 * 1. Get final grades from immediate children
2df71235 448 * 3. Aggregate these grades
0758a08e 449 * 4. Save them in final grades of associated category grade item
b79fe189 450 *
a153c9f2 451 * @param int $userid The user ID if final grade generation should be limited to a single user
b79fe189 452 * @return bool
0aa32279 453 */
da3801e8 454 public function generate_grades($userid=null) {
455 global $CFG, $DB;
4a490db0 456
ac9b0805 457 $this->load_grade_item();
2cc4b0f9 458
459 if ($this->grade_item->is_locked()) {
460 return true; // no need to recalculate locked items
461 }
462
89a5f827 463 // find grade items of immediate children (category or grade items) and force site settings
61c33818 464 $depends_on = $this->grade_item->depends_on();
b3ac6c3e 465
f8e6e4db 466 if (empty($depends_on)) {
467 $items = false;
b79fe189 468
f8e6e4db 469 } else {
5b0af8c5 470 list($usql, $params) = $DB->get_in_or_equal($depends_on);
f8e6e4db 471 $sql = "SELECT *
5b0af8c5 472 FROM {grade_items}
473 WHERE id $usql";
474 $items = $DB->get_records_sql($sql, $params);
f8e6e4db 475 }
4a490db0 476
5b0af8c5 477 $grade_inst = new grade_grade();
478 $fields = 'g.'.implode(',g.', $grade_inst->required_fields);
479
480 // where to look for final grades - include grade of this item too, we will store the results there
481 $gis = array_merge($depends_on, array($this->grade_item->id));
482 list($usql, $params) = $DB->get_in_or_equal($gis);
483
f8e6e4db 484 if ($userid) {
5b0af8c5 485 $usersql = "AND g.userid=?";
486 $params[] = $userid;
b79fe189 487
f8e6e4db 488 } else {
489 $usersql = "";
b8ff92b6 490 }
4a490db0 491
3f2b0c8a 492 $sql = "SELECT $fields
5b0af8c5 493 FROM {grade_grades} g, {grade_items} gi
494 WHERE gi.id = g.itemid AND gi.id $usql $usersql
ac9b0805 495 ORDER BY g.userid";
b8ff92b6 496
9580a21f 497 // group the results by userid and aggregate the grades for this user
1b42e677
EL
498 $rs = $DB->get_recordset_sql($sql, $params);
499 if ($rs->valid()) {
03cedd62 500 $prevuser = 0;
501 $grade_values = array();
502 $excluded = array();
503 $oldgrade = null;
5a59aeb1
DW
504 $grademaxoverrides = array();
505 $grademinoverrides = array();
b79fe189 506
da3801e8 507 foreach ($rs as $used) {
b79fe189 508
03cedd62 509 if ($used->userid != $prevuser) {
5a59aeb1
DW
510 $this->aggregate_grades($prevuser,
511 $items,
512 $grade_values,
513 $oldgrade,
514 $excluded,
515 $grademinoverrides,
516 $grademaxoverrides);
03cedd62 517 $prevuser = $used->userid;
518 $grade_values = array();
519 $excluded = array();
520 $oldgrade = null;
5a59aeb1
DW
521 $grademaxoverrides = array();
522 $grademinoverrides = array();
03cedd62 523 }
524 $grade_values[$used->itemid] = $used->finalgrade;
5a59aeb1
DW
525 $grademaxoverrides[$used->itemid] = $used->rawgrademax;
526 $grademinoverrides[$used->itemid] = $used->rawgrademin;
b79fe189 527
03cedd62 528 if ($used->excluded) {
529 $excluded[] = $used->itemid;
530 }
b79fe189 531
03cedd62 532 if ($this->grade_item->id == $used->itemid) {
533 $oldgrade = $used;
2df71235 534 }
b8ff92b6 535 }
5a59aeb1
DW
536 $this->aggregate_grades($prevuser,
537 $items,
538 $grade_values,
539 $oldgrade,
540 $excluded,
541 $grademinoverrides,
542 $grademaxoverrides);//the last one
b8ff92b6 543 }
1b42e677 544 $rs->close();
b8ff92b6 545
b8ff92b6 546 return true;
547 }
548
549 /**
a153c9f2 550 * Internal function for grade category grade aggregation
ced5ee59 551 *
b79fe189 552 * @param int $userid The User ID
553 * @param array $items Grade items
554 * @param array $grade_values Array of grade values
555 * @param object $oldgrade Old grade
22a9b6d8 556 * @param array $excluded Excluded
5a59aeb1
DW
557 * @param array $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
558 * @param array $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
b8ff92b6 559 */
5a59aeb1
DW
560 private function aggregate_grades($userid,
561 $items,
562 $grade_values,
563 $oldgrade,
564 $excluded,
565 $grademinoverrides,
566 $grademaxoverrides) {
ee07a54b 567 global $CFG, $DB;
bfe969e8
DW
568
569 // Remember these so we can set flags on them to describe how they were used in the aggregation.
570 $novalue = array();
571 $dropped = array();
53771c40 572 $extracredit = array();
bfe969e8
DW
573 $usedweights = array();
574
b8ff92b6 575 if (empty($userid)) {
f8e6e4db 576 //ignore first call
b8ff92b6 577 return;
578 }
4a490db0 579
f8e6e4db 580 if ($oldgrade) {
66690b69 581 $oldfinalgrade = $oldgrade->finalgrade;
f3ac8eb4 582 $grade = new grade_grade($oldgrade, false);
f8e6e4db 583 $grade->grade_item =& $this->grade_item;
b8ff92b6 584
f8e6e4db 585 } else {
586 // insert final grade - it will be needed later anyway
f3ac8eb4 587 $grade = new grade_grade(array('itemid'=>$this->grade_item->id, 'userid'=>$userid), false);
f8e6e4db 588 $grade->grade_item =& $this->grade_item;
f1ad9e04 589 $grade->insert('system');
590 $oldfinalgrade = null;
f8e6e4db 591 }
f1ad9e04 592
c86caae7 593 // no need to recalculate locked or overridden grades
594 if ($grade->is_locked() or $grade->is_overridden()) {
2cc4b0f9 595 return;
ac9b0805 596 }
597
f8e6e4db 598 // can not use own final category grade in calculation
9580a21f 599 unset($grade_values[$this->grade_item->id]);
f8e6e4db 600
53771c40 601 // Make sure a grade_grade exists for every grade_item.
6070e533
DW
602 // We need to do this so we can set the aggregationstatus
603 // with a set_field call instead of checking if each one exists and creating/updating.
c529f4b3 604 if (!empty($items)) {
ee07a54b
DW
605 list($ggsql, $params) = $DB->get_in_or_equal(array_keys($items), SQL_PARAMS_NAMED, 'g');
606
607
608 $params['userid'] = $userid;
609 $sql = "SELECT itemid
610 FROM {grade_grades}
611 WHERE itemid $ggsql AND userid = :userid";
612 $existingitems = $DB->get_records_sql($sql, $params);
613
614 $notexisting = array_diff(array_keys($items), array_keys($existingitems));
615 foreach ($notexisting as $itemid) {
6077a4d4 616 $gradeitem = $items[$itemid];
ee07a54b
DW
617 $gradegrade = new grade_grade(array('itemid' => $itemid,
618 'userid' => $userid,
619 'rawgrademin' => $gradeitem->grademin,
620 'rawgrademax' => $gradeitem->grademax), false);
621 $gradegrade->grade_item = $gradeitem;
53771c40
DW
622 $gradegrade->insert('system');
623 }
53771c40
DW
624 }
625
0758a08e 626 // if no grades calculation possible or grading not allowed clear final grade
9580a21f 627 if (empty($grade_values) or empty($items) or ($this->grade_item->gradetype != GRADE_TYPE_VALUE and $this->grade_item->gradetype != GRADE_TYPE_SCALE)) {
f8e6e4db 628 $grade->finalgrade = null;
b79fe189 629
66690b69 630 if (!is_null($oldfinalgrade)) {
0758a08e 631 $grade->update('aggregation');
f8e6e4db 632 }
bfe969e8 633 $dropped = $grade_values;
53771c40 634 $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
b8ff92b6 635 return;
636 }
0758a08e 637
53771c40
DW
638 // Normalize the grades first - all will have value 0...1
639 // ungraded items are not used in aggregation.
825fba1c 640 foreach ($grade_values as $itemid=>$v) {
53771c40
DW
641 if (is_null($v)) {
642 // If null, it means no grade.
643 if ($this->aggregateonlygraded) {
aa705529 644 unset($grade_values[$itemid]);
ee07a54b 645 // Mark this item as "excluded empty" because it has no grade.
aa705529
JO
646 $novalue[$itemid] = 0;
647 continue;
aa705529 648 }
0e999796 649 }
53771c40
DW
650 if (in_array($itemid, $excluded)) {
651 unset($grade_values[$itemid]);
652 $dropped[$itemid] = 0;
653 continue;
654 }
5a59aeb1
DW
655 // Check for user specific grade min/max overrides.
656 $usergrademin = $items[$itemid]->grademin;
657 $usergrademax = $items[$itemid]->grademax;
658 if (isset($grademinoverrides[$itemid])) {
659 $usergrademin = $grademinoverrides[$itemid];
660 }
661 if (isset($grademaxoverrides[$itemid])) {
662 $usergrademax = $grademaxoverrides[$itemid];
663 }
0b2e8ae0
FM
664 if ($this->aggregation == GRADE_AGGREGATE_SUM) {
665 // Assume that the grademin is 0 when standardising the score, to preserve negative grades.
666 $grade_values[$itemid] = grade_grade::standardise_score($v, 0, $usergrademax, 0, 1);
667 } else {
668 $grade_values[$itemid] = grade_grade::standardise_score($v, $usergrademin, $usergrademax, 0, 1);
669 }
670
0aa32279 671 }
b79fe189 672
ee07a54b 673 // For items with no value, and not excluded - either set their grade to 0 or exclude them.
53771c40
DW
674 foreach ($items as $itemid=>$value) {
675 if (!isset($grade_values[$itemid]) and !in_array($itemid, $excluded)) {
676 if (!$this->aggregateonlygraded) {
c2efb501 677 $grade_values[$itemid] = 0;
53771c40 678 } else {
ee07a54b 679 // We are specifically marking these items as "excluded empty".
53771c40 680 $novalue[$itemid] = 0;
eacd3700 681 }
c2efb501 682 }
eacd3700 683 }
684
685 // limit and sort
bfe969e8 686 $allvalues = $grade_values;
4272386a
FM
687 if ($this->can_apply_limit_rules()) {
688 $this->apply_limit_rules($grade_values, $items);
689 }
bfe969e8
DW
690
691 $moredropped = array_diff($allvalues, $grade_values);
692 foreach ($moredropped as $drop => $unused) {
693 $dropped[$drop] = 0;
694 }
53771c40
DW
695
696 foreach ($grade_values as $itemid => $val) {
697 if (self::is_extracredit_used() && ($items[$itemid]->aggregationcoef > 0)) {
698 $extracredit[$itemid] = 0;
699 }
700 }
701
9580a21f 702 asort($grade_values, SORT_NUMERIC);
4a490db0 703
d5f0aa01 704 // let's see we have still enough grades to do any statistics
9580a21f 705 if (count($grade_values) == 0) {
ac9b0805 706 // not enough attempts yet
f8e6e4db 707 $grade->finalgrade = null;
b79fe189 708
66690b69 709 if (!is_null($oldfinalgrade)) {
0758a08e 710 $grade->update('aggregation');
b8ff92b6 711 }
53771c40 712 $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
b8ff92b6 713 return;
714 }
2df71235 715
d297269d 716 // do the maths
5a59aeb1
DW
717 $result = $this->aggregate_values_and_adjust_bounds($grade_values,
718 $items,
719 $usedweights,
720 $grademinoverrides,
721 $grademaxoverrides);
5232d3f2 722 $agg_grade = $result['grade'];
d297269d 723
0b2e8ae0
FM
724 // Set the actual grademin and max to bind the grade properly.
725 $this->grade_item->grademin = $result['grademin'];
726 $this->grade_item->grademax = $result['grademax'];
727
728 if ($this->aggregation == GRADE_AGGREGATE_SUM) {
729 // The natural aggregation always displays the range as coming from 0 for categories.
730 // However, when we bind the grade we allow for negative values.
731 $result['grademin'] = 0;
732 }
733
825fba1c
FM
734 // Recalculate the grade back to requested range.
735 $finalgrade = grade_grade::standardise_score($agg_grade, 0, 1, $result['grademin'], $result['grademax']);
653a8648 736 $grade->finalgrade = $this->grade_item->bounded_grade($finalgrade);
0b2e8ae0
FM
737
738 $oldrawgrademin = $grade->rawgrademin;
90cc8ce7 739 $oldrawgrademax = $grade->rawgrademax;
0b2e8ae0 740 $grade->rawgrademin = $result['grademin'];
5a59aeb1 741 $grade->rawgrademax = $result['grademax'];
d297269d 742
0b2e8ae0 743 // Update in db if changed.
90cc8ce7 744 if (grade_floats_different($grade->finalgrade, $oldfinalgrade) ||
0b2e8ae0
FM
745 grade_floats_different($grade->rawgrademax, $oldrawgrademax) ||
746 grade_floats_different($grade->rawgrademin, $oldrawgrademin)) {
0758a08e 747 $grade->update('aggregation');
d297269d 748 }
749
53771c40 750 $this->set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit);
bfe969e8 751
d297269d 752 return;
753 }
754
bfe969e8
DW
755 /**
756 * Set the flags on the grade_grade items to indicate how individual grades are used
757 * in the aggregation.
758 *
759 * @param int $userid The user we have aggregated the grades for.
760 * @param array $usedweights An array with keys for each of the grade_item columns included in the aggregation. The value are the relative weight.
761 * @param array $novalue An array with keys for each of the grade_item columns skipped because
6070e533 762 * they had no value in the aggregation.
bfe969e8 763 * @param array $dropped An array with keys for each of the grade_item columns dropped
6070e533
DW
764 * because of any drop lowest/highest settings in the aggregation.
765 * @param array $extracredit An array with keys for each of the grade_item columns
766 * considered extra credit by the aggregation.
bfe969e8 767 */
53771c40 768 private function set_usedinaggregation($userid, $usedweights, $novalue, $dropped, $extracredit) {
bfe969e8
DW
769 global $DB;
770
771 // Included.
772 if (!empty($usedweights)) {
773 // The usedweights items are updated individually to record the weights.
774 foreach ($usedweights as $gradeitemid => $contribution) {
bfe969e8 775 $DB->set_field_select('grade_grades',
a1740d7b 776 'aggregationweight',
bfe969e8
DW
777 $contribution,
778 "itemid = :itemid AND userid = :userid",
779 array('itemid'=>$gradeitemid, 'userid'=>$userid));
780 }
a1740d7b
DW
781
782 // Now set the status flag for all these weights.
783 list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($usedweights), SQL_PARAMS_NAMED, 'g');
784 $itemlist['userid'] = $userid;
785
786 $DB->set_field_select('grade_grades',
787 'aggregationstatus',
788 'used',
789 "itemid $itemsql AND userid = :userid",
790 $itemlist);
bfe969e8
DW
791 }
792
793 // No value.
794 if (!empty($novalue)) {
795 list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($novalue), SQL_PARAMS_NAMED, 'g');
796
797 $itemlist['userid'] = $userid;
798
799 $DB->set_field_select('grade_grades',
a1740d7b 800 'aggregationstatus',
bfe969e8
DW
801 'novalue',
802 "itemid $itemsql AND userid = :userid",
803 $itemlist);
804 }
805
806 // Dropped.
807 if (!empty($dropped)) {
808 list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($dropped), SQL_PARAMS_NAMED, 'g');
809
810 $itemlist['userid'] = $userid;
811
812 $DB->set_field_select('grade_grades',
a1740d7b 813 'aggregationstatus',
bfe969e8
DW
814 'dropped',
815 "itemid $itemsql AND userid = :userid",
816 $itemlist);
817 }
53771c40
DW
818 // Extra credit.
819 if (!empty($extracredit)) {
820 list($itemsql, $itemlist) = $DB->get_in_or_equal(array_keys($extracredit), SQL_PARAMS_NAMED, 'g');
821
822 $itemlist['userid'] = $userid;
823
824 $DB->set_field_select('grade_grades',
825 'aggregationstatus',
826 'extra',
827 "itemid $itemsql AND userid = :userid",
828 $itemlist);
829 }
bfe969e8
DW
830 }
831
89a5f827 832 /**
5232d3f2 833 * Internal function that calculates the aggregated grade and new min/max for this grade category
b79fe189 834 *
a153c9f2 835 * Must be public as it is used by grade_grade::get_hiding_affected()
b79fe189 836 *
a153c9f2
AD
837 * @param array $grade_values An array of values to be aggregated
838 * @param array $items The array of grade_items
d6caf637 839 * @since Moodle 2.6.5, 2.7.2
bfe969e8
DW
840 * @param array & $weights If provided, will be filled with the normalized weights
841 * for each grade_item as used in the aggregation.
ee07a54b
DW
842 * Some rules for the weights are:
843 * 1. The weights must add up to 1 (unless there are extra credit)
844 * 2. The contributed points column must add up to the course
845 * final grade and this column is calculated from these weights.
5a59aeb1
DW
846 * @param array $grademinoverrides User specific grademin values if different to the grade_item grademin (key is itemid)
847 * @param array $grademaxoverrides User specific grademax values if different to the grade_item grademax (key is itemid)
5232d3f2
DW
848 * @return array containing values for:
849 * 'grade' => the new calculated grade
850 * 'grademin' => the new calculated min grade for the category
851 * 'grademax' => the new calculated max grade for the category
89a5f827 852 */
5a59aeb1
DW
853 public function aggregate_values_and_adjust_bounds($grade_values,
854 $items,
855 & $weights = null,
856 $grademinoverrides = array(),
857 $grademaxoverrides = array()) {
5232d3f2
DW
858 $category_item = $this->get_grade_item();
859 $grademin = $category_item->grademin;
860 $grademax = $category_item->grademax;
861
b8ff92b6 862 switch ($this->aggregation) {
b79fe189 863
c2efb501 864 case GRADE_AGGREGATE_MEDIAN: // Middle point value in the set: ignores frequencies
9580a21f 865 $num = count($grade_values);
c186c7b2 866 $grades = array_values($grade_values);
b79fe189 867
852fae0b
DW
868 // The median gets 100% - others get 0.
869 if ($weights !== null && $num > 0) {
870 $count = 0;
871 foreach ($grade_values as $itemid=>$grade_value) {
872 if (($num % 2 == 0) && ($count == intval($num/2)-1 || $count == intval($num/2))) {
873 $weights[$itemid] = 0.5;
874 } else if (($num % 2 != 0) && ($count == intval(($num/2)-0.5))) {
875 $weights[$itemid] = 1.0;
876 } else {
877 $weights[$itemid] = 0;
878 }
879 $count++;
880 }
881 }
c186c7b2 882 if ($num % 2 == 0) {
9c8d38fa 883 $agg_grade = ($grades[intval($num/2)-1] + $grades[intval($num/2)]) / 2;
b8ff92b6 884 } else {
9c8d38fa 885 $agg_grade = $grades[intval(($num/2)-0.5)];
b8ff92b6 886 }
bfe969e8 887
b8ff92b6 888 break;
ac9b0805 889
c2efb501 890 case GRADE_AGGREGATE_MIN:
9c8d38fa 891 $agg_grade = reset($grade_values);
bfe969e8
DW
892 // Record the weights as used.
893 if ($weights !== null) {
894 foreach ($grade_values as $itemid=>$grade_value) {
895 $weights[$itemid] = 0;
896 }
897 }
898 // Set the first item to 1.
899 $itemids = array_keys($grade_values);
900 $weights[reset($itemids)] = 1;
b8ff92b6 901 break;
902
c2efb501 903 case GRADE_AGGREGATE_MAX:
bfe969e8
DW
904 // Record the weights as used.
905 if ($weights !== null) {
906 foreach ($grade_values as $itemid=>$grade_value) {
907 $weights[$itemid] = 0;
908 }
909 }
910 // Set the last item to 1.
911 $itemids = array_keys($grade_values);
912 $weights[end($itemids)] = 1;
913 $agg_grade = end($grade_values);
b8ff92b6 914 break;
915
bfe969e8 916 case GRADE_AGGREGATE_MODE: // the most common value
0198929c 917 // array_count_values only counts INT and STRING, so if grades are floats we must convert them to string
918 $converted_grade_values = array();
919
920 foreach ($grade_values as $k => $gv) {
b79fe189 921
0198929c 922 if (!is_int($gv) && !is_string($gv)) {
923 $converted_grade_values[$k] = (string) $gv;
b79fe189 924
0198929c 925 } else {
926 $converted_grade_values[$k] = $gv;
927 }
bfe969e8
DW
928 if ($weights !== null) {
929 $weights[$k] = 0;
930 }
0198929c 931 }
932
933 $freq = array_count_values($converted_grade_values);
95affb8a 934 arsort($freq); // sort by frequency keeping keys
935 $top = reset($freq); // highest frequency count
936 $modes = array_keys($freq, $top); // search for all modes (have the same highest count)
f7d515b6 937 rsort($modes, SORT_NUMERIC); // get highest mode
9c8d38fa 938 $agg_grade = reset($modes);
bfe969e8
DW
939 // Record the weights as used.
940 if ($weights !== null && $top > 0) {
941 foreach ($grade_values as $k => $gv) {
942 if ($gv == $agg_grade) {
943 $weights[$k] = 1.0 / $top;
944 }
945 }
946 }
d5fab31f 947 break;
95affb8a 948
1426edac 949 case GRADE_AGGREGATE_WEIGHTED_MEAN: // Weighted average of all existing final grades, weight specified in coef
9580a21f 950 $weightsum = 0;
951 $sum = 0;
b79fe189 952
953 foreach ($grade_values as $itemid=>$grade_value) {
954
eacd3700 955 if ($items[$itemid]->aggregationcoef <= 0) {
9580a21f 956 continue;
957 }
eacd3700 958 $weightsum += $items[$itemid]->aggregationcoef;
959 $sum += $items[$itemid]->aggregationcoef * $grade_value;
bfe969e8
DW
960 if ($weights !== null) {
961 $weights[$itemid] = $items[$itemid]->aggregationcoef;
962 }
9580a21f 963 }
964 if ($weightsum == 0) {
9c8d38fa 965 $agg_grade = null;
b79fe189 966
9580a21f 967 } else {
9c8d38fa 968 $agg_grade = $sum / $weightsum;
bfe969e8
DW
969 if ($weights !== null) {
970 // Normalise the weights.
971 foreach ($weights as $itemid => $weight) {
972 $weights[$itemid] = $weight / $weightsum;
973 }
974 }
975
9580a21f 976 }
977 break;
978
d9ae2ab5 979 case GRADE_AGGREGATE_WEIGHTED_MEAN2:
980 // Weighted average of all existing final grades with optional extra credit flag,
f7d515b6 981 // weight is the range of grade (usually grademax)
1426edac 982 $weightsum = 0;
d9ae2ab5 983 $sum = null;
b79fe189 984
985 foreach ($grade_values as $itemid=>$grade_value) {
1426edac 986 $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
b79fe189 987
1426edac 988 if ($weight <= 0) {
989 continue;
990 }
b79fe189 991
d9ae2ab5 992 if ($items[$itemid]->aggregationcoef == 0) {
993 $weightsum += $weight;
994 }
995 $sum += $weight * $grade_value;
1426edac 996 }
997 if ($weightsum == 0) {
d9ae2ab5 998 $agg_grade = $sum; // only extra credits
b79fe189 999
1426edac 1000 } else {
1001 $agg_grade = $sum / $weightsum;
1002 }
bfe969e8
DW
1003 // Record the weights as used.
1004 if ($weights !== null) {
1005 foreach ($grade_values as $itemid=>$grade_value) {
852fae0b 1006 if ($weightsum > 0) {
bfe969e8 1007 $weight = $items[$itemid]->grademax - $items[$itemid]->grademin;
852fae0b 1008 $weights[$itemid] = $weight / $weightsum;
bfe969e8
DW
1009 } else {
1010 $weights[$itemid] = 0;
1011 }
1012 }
1013 }
1426edac 1014 break;
1015
c2efb501 1016 case GRADE_AGGREGATE_EXTRACREDIT_MEAN: // special average
9580a21f 1017 $num = 0;
d9ae2ab5 1018 $sum = null;
b79fe189 1019
1020 foreach ($grade_values as $itemid=>$grade_value) {
1021
eacd3700 1022 if ($items[$itemid]->aggregationcoef == 0) {
9580a21f 1023 $num += 1;
1024 $sum += $grade_value;
bfe969e8
DW
1025 if ($weights !== null) {
1026 $weights[$itemid] = 1;
1027 }
b79fe189 1028
eacd3700 1029 } else if ($items[$itemid]->aggregationcoef > 0) {
1030 $sum += $items[$itemid]->aggregationcoef * $grade_value;
bfe969e8 1031 if ($weights !== null) {
852fae0b 1032 $weights[$itemid] = 1;
bfe969e8
DW
1033 }
1034 }
1035 }
1036 if ($weights !== null && $num > 0) {
1037 foreach ($grade_values as $itemid=>$grade_value) {
1038 if ($weights[$itemid]) {
1039 $weights[$itemid] = 1.0 / $num;
1040 }
9580a21f 1041 }
1042 }
b79fe189 1043
9580a21f 1044 if ($num == 0) {
9c8d38fa 1045 $agg_grade = $sum; // only extra credits or wrong coefs
b79fe189 1046
9580a21f 1047 } else {
9c8d38fa 1048 $agg_grade = $sum / $num;
9580a21f 1049 }
1050 break;
1051
5232d3f2
DW
1052 case GRADE_AGGREGATE_SUM: // Add up all the items.
1053 $num = count($grade_values);
d57ea599 1054 $sum = 0;
a2c13f68 1055 $sumweights = 0;
a2c13f68
DW
1056 $grademin = 0;
1057 $grademax = 0;
1058 foreach ($grade_values as $itemid => $gradevalue) {
5a59aeb1 1059 // We need to check if the grademax/min was adjusted per user because of excluded items.
057063b1 1060 $usergrademin = $items[$itemid]->grademin;
5a59aeb1
DW
1061 $usergrademax = $items[$itemid]->grademax;
1062 if (isset($grademinoverrides[$itemid])) {
1063 $usergrademin = $grademinoverrides[$itemid];
1064 }
1065 if (isset($grademaxoverrides[$itemid])) {
1066 $usergrademax = $grademaxoverrides[$itemid];
1067 }
a2c13f68 1068
64055d50
FM
1069 // Ignore extra credit and items with a weight of 0.
1070 if ($items[$itemid]->aggregationcoef <= 0 && $items[$itemid]->aggregationcoef2 > 0) {
0b2e8ae0
FM
1071 $grademin += $usergrademin;
1072 $grademax += $usergrademax;
a2c13f68 1073 $sumweights += $items[$itemid]->aggregationcoef2;
aa705529
JO
1074 }
1075 }
057063b1
DW
1076 $userweights = array();
1077 $totaloverriddenweight = 0;
1078 $totaloverriddengrademax = 0;
1079 // We first need to rescale all manually assigned weights down by the
1080 // percentage of weights missing from the category.
1081 foreach ($grade_values as $itemid => $gradevalue) {
1082 if ($items[$itemid]->weightoverride) {
64055d50
FM
1083 if ($items[$itemid]->aggregationcoef2 <= 0) {
1084 // Records the weight of 0 and continue.
1085 $userweights[$itemid] = 0;
1086 continue;
1087 }
057063b1
DW
1088 $userweights[$itemid] = $items[$itemid]->aggregationcoef2 / $sumweights;
1089 $totaloverriddenweight += $userweights[$itemid];
1090 $usergrademax = $items[$itemid]->grademax;
1091 if (isset($grademaxoverrides[$itemid])) {
1092 $usergrademax = $grademaxoverrides[$itemid];
1093 }
1094 $totaloverriddengrademax += $usergrademax;
1095 }
1096 }
1097 $nonoverriddenpoints = $grademax - $totaloverriddengrademax;
1098
1099 // Then we need to recalculate the automatic weights.
1100 foreach ($grade_values as $itemid => $gradevalue) {
1101 if (!$items[$itemid]->weightoverride) {
64055d50
FM
1102 $usergrademax = $items[$itemid]->grademax;
1103 if (isset($grademaxoverrides[$itemid])) {
1104 $usergrademax = $grademaxoverrides[$itemid];
1105 }
057063b1 1106 if ($nonoverriddenpoints > 0) {
057063b1
DW
1107 $userweights[$itemid] = ($usergrademax/$nonoverriddenpoints) * (1 - $totaloverriddenweight);
1108 } else {
1109 $userweights[$itemid] = 0;
64055d50
FM
1110 if ($items[$itemid]->aggregationcoef2 > 0) {
1111 // Items with a weight of 0 should not count for the grade max,
1112 // though this only applies if the weight was changed to 0.
1113 $grademax -= $usergrademax;
1114 }
057063b1
DW
1115 }
1116 }
a2c13f68 1117 }
057063b1
DW
1118
1119 // We can use our freshly corrected weights below.
a2c13f68 1120 foreach ($grade_values as $itemid => $gradevalue) {
057063b1 1121 $sum += $gradevalue * $userweights[$itemid] * $grademax;
a2c13f68 1122 if ($weights !== null) {
057063b1 1123 $weights[$itemid] = $userweights[$itemid];
a2c13f68
DW
1124 }
1125 }
1126 if ($grademax > 0) {
1127 $agg_grade = $sum / $grademax; // Re-normalize score.
1128 } else {
1129 // Every item in the category is extra credit.
1130 $agg_grade = $sum;
1131 $grademax = $sum;
1132 }
b50b12b3 1133
5232d3f2
DW
1134 break;
1135
c2efb501 1136 case GRADE_AGGREGATE_MEAN: // Arithmetic average of all grade items (if ungraded aggregated, NULL counted as minimum)
ac9b0805 1137 default:
9580a21f 1138 $num = count($grade_values);
1139 $sum = array_sum($grade_values);
9c8d38fa 1140 $agg_grade = $sum / $num;
bfe969e8
DW
1141 // Record the weights evenly.
1142 if ($weights !== null && $num > 0) {
1143 foreach ($grade_values as $itemid=>$grade_value) {
1144 $weights[$itemid] = 1.0 / $num;
1145 }
1146 }
b8ff92b6 1147 break;
1148 }
1149
5232d3f2
DW
1150 return array('grade' => $agg_grade, 'grademin' => $grademin, 'grademax' => $grademax);
1151 }
1152
1153 /**
1154 * Internal function that calculates the aggregated grade for this grade category
1155 *
1156 * Must be public as it is used by grade_grade::get_hiding_affected()
1157 *
1158 * @deprecated since Moodle 2.8
1159 * @param array $grade_values An array of values to be aggregated
1160 * @param array $items The array of grade_items
1161 * @return float The aggregate grade for this grade category
1162 */
1163 public function aggregate_values($grade_values, $items) {
1164 debugging('grade_category::aggregate_values() is deprecated.
1165 Call grade_category::aggregate_values_and_adjust_bounds() instead.', DEBUG_DEVELOPER);
1166 $result = $this->aggregate_values_and_adjust_bounds($grade_values, $items);
1167 return $result['grade'];
0aa32279 1168 }
0758a08e 1169
3a03653e 1170 /**
fcf6e015 1171 * Some aggregation types may need to update their max grade.
a153c9f2 1172 *
64055d50
FM
1173 * This must be executed after updating the weights as it relies on them.
1174 *
fcf6e015 1175 * @return void
3a03653e 1176 */
fcf6e015
FM
1177 private function auto_update_max() {
1178 global $DB;
3a03653e 1179 if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1180 // not needed at all
1181 return;
1182 }
1183
fcf6e015
FM
1184 // Find grade items of immediate children (category or grade items) and force site settings.
1185 $this->load_grade_item();
1186 $depends_on = $this->grade_item->depends_on();
1187
1188 $items = false;
1189 if (!empty($depends_on)) {
1190 list($usql, $params) = $DB->get_in_or_equal($depends_on);
1191 $sql = "SELECT *
1192 FROM {grade_items}
1193 WHERE id $usql";
1194 $items = $DB->get_records_sql($sql, $params);
1195 }
1196
3a03653e 1197 if (!$items) {
b79fe189 1198
3a03653e 1199 if ($this->grade_item->grademax != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
1200 $this->grade_item->grademax = 0;
1201 $this->grade_item->grademin = 0;
1202 $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1203 $this->grade_item->update('aggregation');
1204 }
1205 return;
1206 }
1207
d57ea599 1208 //find max grade possible
59080eee 1209 $maxes = array();
b79fe189 1210
3a03653e 1211 foreach ($items as $item) {
b79fe189 1212
d57ea599
JO
1213 if ($item->aggregationcoef > 0) {
1214 // extra credit from this activity - does not affect total
1215 continue;
64055d50
FM
1216 } else if ($item->aggregationcoef2 <= 0) {
1217 // Items with a weight of 0 do not affect the total.
1218 continue;
3a03653e 1219 }
11eca362 1220
d57ea599
JO
1221 if ($item->gradetype == GRADE_TYPE_VALUE) {
1222 $maxes[$item->id] = $item->grademax;
1223
1224 } else if ($item->gradetype == GRADE_TYPE_SCALE) {
1225 $maxes[$item->id] = $item->grademax; // 0 = nograde, 1 = first scale item, 2 = second scale item
1226 }
1227 }
4272386a
FM
1228
1229 if ($this->can_apply_limit_rules()) {
1230 // Apply droplow and keephigh.
1231 $this->apply_limit_rules($maxes, $items);
1232 }
59080eee 1233 $max = array_sum($maxes);
3a03653e 1234
59080eee 1235 // update db if anything changed
1236 if ($this->grade_item->grademax != $max or $this->grade_item->grademin != 0 or $this->grade_item->gradetype != GRADE_TYPE_VALUE) {
3a03653e 1237 $this->grade_item->grademax = $max;
1238 $this->grade_item->grademin = 0;
1239 $this->grade_item->gradetype = GRADE_TYPE_VALUE;
1240 $this->grade_item->update('aggregation');
1241 }
1242 }
1243
a2f1f65d
AD
1244 /**
1245 * Recalculate the weights of the grade items in this category.
fcf6e015 1246 *
64055d50
FM
1247 * The category total is not updated here, a further call to
1248 * {@link self::auto_update_max()} is required.
1249 *
fcf6e015 1250 * @return void
a2f1f65d
AD
1251 */
1252 private function auto_update_weights() {
1253 if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1254 // This is only required if we are using natural weights.
1255 return;
1256 }
1257 $children = $this->get_children();
1258
519429b2 1259 $gradeitem = null;
a2f1f65d
AD
1260
1261 // Calculate the sum of the grademax's of all the items within this category.
f70da024 1262 $totalnonoverriddengrademax = 0;
11c93e28 1263 $totalgrademax = 0;
45da5361
AD
1264
1265 // Out of 1, how much weight has been manually overriden by a user?
1266 $totaloverriddenweight = 0;
1267 $totaloverriddengrademax = 0;
519429b2
AG
1268
1269 // Has every assessment in this category been overridden?
f70da024 1270 $automaticgradeitemspresent = false;
519429b2
AG
1271 // Does the grade item require normalising?
1272 $requiresnormalising = false;
1273
1274 // This array keeps track of the id and weight of every grade item that has been overridden.
1275 $overridearray = array();
a2f1f65d 1276 foreach ($children as $sortorder => $child) {
519429b2 1277 $gradeitem = null;
a2f1f65d
AD
1278
1279 if ($child['type'] == 'item') {
519429b2 1280 $gradeitem = $child['object'];
a2f1f65d 1281 } else if ($child['type'] == 'category') {
519429b2
AG
1282 $gradeitem = $child['object']->load_grade_item();
1283 }
1284
1285 // Record the ID and the weight for this grade item.
1286 $overridearray[$gradeitem->id] = array();
11c93e28 1287 $overridearray[$gradeitem->id]['extracredit'] = intval($gradeitem->aggregationcoef);
519429b2 1288 $overridearray[$gradeitem->id]['weight'] = $gradeitem->aggregationcoef2;
11c93e28 1289 $overridearray[$gradeitem->id]['weightoverride'] = intval($gradeitem->weightoverride);
519429b2
AG
1290 // If this item has had its weight overridden then set the flag to true, but
1291 // only if all previous items were also overridden. Note that extra credit items
1292 // are counted as overridden grade items.
f70da024
AG
1293 if (!$gradeitem->weightoverride && $gradeitem->aggregationcoef == 0) {
1294 $automaticgradeitemspresent = true;
1295 }
45da5361 1296
f70da024 1297 if ($gradeitem->aggregationcoef > 0) {
64055d50
FM
1298 // An extra credit grade item doesn't contribute to $totaloverriddengrademax.
1299 continue;
11c93e28 1300 } else if ($gradeitem->weightoverride > 0 && $gradeitem->aggregationcoef2 <= 0) {
64055d50 1301 // An overriden item that defines a weight of 0 does not contribute to $totaloverriddengrademax.
b14c5870
AD
1302 continue;
1303 }
1304
11c93e28
DW
1305 $totalgrademax += $gradeitem->grademax;
1306 if ($gradeitem->weightoverride > 0) {
519429b2
AG
1307 $totaloverriddenweight += $gradeitem->aggregationcoef2;
1308 $totaloverriddengrademax += $gradeitem->grademax;
1309 }
1310 }
1311
1312 // Initialise this variable (used to keep track of the weight override total).
1313 $normalisetotal = 0;
1314 // 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
1315 // other weights to zero and normalise the others.
1316 $overriddentotal = 0;
f70da024
AG
1317 // If the overridden weight total is higher than 1 then set the other untouched weights to zero.
1318 $setotherweightstozero = false;
519429b2
AG
1319 // Total up all of the weights.
1320 foreach ($overridearray as $gradeitemdetail) {
1321 // If the grade item has extra credit, then don't add it to the normalisetotal.
11c93e28 1322 if (!$gradeitemdetail['extracredit']) {
519429b2
AG
1323 $normalisetotal += $gradeitemdetail['weight'];
1324 }
11c93e28 1325 if ($gradeitemdetail['weightoverride'] && !$gradeitemdetail['extracredit']) {
519429b2
AG
1326 // Add overriden weights up to see if they are greater than 1.
1327 $overriddentotal += $gradeitemdetail['weight'];
45da5361 1328 }
a2f1f65d 1329 }
f70da024 1330 if ($overriddentotal > 1) {
519429b2
AG
1331 // Make sure that this catergory of weights gets normalised.
1332 $requiresnormalising = true;
1333 // The normalised weights are only the overridden weights, so we just use the total of those.
1334 $normalisetotal = $overriddentotal;
519429b2
AG
1335 }
1336
11c93e28 1337 $totalnonoverriddengrademax = $totalgrademax - $totaloverriddengrademax;
45da5361 1338
a2f1f65d
AD
1339 reset($children);
1340 foreach ($children as $sortorder => $child) {
519429b2 1341 $gradeitem = null;
a2f1f65d
AD
1342
1343 if ($child['type'] == 'item') {
519429b2 1344 $gradeitem = $child['object'];
a2f1f65d 1345 } else if ($child['type'] == 'category') {
519429b2
AG
1346 $gradeitem = $child['object']->load_grade_item();
1347 }
1348
f70da024
AG
1349 if (!$gradeitem->weightoverride) {
1350 // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.
1351 if ($totaloverriddenweight >= 1 || $totalnonoverriddengrademax == 0 || $gradeitem->grademax == 0) {
64055d50 1352 // There is no more weight to distribute.
f70da024 1353 $gradeitem->aggregationcoef2 = 0;
64055d50
FM
1354 } else {
1355 // Calculate this item's weight as a percentage of the non-overridden total grade maxes
1356 // then convert it to a proportion of the available non-overriden weight.
f70da024
AG
1357 $gradeitem->aggregationcoef2 = ($gradeitem->grademax/$totalnonoverriddengrademax) *
1358 (1 - $totaloverriddenweight);
64055d50 1359 }
f70da024 1360 $gradeitem->update();
11c93e28
DW
1361 } else if ((!$automaticgradeitemspresent && $normalisetotal != 1) || ($requiresnormalising)) {
1362 // Just divide the overriden weight for this item against the total weight override of all
1363 // items in this category.
1364 if ($normalisetotal == 0) {
1365 $gradeitem->aggregationcoef2 = 0;
1366 } else {
1367 $gradeitem->aggregationcoef2 = $overridearray[$gradeitem->id]['weight'] / $normalisetotal;
1368 }
1369 // Update the grade item to reflect these changes.
1370 $gradeitem->update();
45da5361 1371 }
a2f1f65d
AD
1372 }
1373 }
1374
adc2f286 1375 /**
a153c9f2 1376 * Given an array of grade values (numerical indices) applies droplow or keephigh rules to limit the final array.
b79fe189 1377 *
a153c9f2 1378 * @param array $grade_values itemid=>$grade_value float
b79fe189 1379 * @param array $items grade item objects
adc2f286 1380 * @return array Limited grades.
1381 */
a9e38ac8 1382 public function apply_limit_rules(&$grade_values, $items) {
1383 $extraused = $this->is_extracredit_used();
1384
adc2f286 1385 if (!empty($this->droplow)) {
a9e38ac8 1386 asort($grade_values, SORT_NUMERIC);
1387 $dropped = 0;
b79fe189 1388
fb80cb2a
AD
1389 // If we have fewer grade items available to drop than $this->droplow, use this flag to escape the loop
1390 // May occur because of "extra credit" or if droplow is higher than the number of grade items
1391 $droppedsomething = true;
b79fe189 1392
fb80cb2a
AD
1393 while ($dropped < $this->droplow && $droppedsomething) {
1394 $droppedsomething = false;
b79fe189 1395
fb80cb2a 1396 $grade_keys = array_keys($grade_values);
aee968da
AD
1397 $gradekeycount = count($grade_keys);
1398
1399 if ($gradekeycount === 0) {
fb80cb2a
AD
1400 //We've dropped all grade items
1401 break;
1402 }
b79fe189 1403
fb80cb2a
AD
1404 $originalindex = $founditemid = $foundmax = null;
1405
1406 // Find the first remaining grade item that is available to be dropped
1407 foreach ($grade_keys as $gradekeyindex=>$gradekey) {
1408 if (!$extraused || $items[$gradekey]->aggregationcoef <= 0) {
1409 // Found a non-extra credit grade item that is eligible to be dropped
1410 $originalindex = $gradekeyindex;
1411 $founditemid = $grade_keys[$originalindex];
1412 $foundmax = $items[$founditemid]->grademax;
1413 break;
a9e38ac8 1414 }
fb80cb2a 1415 }
b79fe189 1416
fb80cb2a
AD
1417 if (empty($founditemid)) {
1418 // No grade items available to drop
a9e38ac8 1419 break;
59080eee 1420 }
fb80cb2a
AD
1421
1422 // Now iterate over the remaining grade items
1423 // We're looking for other grade items with the same grade value but a higher grademax
1424 $i = 1;
aee968da 1425 while ($originalindex + $i < $gradekeycount) {
52929486 1426
fb80cb2a 1427 $possibleitemid = $grade_keys[$originalindex+$i];
52929486
AD
1428 $i++;
1429
fb80cb2a
AD
1430 if ($grade_values[$founditemid] != $grade_values[$possibleitemid]) {
1431 // The next grade item has a different grade value. Stop looking.
1432 break;
1433 }
1434
1435 if ($extraused && $items[$possibleitemid]->aggregationcoef > 0) {
1436 // Don't drop extra credit grade items. Continue the search.
1437 continue;
1438 }
1439
1440 if ($foundmax < $items[$possibleitemid]->grademax) {
1441 // Found a grade item with the same grade value and a higher grademax
1442 $foundmax = $items[$possibleitemid]->grademax;
1443 $founditemid = $possibleitemid;
1444 // Continue searching to see if there is an even higher grademax
1445 }
fb80cb2a
AD
1446 }
1447
1448 // Now drop whatever grade item we have found
1449 unset($grade_values[$founditemid]);
1450 $dropped++;
1451 $droppedsomething = true;
adc2f286 1452 }
a9e38ac8 1453
1454 } else if (!empty($this->keephigh)) {
1455 arsort($grade_values, SORT_NUMERIC);
1456 $kept = 0;
b79fe189 1457
a9e38ac8 1458 foreach ($grade_values as $itemid=>$value) {
b79fe189 1459
a9e38ac8 1460 if ($extraused and $items[$itemid]->aggregationcoef > 0) {
1461 // we keep all extra credits
b79fe189 1462
a9e38ac8 1463 } else if ($kept < $this->keephigh) {
1464 $kept++;
b79fe189 1465
a9e38ac8 1466 } else {
1467 unset($grade_values[$itemid]);
1468 }
adc2f286 1469 }
1470 }
0aa32279 1471 }
1472
4272386a
FM
1473 /**
1474 * Returns whether or not we can apply the limit rules.
1475 *
1476 * There are cases where drop lowest or keep highest should not be used
1477 * at all. This method will determine whether or not this logic can be
1478 * applied considering the current setup of the category.
1479 *
1480 * @return bool
1481 */
1482 public function can_apply_limit_rules() {
1483 if ($this->canapplylimitrules !== null) {
1484 return $this->canapplylimitrules;
1485 }
1486
1487 // Set it to be supported by default.
1488 $this->canapplylimitrules = true;
1489
1490 // Natural aggregation.
1491 if ($this->aggregation == GRADE_AGGREGATE_SUM) {
1492 $canapply = true;
1493
1494 // Check until one child breaks the rules.
1495 $gradeitems = $this->get_children();
1496 $validitems = 0;
1497 $lastweight = null;
1498 $lastmaxgrade = null;
1499 foreach ($gradeitems as $gradeitem) {
1500 $gi = $gradeitem['object'];
1501
1502 if ($gradeitem['type'] == 'category') {
1503 // Sub categories are not allowed because they can have dynamic weights/maxgrades.
1504 $canapply = false;
1505 break;
1506 }
1507
1508 if ($gi->aggregationcoef > 0) {
1509 // Extra credit items are not allowed.
1510 $canapply = false;
1511 break;
1512 }
1513
1514 if ($lastweight !== null && $lastweight != $gi->aggregationcoef2) {
1515 // One of the weight differs from another item.
1516 $canapply = false;
1517 break;
1518 }
1519
1520 if ($lastmaxgrade !== null && $lastmaxgrade != $gi->grademax) {
1521 // One of the max grade differ from another item. This is not allowed for now
1522 // because we could be end up with different max grade between users for this category.
1523 $canapply = false;
1524 break;
1525 }
1526
1527 $lastweight = $gi->aggregationcoef2;
1528 $lastmaxgrade = $gi->grademax;
1529 }
1530
1531 $this->canapplylimitrules = $canapply;
1532 }
1533
1534 return $this->canapplylimitrules;
1535 }
1536
793253ae 1537 /**
1538 * Returns true if category uses extra credit of any kind
b79fe189 1539 *
a153c9f2 1540 * @return bool True if extra credit used
793253ae 1541 */
134c514b
RT
1542 public function is_extracredit_used() {
1543 return self::aggregation_uses_extracredit($this->aggregation);
1544 }
1545
1546 /**
1547 * Returns true if aggregation passed is using extracredit.
1548 *
1549 * @param int $aggregation Aggregation const.
1550 * @return bool True if extra credit used
1551 */
1552 public static function aggregation_uses_extracredit($aggregation) {
1553 return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1554 or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1555 or $aggregation == GRADE_AGGREGATE_SUM);
793253ae 1556 }
1557
9580a21f 1558 /**
f7d515b6 1559 * Returns true if category uses special aggregation coefficient
b79fe189 1560 *
a153c9f2 1561 * @return bool True if an aggregation coefficient is being used
9580a21f 1562 */
4e9ca991 1563 public function is_aggregationcoef_used() {
134c514b
RT
1564 return self::aggregation_uses_aggregationcoef($this->aggregation);
1565
1566 }
1567
1568 /**
1569 * Returns true if aggregation uses aggregationcoef
1570 *
1571 * @param int $aggregation Aggregation const.
1572 * @return bool True if an aggregation coefficient is being used
1573 */
1574 public static function aggregation_uses_aggregationcoef($aggregation) {
1575 return ($aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN
1576 or $aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2
1577 or $aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN
1578 or $aggregation == GRADE_AGGREGATE_SUM);
ba74762b 1579
9580a21f 1580 }
1581
653a8648 1582 /**
a153c9f2 1583 * Recursive function to find which weight/extra credit field to use in the grade item form.
b79fe189 1584 *
a153c9f2 1585 * Inherits from a parent category if that category has aggregatesubcats set to true.
b79fe189 1586 *
a153c9f2 1587 * @param string $first Whether or not this is the first item in the recursion
b79fe189 1588 * @return string
653a8648 1589 */
1590 public function get_coefstring($first=true) {
1591 if (!is_null($this->coefstring)) {
1592 return $this->coefstring;
1593 }
1594
1595 $overriding_coefstring = null;
1596
1597 // Stop recursing upwards if this category aggregates subcats or has no parent
1598 if (!$first && !$this->aggregatesubcats) {
b79fe189 1599
121d8006 1600 if ($parent_category = $this->load_parent_category()) {
653a8648 1601 return $parent_category->get_coefstring(false);
b79fe189 1602
653a8648 1603 } else {
1604 return null;
1605 }
b79fe189 1606
1607 } else if ($first) {
1608
653a8648 1609 if (!$this->aggregatesubcats) {
b79fe189 1610
121d8006 1611 if ($parent_category = $this->load_parent_category()) {
653a8648 1612 $overriding_coefstring = $parent_category->get_coefstring(false);
1613 }
1614 }
1615 }
1616
1617 // If an overriding coefstring has trickled down from one of the parent categories, return it. Otherwise, return self.
1618 if (!is_null($overriding_coefstring)) {
1619 return $overriding_coefstring;
1620 }
1621
1622 // No parent category is overriding this category's aggregation, return its string
1623 if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN) {
1624 $this->coefstring = 'aggregationcoefweight';
b79fe189 1625
d9ae2ab5 1626 } else if ($this->aggregation == GRADE_AGGREGATE_WEIGHTED_MEAN2) {
1627 $this->coefstring = 'aggregationcoefextrasum';
b79fe189 1628
653a8648 1629 } else if ($this->aggregation == GRADE_AGGREGATE_EXTRACREDIT_MEAN) {
3869ab1a 1630 $this->coefstring = 'aggregationcoefextraweight';
b79fe189 1631
653a8648 1632 } else if ($this->aggregation == GRADE_AGGREGATE_SUM) {
b6a1e366 1633 $this->coefstring = 'aggregationcoefextraweightsum';
b79fe189 1634
653a8648 1635 } else {
1636 $this->coefstring = 'aggregationcoef';
1637 }
1638 return $this->coefstring;
1639 }
1640
1c307f21 1641 /**
b3ac6c3e 1642 * Returns tree with all grade_items and categories as elements
b79fe189 1643 *
1644 * @param int $courseid The course ID
a153c9f2 1645 * @param bool $include_category_items as category children
b3ac6c3e 1646 * @return array
1c307f21 1647 */
da3801e8 1648 public static function fetch_course_tree($courseid, $include_category_items=false) {
f3ac8eb4 1649 $course_category = grade_category::fetch_course_category($courseid);
514a3467 1650 $category_array = array('object'=>$course_category, 'type'=>'category', 'depth'=>1,
1651 'children'=>$course_category->get_children($include_category_items));
b146d984
AD
1652
1653 $course_category->sortorder = $course_category->get_sortorder();
e2bb3c92
PS
1654 $sortorder = $course_category->get_sortorder();
1655 return grade_category::_fetch_course_tree_recursion($category_array, $sortorder);
1c307f21 1656 }
1657
b79fe189 1658 /**
a153c9f2 1659 * An internal function that recursively sorts grade categories within a course
b79fe189 1660 *
1661 * @param array $category_array The seed of the recursion
a153c9f2
AD
1662 * @param int $sortorder The current sortorder
1663 * @return array An array containing 'object', 'type', 'depth' and optionally 'children'
b79fe189 1664 */
1665 static private function _fetch_course_tree_recursion($category_array, &$sortorder) {
b3ac6c3e 1666 // update the sortorder in db if needed
b146d984
AD
1667 //NOTE: This leads to us resetting sort orders every time the categories and items page is viewed :(
1668 //if ($category_array['object']->sortorder != $sortorder) {
1669 //$category_array['object']->set_sortorder($sortorder);
1670 //}
ce385eb4 1671
7bac3777
AD
1672 if (isset($category_array['object']->gradetype) && $category_array['object']->gradetype==GRADE_TYPE_NONE) {
1673 return null;
1674 }
1675
314c4336 1676 // store the grade_item or grade_category instance with extra info
1677 $result = array('object'=>$category_array['object'], 'type'=>$category_array['type'], 'depth'=>$category_array['depth']);
b3ac6c3e 1678
1679 // reuse final grades if there
1680 if (array_key_exists('finalgrades', $category_array)) {
1681 $result['finalgrades'] = $category_array['finalgrades'];
1682 }
1683
1684 // recursively resort children
1685 if (!empty($category_array['children'])) {
1686 $result['children'] = array();
29d509f5 1687 //process the category item first
7bac3777 1688 $child = null;
b79fe189 1689
1690 foreach ($category_array['children'] as $oldorder=>$child_array) {
1691
314c4336 1692 if ($child_array['type'] == 'courseitem' or $child_array['type'] == 'categoryitem') {
7bac3777
AD
1693 $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
1694 if (!empty($child)) {
1695 $result['children'][$sortorder] = $child;
1696 }
29d509f5 1697 }
2b0f65e2 1698 }
b79fe189 1699
1700 foreach ($category_array['children'] as $oldorder=>$child_array) {
1701
29d509f5 1702 if ($child_array['type'] != 'courseitem' and $child_array['type'] != 'categoryitem') {
7bac3777
AD
1703 $child = grade_category::_fetch_course_tree_recursion($child_array, $sortorder);
1704 if (!empty($child)) {
1705 $result['children'][++$sortorder] = $child;
1706 }
b3ac6c3e 1707 }
1708 }
1709 }
1710
1711 return $result;
ce385eb4 1712 }
7c8a963f 1713
1714 /**
4a490db0 1715 * Fetches and returns all the children categories and/or grade_items belonging to this category.
1716 * By default only returns the immediate children (depth=1), but deeper levels can be requested,
a39cac25 1717 * as well as all levels (0). The elements are indexed by sort order.
b79fe189 1718 *
1719 * @param bool $include_category_items Whether or not to include category grade_items in the children array
7c8a963f 1720 * @return array Array of child objects (grade_category and grade_item).
1721 */
da3801e8 1722 public function get_children($include_category_items=false) {
1723 global $DB;
b3ac6c3e 1724
1725 // This function must be as fast as possible ;-)
1726 // fetch all course grade items and categories into memory - we do not expect hundreds of these in course
1727 // we have to limit the number of queries though, because it will be used often in grade reports
1728
da3801e8 1729 $cats = $DB->get_records('grade_categories', array('courseid' => $this->courseid));
1730 $items = $DB->get_records('grade_items', array('courseid' => $this->courseid));
4a490db0 1731
b3ac6c3e 1732 // init children array first
1733 foreach ($cats as $catid=>$cat) {
1734 $cats[$catid]->children = array();
27f95e9b 1735 }
4a490db0 1736
b3ac6c3e 1737 //first attach items to cats and add category sortorder
1738 foreach ($items as $item) {
b79fe189 1739
b3ac6c3e 1740 if ($item->itemtype == 'course' or $item->itemtype == 'category') {
1741 $cats[$item->iteminstance]->sortorder = $item->sortorder;
4a490db0 1742
b3ac6c3e 1743 if (!$include_category_items) {
1744 continue;
1745 }
1746 $categoryid = $item->iteminstance;
b79fe189 1747
b3ac6c3e 1748 } else {
1749 $categoryid = $item->categoryid;
63354ab5
AD
1750 if (empty($categoryid)) {
1751 debugging('Found a grade item that isnt in a category');
1752 }
b3ac6c3e 1753 }
1754
1755 // prevent problems with duplicate sortorders in db
1756 $sortorder = $item->sortorder;
b79fe189 1757
63354ab5
AD
1758 while (array_key_exists($categoryid, $cats)
1759 && array_key_exists($sortorder, $cats[$categoryid]->children)) {
1760
b3ac6c3e 1761 $sortorder++;
1762 }
1763
1764 $cats[$categoryid]->children[$sortorder] = $item;
1765
1766 }
1767
1768 // now find the requested category and connect categories as children
1769 $category = false;
b79fe189 1770
b3ac6c3e 1771 foreach ($cats as $catid=>$cat) {
b79fe189 1772
ec3717e1 1773 if (empty($cat->parent)) {
b79fe189 1774
ec3717e1 1775 if ($cat->path !== '/'.$cat->id.'/') {
1776 $grade_category = new grade_category($cat, false);
1777 $grade_category->path = '/'.$cat->id.'/';
1778 $grade_category->depth = 1;
1779 $grade_category->update('system');
1780 return $this->get_children($include_category_items);
1781 }
b79fe189 1782
ec3717e1 1783 } else {
b79fe189 1784
ec3717e1 1785 if (empty($cat->path) or !preg_match('|/'.$cat->parent.'/'.$cat->id.'/$|', $cat->path)) {
1786 //fix paths and depts
1787 static $recursioncounter = 0; // prevents infinite recursion
1788 $recursioncounter++;
b79fe189 1789
da3801e8 1790 if ($recursioncounter < 5) {
ec3717e1 1791 // fix paths and depths!
1792 $grade_category = new grade_category($cat, false);
1793 $grade_category->depth = 0;
1794 $grade_category->path = null;
1795 $grade_category->update('system');
1796 return $this->get_children($include_category_items);
1797 }
da3801e8 1798 }
b3ac6c3e 1799 // prevent problems with duplicate sortorders in db
1800 $sortorder = $cat->sortorder;
b79fe189 1801
1802 while (array_key_exists($sortorder, $cats[$cat->parent]->children)) {
f13002d5 1803 //debugging("$sortorder exists in cat loop");
b3ac6c3e 1804 $sortorder++;
1805 }
1806
65370356 1807 $cats[$cat->parent]->children[$sortorder] = &$cats[$catid];
b3ac6c3e 1808 }
f3ac8eb4 1809
b3ac6c3e 1810 if ($catid == $this->id) {
1811 $category = &$cats[$catid];
1812 }
1813 }
1814
1815 unset($items); // not needed
1816 unset($cats); // not needed
1817
f3ac8eb4 1818 $children_array = grade_category::_get_children_recursion($category);
b3ac6c3e 1819
1820 ksort($children_array);
1821
1822 return $children_array;
1823
1824 }
1825
b79fe189 1826 /**
1827 * Private method used to retrieve all children of this category recursively
1828 *
1829 * @param grade_category $category Source of current recursion
a153c9f2 1830 * @return array An array of child grade categories
b79fe189 1831 */
22a9b6d8 1832 private static function _get_children_recursion($category) {
b3ac6c3e 1833
1834 $children_array = array();
b79fe189 1835 foreach ($category->children as $sortorder=>$child) {
1836
b3ac6c3e 1837 if (array_key_exists('itemtype', $child)) {
f3ac8eb4 1838 $grade_item = new grade_item($child, false);
b79fe189 1839
4faf5f99 1840 if (in_array($grade_item->itemtype, array('course', 'category'))) {
1841 $type = $grade_item->itemtype.'item';
1842 $depth = $category->depth;
b79fe189 1843
314c4336 1844 } else {
1845 $type = 'item';
1846 $depth = $category->depth; // we use this to set the same colour
b3ac6c3e 1847 }
4faf5f99 1848 $children_array[$sortorder] = array('object'=>$grade_item, 'type'=>$type, 'depth'=>$depth);
4a490db0 1849
7c8a963f 1850 } else {
f3ac8eb4 1851 $children = grade_category::_get_children_recursion($child);
1852 $grade_category = new grade_category($child, false);
b79fe189 1853
b3ac6c3e 1854 if (empty($children)) {
314c4336 1855 $children = array();
7c8a963f 1856 }
4faf5f99 1857 $children_array[$sortorder] = array('object'=>$grade_category, 'type'=>'category', 'depth'=>$grade_category->depth, 'children'=>$children);
314c4336 1858 }
27f95e9b 1859 }
1860
b3ac6c3e 1861 // sort the array
1862 ksort($children_array);
1863
27f95e9b 1864 return $children_array;
1865 }
4a490db0 1866
f151b073 1867 /**
a4d76049 1868 * Uses {@link get_grade_item()} to load or create a grade_item, then saves it as $this->grade_item.
a153c9f2
AD
1869 *
1870 * @return grade_item
f151b073 1871 */
da3801e8 1872 public function load_grade_item() {
ac9b0805 1873 if (empty($this->grade_item)) {
1874 $this->grade_item = $this->get_grade_item();
1875 }
ab53054f 1876 return $this->grade_item;
1877 }
4a490db0 1878
ab53054f 1879 /**
a153c9f2
AD
1880 * Retrieves this grade categories' associated grade_item from the database
1881 *
1882 * If no grade_item exists yet, creates one.
1883 *
1884 * @return grade_item
ab53054f 1885 */
da3801e8 1886 public function get_grade_item() {
c91ed4be 1887 if (empty($this->id)) {
1888 debugging("Attempt to obtain a grade_category's associated grade_item without the category's ID being set.");
1889 return false;
1890 }
1891
b3ac6c3e 1892 if (empty($this->parent)) {
1893 $params = array('courseid'=>$this->courseid, 'itemtype'=>'course', 'iteminstance'=>$this->id);
1894
1895 } else {
1896 $params = array('courseid'=>$this->courseid, 'itemtype'=>'category', 'iteminstance'=>$this->id);
1897 }
4ac209d5 1898
f3ac8eb4 1899 if (!$grade_items = grade_item::fetch_all($params)) {
b8ff92b6 1900 // create a new one
f3ac8eb4 1901 $grade_item = new grade_item($params, false);
b8ff92b6 1902 $grade_item->gradetype = GRADE_TYPE_VALUE;
f8e6e4db 1903 $grade_item->insert('system');
4a490db0 1904
b79fe189 1905 } else if (count($grade_items) == 1) {
b8ff92b6 1906 // found existing one
1907 $grade_item = reset($grade_items);
4a490db0 1908
b8ff92b6 1909 } else {
1910 debugging("Found more than one grade_item attached to category id:".$this->id);
ac9b0805 1911 // return first one
1912 $grade_item = reset($grade_items);
2c72af1f 1913 }
1914
ab53054f 1915 return $grade_item;
f151b073 1916 }
8c846243 1917
1918 /**
a153c9f2
AD
1919 * Uses $this->parent to instantiate $this->parent_category based on the referenced record in the DB
1920 *
1921 * @return grade_category The parent category
8c846243 1922 */
da3801e8 1923 public function load_parent_category() {
8c846243 1924 if (empty($this->parent_category) && !empty($this->parent)) {
ab53054f 1925 $this->parent_category = $this->get_parent_category();
8c846243 1926 }
1927 return $this->parent_category;
4a490db0 1928 }
1929
ab53054f 1930 /**
a153c9f2
AD
1931 * Uses $this->parent to instantiate and return a grade_category object
1932 *
1933 * @return grade_category Returns the parent category or null if this category has no parent
ab53054f 1934 */
da3801e8 1935 public function get_parent_category() {
ab53054f 1936 if (!empty($this->parent)) {
f3ac8eb4 1937 $parent_category = new grade_category(array('id' => $this->parent));
4a490db0 1938 return $parent_category;
ab53054f 1939 } else {
1940 return null;
1941 }
1942 }
1943
2186f72c 1944 /**
a153c9f2 1945 * Returns the most descriptive field for this grade category
b79fe189 1946 *
2186f72c 1947 * @return string name
1948 */
da3801e8 1949 public function get_name() {
1950 global $DB;
8f6fdf43 1951 // For a course category, we return the course name if the fullname is set to '?' in the DB (empty in the category edit form)
1952 if (empty($this->parent) && $this->fullname == '?') {
da3801e8 1953 $course = $DB->get_record('course', array('id'=> $this->courseid));
410753fb 1954 return format_string($course->fullname);
b79fe189 1955
314c4336 1956 } else {
1957 return $this->fullname;
1958 }
2186f72c 1959 }
c91ed4be 1960
65c2ac93
DW
1961 /**
1962 * Describe the aggregation settings for this category so the reports make more sense.
1963 *
1964 * @return string description
1965 */
1966 public function get_description() {
1967 $allhelp = array();
2be0d5c3
MG
1968 if ($this->aggregation != GRADE_AGGREGATE_SUM) {
1969 $aggrstrings = grade_helper::get_aggregation_strings();
1970 $allhelp[] = $aggrstrings[$this->aggregation];
1971 }
65c2ac93 1972
ded8ea8f 1973 if ($this->droplow && $this->can_apply_limit_rules()) {
65c2ac93
DW
1974 $allhelp[] = get_string('droplowestvalues', 'grades', $this->droplow);
1975 }
ded8ea8f 1976 if ($this->keephigh && $this->can_apply_limit_rules()) {
65c2ac93
DW
1977 $allhelp[] = get_string('keephighestvalues', 'grades', $this->keephigh);
1978 }
1979 if (!$this->aggregateonlygraded) {
1980 $allhelp[] = get_string('aggregatenotonlygraded', 'grades');
1981 }
1982 if ($this->aggregatesubcats) {
1983 $allhelp[] = get_string('aggregatesubcatsshort', 'grades');
1984 }
2be0d5c3
MG
1985 if ($allhelp) {
1986 return implode('. ', $allhelp) . '.';
1987 }
1988 return '';
65c2ac93
DW
1989 }
1990
0fc7f624 1991 /**
a153c9f2 1992 * Sets this category's parent id
b79fe189 1993 *
a153c9f2
AD
1994 * @param int $parentid The ID of the category that is the new parent to $this
1995 * @param string $source From where was the object updated (mod/forum, manual, etc.)
1996 * @return bool success
0fc7f624 1997 */
da3801e8 1998 public function set_parent($parentid, $source=null) {
f13002d5 1999 if ($this->parent == $parentid) {
2000 return true;
2001 }
2002
2003 if ($parentid == $this->id) {
2f137aa1 2004 print_error('cannotassignselfasparent');
f13002d5 2005 }
2006
2007 if (empty($this->parent) and $this->is_course_category()) {
2f137aa1 2008 print_error('cannothaveparentcate');
b3ac6c3e 2009 }
f13002d5 2010
2011 // find parent and check course id
f3ac8eb4 2012 if (!$parent_category = grade_category::fetch(array('id'=>$parentid, 'courseid'=>$this->courseid))) {
b3ac6c3e 2013 return false;
2014 }
2015
f8e6e4db 2016 $this->force_regrading();
b3ac6c3e 2017
2018 // set new parent category
f8e6e4db 2019 $this->parent = $parent_category->id;
2020 $this->parent_category =& $parent_category;
b3ac6c3e 2021 $this->path = null; // remove old path and depth - will be recalculated in update()
ec3717e1 2022 $this->depth = 0; // remove old path and depth - will be recalculated in update()
f8e6e4db 2023 $this->update($source);
b3ac6c3e 2024
15b462da 2025 return $this->update($source);
b3ac6c3e 2026 }
2027
2028 /**
a153c9f2 2029 * Returns the final grade values for this grade category.
b79fe189 2030 *
a153c9f2 2031 * @param int $userid Optional user ID to retrieve a single user's final grade
b3ac6c3e 2032 * @return mixed An array of all final_grades (stdClass objects) for this grade_item, or a single final_grade.
2033 */
b79fe189 2034 public function get_final($userid=null) {
b3ac6c3e 2035 $this->load_grade_item();
2036 return $this->grade_item->get_final($userid);
0fc7f624 2037 }
4a490db0 2038
0fc7f624 2039 /**
a153c9f2
AD
2040 * Returns the sortorder of the grade categories' associated grade_item
2041 *
2042 * This method is also available in grade_item for cases where the object type is not known.
b79fe189 2043 *
0fc7f624 2044 * @return int Sort order
2045 */
da3801e8 2046 public function get_sortorder() {
b3ac6c3e 2047 $this->load_grade_item();
2048 return $this->grade_item->get_sortorder();
0fc7f624 2049 }
2050
be7c0693 2051 /**
a153c9f2
AD
2052 * Returns the idnumber of the grade categories' associated grade_item.
2053 *
2054 * This method is also available in grade_item for cases where the object type is not known.
b79fe189 2055 *
be7c0693 2056 * @return string idnumber
2057 */
da3801e8 2058 public function get_idnumber() {
be7c0693 2059 $this->load_grade_item();
2060 return $this->grade_item->get_idnumber();
2061 }
2062
0fc7f624 2063 /**
a153c9f2
AD
2064 * Sets the sortorder variable for this category.
2065 *
4a490db0 2066 * This method is also available in grade_item, for cases where the object type is not know.
b79fe189 2067 *
2068 * @param int $sortorder The sortorder to assign to this category
0fc7f624 2069 */
da3801e8 2070 public function set_sortorder($sortorder) {
b3ac6c3e 2071 $this->load_grade_item();
2072 $this->grade_item->set_sortorder($sortorder);
2073 }
2074
6639ead3 2075 /**
a153c9f2 2076 * Move this category after the given sortorder
b79fe189 2077 *
a153c9f2 2078 * Does not change the parent
b79fe189 2079 *
a153c9f2 2080 * @param int $sortorder to place after.
b79fe189 2081 * @return void
6639ead3 2082 */
da3801e8 2083 public function move_after_sortorder($sortorder) {
f13002d5 2084 $this->load_grade_item();
2085 $this->grade_item->move_after_sortorder($sortorder);
2086 }
2087
b3ac6c3e 2088 /**
f13002d5 2089 * Return true if this is the top most category that represents the total course grade.
b79fe189 2090 *
a153c9f2 2091 * @return bool
b3ac6c3e 2092 */
da3801e8 2093 public function is_course_category() {
b3ac6c3e 2094 $this->load_grade_item();
2095 return $this->grade_item->is_course_item();
2096 }
2097
2098 /**
a153c9f2 2099 * Return the course level grade_category object
b79fe189 2100 *
2101 * @param int $courseid The Course ID
a153c9f2 2102 * @return grade_category Returns the course level grade_category instance
b3ac6c3e 2103 */
22a9b6d8 2104 public static function fetch_course_category($courseid) {
a4503119 2105 if (empty($courseid)) {
2106 debugging('Missing course id!');
2107 return false;
2108 }
b3ac6c3e 2109
2110 // course category has no parent
f3ac8eb4 2111 if ($course_category = grade_category::fetch(array('courseid'=>$courseid, 'parent'=>null))) {
b3ac6c3e 2112 return $course_category;
2113 }
2114
2115 // create a new one
f3ac8eb4 2116 $course_category = new grade_category();
b3ac6c3e 2117 $course_category->insert_course_category($courseid);
2118
2119 return $course_category;
0fc7f624 2120 }
4ac209d5 2121
79eabc2a 2122 /**
2123 * Is grading object editable?
b79fe189 2124 *
a153c9f2 2125 * @return bool
79eabc2a 2126 */
da3801e8 2127 public function is_editable() {
79eabc2a 2128 return true;
2129 }
2130
5fad5061 2131 /**
a153c9f2
AD
2132 * Returns the locked state/date of the grade categories' associated grade_item.
2133 *
2134 * This method is also available in grade_item, for cases where the object type is not known.
2135 *
2136 * @return bool
5fad5061 2137 */
da3801e8 2138 public function is_locked() {
5fad5061 2139 $this->load_grade_item();
22e23c78 2140 return $this->grade_item->is_locked();
5fad5061 2141 }
2142
2143 /**
2144 * Sets the grade_item's locked variable and updates the grade_item.
a153c9f2
AD
2145 *
2146 * Calls set_locked() on the categories' grade_item
b79fe189 2147 *
2148 * @param int $lockedstate 0, 1 or a timestamp int(10) after which date the item will be locked.
2149 * @param bool $cascade lock/unlock child objects too
2150 * @param bool $refresh refresh grades when unlocking
a153c9f2 2151 * @return bool success if category locked (not all children mayb be locked though)
5fad5061 2152 */
da3801e8 2153 public function set_locked($lockedstate, $cascade=false, $refresh=true) {
5fad5061 2154 $this->load_grade_item();
2b0f65e2 2155
fb0e3570 2156 $result = $this->grade_item->set_locked($lockedstate, $cascade, true);
2157
2158 if ($cascade) {
2159 //process all children - items and categories
f3ac8eb4 2160 if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
b79fe189 2161
2162 foreach ($children as $child) {
fb0e3570 2163 $child->set_locked($lockedstate, true, false);
b79fe189 2164
fb0e3570 2165 if (empty($lockedstate) and $refresh) {
2166 //refresh when unlocking
2167 $child->refresh_grades();
2168 }
2b0f65e2 2169 }
7a7a53d3 2170 }
b79fe189 2171
f3ac8eb4 2172 if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
b79fe189 2173
2174 foreach ($children as $child) {
fb0e3570 2175 $child->set_locked($lockedstate, true, true);
2176 }
7a7a53d3 2177 }
2178 }
2b0f65e2 2179
b121b544 2180 return $result;
5fad5061 2181 }
4a490db0 2182
a153c9f2
AD
2183 /**
2184 * Overrides grade_object::set_properties() to add special handling for changes to category aggregation types
2185 *
2186 * @param stdClass $instance the object to set the properties on
2187 * @param array|stdClass $params Either an associative array or an object containing property name, property value pairs
2188 */
79b260cc
AD
2189 public static function set_properties(&$instance, $params) {
2190 global $DB;
2191
2192 parent::set_properties($instance, $params);
2193
c1024411 2194 //if they've changed aggregation type we made need to do some fiddling to provide appropriate defaults
79b260cc
AD
2195 if (!empty($params->aggregation)) {
2196
2197 //weight and extra credit share a column :( Would like a default of 1 for weight and 0 for extra credit
2198 //Flip from the default of 0 to 1 (or vice versa) if ALL items in the category are still set to the old default.
134c514b 2199 if (self::aggregation_uses_aggregationcoef($params->aggregation)) {
79b260cc 2200 $sql = $defaultaggregationcoef = null;
7ad5a627 2201
134c514b 2202 if (!self::aggregation_uses_extracredit($params->aggregation)) {
79b260cc
AD
2203 //if all items in this category have aggregation coefficient of 0 we can change it to 1 ie evenly weighted
2204 $sql = "select count(id) from {grade_items} where categoryid=:categoryid and aggregationcoef!=0";
2205 $defaultaggregationcoef = 1;
134c514b 2206 } else {
79b260cc
AD
2207 //if all items in this category have aggregation coefficient of 1 we can change it to 0 ie no extra credit
2208 $sql = "select count(id) from {grade_items} where categoryid=:categoryid and aggregationcoef!=1";
2209 $defaultaggregationcoef = 0;
2210 }
2211
2212 $params = array('categoryid'=>$instance->id);
2213 $count = $DB->count_records_sql($sql, $params);
2214 if ($count===0) { //category is either empty or all items are set to a default value so we can switch defaults
2215 $params['aggregationcoef'] = $defaultaggregationcoef;
2216 $DB->execute("update {grade_items} set aggregationcoef=:aggregationcoef where categoryid=:categoryid",$params);
2217 }
2218 }
2219 }
2220 }
2221
5fad5061 2222 /**
4a490db0 2223 * Sets the grade_item's hidden variable and updates the grade_item.
a153c9f2
AD
2224 *
2225 * Overrides grade_item::set_hidden() to add cascading of the hidden value to grade items in this grade category
2226 *
2227 * @param int $hidden 0 mean always visible, 1 means always hidden and a number > 1 is a timestamp to hide until
2228 * @param bool $cascade apply to child objects too
5fad5061 2229 */
da3801e8 2230 public function set_hidden($hidden, $cascade=false) {
5fad5061 2231 $this->load_grade_item();
a25bb902 2232 //this hides the associated grade item (the course total)
1762a264 2233 $this->grade_item->set_hidden($hidden, $cascade);
a25bb902
AD
2234 //this hides the category itself and everything it contains
2235 parent::set_hidden($hidden, $cascade);
b79fe189 2236
f60c61b1 2237 if ($cascade) {
b79fe189 2238
f3ac8eb4 2239 if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
b79fe189 2240
2241 foreach ($children as $child) {
39873128
TH
2242 if ($child->can_control_visibility()) {
2243 $child->set_hidden($hidden, $cascade);
2244 }
f60c61b1 2245 }
f13002d5 2246 }
b79fe189 2247
f3ac8eb4 2248 if ($children = grade_category::fetch_all(array('parent'=>$this->id))) {
b79fe189 2249
2250 foreach ($children as $child) {
f60c61b1 2251 $child->set_hidden($hidden, $cascade);
2252 }
f13002d5 2253 }
2254 }
d90aa634
AD
2255
2256 //if marking category visible make sure parent category is visible MDL-21367
93fad868 2257 if( !$hidden ) {
d90aa634
AD
2258 $category_array = grade_category::fetch_all(array('id'=>$this->parent));
2259 if ($category_array && array_key_exists($this->parent, $category_array)) {
2260 $category = $category_array[$this->parent];
93fad868
SH
2261 //call set_hidden on the category regardless of whether it is hidden as its parent might be hidden
2262 //if($category->is_hidden()) {
2263 $category->set_hidden($hidden, false);
2264 //}
d90aa634
AD
2265 }
2266 }
5fad5061 2267 }
89a5f827 2268
190af29f 2269 /**
2270 * Applies default settings on this category
a153c9f2
AD
2271 *
2272 * @return bool True if anything changed
190af29f 2273 */
da3801e8 2274 public function apply_default_settings() {
190af29f 2275 global $CFG;
2276
2277 foreach ($this->forceable as $property) {
b79fe189 2278
190af29f 2279 if (isset($CFG->{"grade_$property"})) {
b79fe189 2280
190af29f 2281 if ($CFG->{"grade_$property"} == -1) {
2282 continue; //temporary bc before version bump
2283 }
2284 $this->$property = $CFG->{"grade_$property"};
2285 }
2286 }
2287 }
2288
89a5f827 2289 /**
2290 * Applies forced settings on this category
a153c9f2
AD
2291 *
2292 * @return bool True if anything changed
89a5f827 2293 */
da3801e8 2294 public function apply_forced_settings() {
89a5f827 2295 global $CFG;
2296
2297 $updated = false;
b79fe189 2298
89a5f827 2299 foreach ($this->forceable as $property) {
b79fe189 2300
2301 if (isset($CFG->{"grade_$property"}) and isset($CFG->{"grade_{$property}_flag"}) and
2302 ((int) $CFG->{"grade_{$property}_flag"} & 1)) {
2303
190af29f 2304 if ($CFG->{"grade_$property"} == -1) {
2305 continue; //temporary bc before version bump
2306 }
89a5f827 2307 $this->$property = $CFG->{"grade_$property"};
2308 $updated = true;
2309 }
2310 }
2311
2312 return $updated;
2313 }
2314
2315 /**
2316 * Notification of change in forced category settings.
b79fe189 2317 *
a153c9f2 2318 * Causes all course and category grade items to be marked as needing to be updated
89a5f827 2319 */
da3801e8 2320 public static function updated_forced_settings() {
5b0af8c5 2321 global $CFG, $DB;
2322 $params = array(1, 'course', 'category');
2323 $sql = "UPDATE {grade_items} SET needsupdate=? WHERE itemtype=? or itemtype=?";
2324 $DB->execute($sql, $params);
89a5f827 2325 }
4a490db0 2326}