MDL-38147 Improvements to caching of course categories, coursecat::get_children(...
[moodle.git] / lib / coursecatlib.php
CommitLineData
b33389d2
MG
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Contains class coursecat reponsible for course category operations
19 *
20 * @package core
21 * @subpackage course
22 * @copyright 2013 Marina Glancy
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26defined('MOODLE_INTERNAL') || die();
27
28/**
29 * Class to store, cache, render and manage course category
30 *
31 * @package core
32 * @subpackage course
33 * @copyright 2013 Marina Glancy
34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35 */
36class coursecat implements renderable, cacheable_object, IteratorAggregate {
37 /** @var coursecat stores pseudo category with id=0. Use coursecat::get(0) to retrieve */
38 protected static $coursecat0;
39
40 /** @var array list of all fields and their short name and default value for caching */
41 protected static $coursecatfields = array(
42 'id' => array('id', 0),
43 'name' => array('na', ''),
44 'idnumber' => array('in', null),
15d50fff
MG
45 'description' => null, // not cached
46 'descriptionformat' => null, // not cached
b33389d2
MG
47 'parent' => array('pa', 0),
48 'sortorder' => array('so', 0),
15d50fff 49 'coursecount' => null, // not cached
b33389d2 50 'visible' => array('vi', 1),
15d50fff 51 'visibleold' => null, // not cached
b33389d2
MG
52 'timemodified' => null, // not cached
53 'depth' => array('dh', 1),
54 'path' => array('ph', null),
15d50fff 55 'theme' => null, // not cached
b33389d2
MG
56 );
57
58 /** @var int */
59 protected $id;
60
61 /** @var string */
62 protected $name = '';
63
64 /** @var string */
65 protected $idnumber = null;
66
67 /** @var string */
15d50fff 68 protected $description = false;
b33389d2
MG
69
70 /** @var int */
15d50fff 71 protected $descriptionformat = false;
b33389d2
MG
72
73 /** @var int */
74 protected $parent = 0;
75
76 /** @var int */
77 protected $sortorder = 0;
78
79 /** @var int */
15d50fff 80 protected $coursecount = false;
b33389d2
MG
81
82 /** @var int */
83 protected $visible = 1;
84
85 /** @var int */
15d50fff 86 protected $visibleold = false;
b33389d2
MG
87
88 /** @var int */
15d50fff 89 protected $timemodified = false;
b33389d2
MG
90
91 /** @var int */
92 protected $depth = 0;
93
94 /** @var string */
95 protected $path = '';
96
97 /** @var string */
15d50fff 98 protected $theme = false;
b33389d2
MG
99
100 /** @var bool */
101 protected $fromcache;
102
103 // ====== magic methods =======
104
105 /**
106 * Magic setter method, we do not want anybody to modify properties from the outside
107 * @param string $name
108 * @param mixed $value
109 */
110 public function __set($name, $value) {
111 debugging('Can not change coursecat instance properties!', DEBUG_DEVELOPER);
112 }
113
114 /**
15d50fff 115 * Magic method getter, redirects to read only values. Queries from DB the fields that were not cached
b33389d2
MG
116 * @param string $name
117 * @return mixed
118 */
119 public function __get($name) {
15d50fff 120 global $DB;
b33389d2 121 if (array_key_exists($name, self::$coursecatfields)) {
15d50fff
MG
122 if ($this->$name === false) {
123 // property was not retrieved from DB, retrieve all not retrieved fields
124 $notretrievedfields = array_diff(self::$coursecatfields, array_filter(self::$coursecatfields));
125 $record = $DB->get_record('course_categories', array('id' => $this->id),
126 join(',', array_keys($notretrievedfields)), MUST_EXIST);
127 foreach ($record as $key => $value) {
128 $this->$key = $value;
129 }
130 }
b33389d2
MG
131 return $this->$name;
132 }
133 debugging('Invalid coursecat property accessed! '.$name, DEBUG_DEVELOPER);
134 return null;
135 }
136
137 /**
138 * Full support for isset on our magic read only properties.
139 * @param string $name
140 * @return bool
141 */
142 public function __isset($name) {
143 if (array_key_exists($name, self::$coursecatfields)) {
144 return isset($this->$name);
145 }
146 return false;
147 }
148
149 /**
150 * ALl properties are read only, sorry.
151 * @param string $name
152 */
153 public function __unset($name) {
154 debugging('Can not unset coursecat instance properties!', DEBUG_DEVELOPER);
155 }
156
157 // ====== implementing method from interface IteratorAggregate ======
158
159 /**
160 * Create an iterator because magic vars can't be seen by 'foreach'.
161 */
162 public function getIterator() {
163 $ret = array();
164 foreach (self::$coursecatfields as $property => $unused) {
15d50fff
MG
165 if ($this->$property !== false) {
166 $ret[$property] = $this->$property;
167 }
b33389d2
MG
168 }
169 return new ArrayIterator($ret);
170 }
171
172 // ====== general coursecat methods ======
173
174 /**
175 * Constructor
176 *
177 * Constructor is protected, use coursecat::get($id) to retrieve category
178 *
179 * @param stdClass $record
180 */
181 protected function __construct(stdClass $record, $fromcache = false) {
182 context_instance_preload($record);
183 foreach ($record as $key => $val) {
184 if (array_key_exists($key, self::$coursecatfields)) {
185 $this->$key = $val;
186 }
187 }
188 $this->fromcache = $fromcache;
189 }
190
191 /**
192 * Returns coursecat object for requested category
193 *
194 * If category is not visible to user it is treated as non existing
15d50fff 195 * unless $alwaysreturnhidden is set to true
b33389d2
MG
196 *
197 * If id is 0, the pseudo object for root category is returned (convenient
198 * for calling other functions such as get_children())
199 *
200 * @param int $id category id
201 * @param int $strictness whether to throw an exception (MUST_EXIST) or
202 * return null (IGNORE_MISSING) in case the category is not found or
203 * not visible to current user
15d50fff 204 * @param bool $alwaysreturnhidden set to true if you want an object to be
b33389d2
MG
205 * returned even if this category is not visible to the current user
206 * (category is hidden and user does not have
207 * 'moodle/category:viewhiddencategories' capability). Use with care!
15d50fff 208 * @return null|coursecat
b33389d2 209 */
15d50fff 210 public static function get($id, $strictness = MUST_EXIST, $alwaysreturnhidden = false) {
b33389d2
MG
211 if (!$id) {
212 if (!isset(self::$coursecat0)) {
15d50fff
MG
213 $record = new stdClass();
214 $record->id = 0;
215 $record->visible = 1;
216 $record->depth = 0;
217 $record->path = '';
218 self::$coursecat0 = new coursecat($record);
b33389d2
MG
219 }
220 return self::$coursecat0;
221 }
eabbfa82
MG
222 $coursecatrecordcache = cache::make('core', 'coursecatrecords');
223 $coursecat = $coursecatrecordcache->get($id);
15d50fff 224 if ($coursecat === false) {
eabbfa82
MG
225 if ($records = self::get_records('cc.id = :id', array('id' => $id))) {
226 $record = reset($records);
227 $coursecat = new coursecat($record);
228 // Store in cache
229 $coursecatrecordcache->set($id, $coursecat);
b33389d2
MG
230 }
231 }
15d50fff 232 if ($coursecat && ($alwaysreturnhidden || $coursecat->is_uservisible())) {
b33389d2
MG
233 return $coursecat;
234 } else {
235 if ($strictness == MUST_EXIST) {
236 throw new moodle_exception('unknowcategory');
237 }
238 }
239 return null;
240 }
241
242 /**
243 * Returns the first found category
244 *
245 * Note that if there are no categories visible to the current user on the first level,
246 * the invisible category may be returned
247 *
248 * @return coursecat
249 */
250 public static function get_default() {
251 if ($visiblechildren = self::get(0)->get_children()) {
252 $defcategory = reset($visiblechildren);
253 } else {
eabbfa82
MG
254 $toplevelcategories = self::get_tree(0);
255 $defcategoryid = $toplevelcategories[0];
b33389d2
MG
256 $defcategory = self::get($defcategoryid, MUST_EXIST, true);
257 }
258 return $defcategory;
259 }
260
261 /**
262 * Restores the object after it has been externally modified in DB for example
263 * during {@link fix_course_sortorder()}
264 */
265 protected function restore() {
266 // update all fields in the current object
267 $newrecord = self::get($this->id, MUST_EXIST, true);
268 foreach (self::$coursecatfields as $key => $unused) {
269 $this->$key = $newrecord->$key;
270 }
271 }
272
273 /**
274 * Creates a new category either from form data or from raw data
275 *
276 * Please note that this function does not verify access control.
277 *
278 * Exception is thrown if name is missing or idnumber is duplicating another one in the system.
279 *
280 * Category visibility is inherited from parent unless $data->visible = 0 is specified
281 *
282 * @param array|stdClass $data
283 * @param array $editoroptions if specified, the data is considered to be
284 * form data and file_postupdate_standard_editor() is being called to
285 * process images in description.
286 * @return coursecat
287 * @throws moodle_exception
288 */
289 public static function create($data, $editoroptions = null) {
290 global $DB, $CFG;
291 $data = (object)$data;
292 $newcategory = new stdClass();
293
294 $newcategory->descriptionformat = FORMAT_MOODLE;
295 $newcategory->description = '';
296 // copy all description* fields regardless of whether this is form data or direct field update
297 foreach ($data as $key => $value) {
298 if (preg_match("/^description/", $key)) {
299 $newcategory->$key = $value;
300 }
301 }
302
303 if (empty($data->name)) {
304 throw new moodle_exception('categorynamerequired');
305 }
306 if (textlib::strlen($data->name) > 255) {
307 throw new moodle_exception('categorytoolong');
308 }
309 $newcategory->name = $data->name;
310
311 // validate and set idnumber
312 if (!empty($data->idnumber)) {
313 if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) {
314 throw new moodle_exception('categoryidnumbertaken');
315 }
316 if (textlib::strlen($data->idnumber) > 100) {
317 throw new moodle_exception('idnumbertoolong');
318 }
319 }
320 if (isset($data->idnumber)) {
321 $newcategory->idnumber = $data->idnumber;
322 }
323
324 if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
325 $newcategory->theme = $data->theme;
326 }
327
328 if (empty($data->parent)) {
329 $parent = self::get(0);
330 } else {
331 $parent = self::get($data->parent, MUST_EXIST, true);
332 }
333 $newcategory->parent = $parent->id;
334 $newcategory->depth = $parent->depth + 1;
335
336 // By default category is visible, unless visible = 0 is specified or parent category is hidden
337 if (isset($data->visible) && !$data->visible) {
338 // create a hidden category
339 $newcategory->visible = $newcategory->visibleold = 0;
340 } else {
341 // create a category that inherits visibility from parent
342 $newcategory->visible = $parent->visible;
343 // in case parent is hidden, when it changes visibility this new subcategory will automatically become visible too
344 $newcategory->visibleold = 1;
345 }
346
347 $newcategory->sortorder = 0;
348 $newcategory->timemodified = time();
349
350 $newcategory->id = $DB->insert_record('course_categories', $newcategory);
351
352 // update path (only possible after we know the category id
353 $path = $parent->path . '/' . $newcategory->id;
354 $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));
355
356 // We should mark the context as dirty
357 context_coursecat::instance($newcategory->id)->mark_dirty();
358
359 fix_course_sortorder();
360
361 // if this is data from form results, save embedded files and update description
362 $categorycontext = context_coursecat::instance($newcategory->id);
363 if ($editoroptions) {
364 $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
365
366 // update only fields description and descriptionformat
367 $updatedata = array_intersect_key((array)$newcategory, array('id' => 1, 'description' => 1, 'descriptionformat' => 1));
368 $DB->update_record('course_categories', $updatedata);
b33389d2
MG
369 }
370
371 add_to_log(SITEID, "category", 'add', "editcategory.php?id=$newcategory->id", $newcategory->id);
eabbfa82 372 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
373
374 return self::get($newcategory->id, MUST_EXIST, true);
375 }
376
377 /**
378 * Updates the record with either form data or raw data
379 *
380 * Please note that this function does not verify access control.
381 *
15d50fff
MG
382 * This function calls coursecat::change_parent_raw if field 'parent' is updated.
383 * It also calls coursecat::hide_raw or coursecat::show_raw if 'visible' is updated.
b33389d2
MG
384 * Visibility is changed first and then parent is changed. This means that
385 * if parent category is hidden, the current category will become hidden
386 * too and it may overwrite whatever was set in field 'visible'.
387 *
388 * Note that fields 'path' and 'depth' can not be updated manually
389 * Also coursecat::update() can not directly update the field 'sortoder'
390 *
391 * @param array|stdClass $data
392 * @param array $editoroptions if specified, the data is considered to be
393 * form data and file_postupdate_standard_editor() is being called to
394 * process images in description.
395 * @throws moodle_exception
396 */
397 public function update($data, $editoroptions = null) {
398 global $DB, $CFG;
399 if (!$this->id) {
400 // there is no actual DB record associated with root category
401 return;
402 }
403
404 $data = (object)$data;
405 $newcategory = new stdClass();
406 $newcategory->id = $this->id;
407
408 // copy all description* fields regardless of whether this is form data or direct field update
409 foreach ($data as $key => $value) {
410 if (preg_match("/^description/", $key)) {
411 $newcategory->$key = $value;
412 }
413 }
414
415 if (isset($data->name) && empty($data->name)) {
416 throw new moodle_exception('categorynamerequired');
417 }
418
419 if (!empty($data->name) && $data->name !== $this->name) {
420 if (textlib::strlen($data->name) > 255) {
421 throw new moodle_exception('categorytoolong');
422 }
423 $newcategory->name = $data->name;
424 }
425
426 if (isset($data->idnumber) && $data->idnumber != $this->idnumber) {
427 if (textlib::strlen($data->idnumber) > 100) {
428 throw new moodle_exception('idnumbertoolong');
429 }
430 if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) {
431 throw new moodle_exception('categoryidnumbertaken');
432 }
433 $newcategory->idnumber = $data->idnumber;
434 }
435
436 if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
437 $newcategory->theme = $data->theme;
438 }
439
440 $changes = false;
441 if (isset($data->visible)) {
442 if ($data->visible) {
15d50fff 443 $changes = $this->show_raw();
b33389d2 444 } else {
15d50fff 445 $changes = $this->hide_raw(0);
b33389d2
MG
446 }
447 }
448
449 if (isset($data->parent) && $data->parent != $this->parent) {
450 if ($changes) {
eabbfa82 451 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
452 }
453 $parentcat = self::get($data->parent, MUST_EXIST, true);
15d50fff 454 $this->change_parent_raw($parentcat);
b33389d2
MG
455 fix_course_sortorder();
456 }
457
458 $newcategory->timemodified = time();
459
460 if ($editoroptions) {
461 $categorycontext = context_coursecat::instance($this->id);
462 $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
463 }
464 $DB->update_record('course_categories', $newcategory);
465 add_to_log(SITEID, "category", 'update', "editcategory.php?id=$this->id", $this->id);
466 fix_course_sortorder();
eabbfa82
MG
467 // purge cache even if fix_course_sortorder() did not do it
468 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
469
470 // update all fields in the current object
471 $this->restore();
472 }
473
474 /**
475 * Checks if this course category is visible to current user
476 *
477 * Please note that methods coursecat::get (without 3rd argumet),
478 * coursecat::get_children(), etc. return only visible categories so it is
479 * usually not needed to call this function outside of this class
480 *
481 * @return bool
482 */
483 public function is_uservisible() {
484 return !$this->id || $this->visible ||
485 has_capability('moodle/category:viewhiddencategories',
486 context_coursecat::instance($this->id));
487 }
488
489 /**
490 * Returns all categories visible to the current user
491 *
492 * This is a generic function that returns an array of
493 * (category id => coursecat object) sorted by sortorder
494 *
495 * @see coursecat::get_children()
496 * @see coursecat::get_all_parents()
497 *
498 * @return cacheable_object_array array of coursecat objects
499 */
500 public static function get_all_visible() {
501 global $USER;
502 $coursecatcache = cache::make('core', 'coursecat');
503 $ids = $coursecatcache->get('user'. $USER->id);
504 if ($ids === false) {
505 $all = self::get_all_ids();
506 $parentvisible = $all[0];
507 $rv = array();
508 foreach ($all as $id => $children) {
509 if ($id && in_array($id, $parentvisible) &&
510 ($coursecat = self::get($id, IGNORE_MISSING)) &&
511 (!$coursecat->parent || isset($rv[$coursecat->parent]))) {
512 $rv[$id] = $coursecat;
513 $parentvisible += $children;
514 }
515 }
516 $coursecatcache->set('user'. $USER->id, array_keys($rv));
517 } else {
518 $rv = array();
519 foreach ($ids as $id) {
520 if ($coursecat = self::get($id, IGNORE_MISSING)) {
521 $rv[$id] = $coursecat;
522 }
523 }
524 }
525 return $rv;
526 }
527
528 /**
eabbfa82 529 * Returns the entry from categories tree and makes sure the application-level tree cache is built
b33389d2 530 *
eabbfa82 531 * The following keys can be requested:
b33389d2 532 *
eabbfa82
MG
533 * 'countall' - total number of categories in the system (always present)
534 * 0 - array of ids of top-level categories (always present)
535 * '0i' - array of ids of top-level categories that have visible=0 (always present but may be empty array)
536 * $id (int) - array of ids of categories that are direct children of category with id $id. If
537 * category with id $id does not exist returns false. If category has no children returns empty array
538 * $id.'i' - array of ids of children categories that have visible=0
539 *
540 * @param int|string $id
541 * @return mixed
b33389d2 542 */
eabbfa82 543 protected static function get_tree($id) {
b33389d2 544 global $DB;
eabbfa82
MG
545 $coursecattreecache = cache::make('core', 'coursecattree');
546 $rv = $coursecattreecache->get($id);
547 if ($rv !== false) {
548 return $rv;
549 }
550 // We did not find the entry in cache but it also can mean that tree is not built.
551 // The keys 0 and 'countall' must always be present if tree is built.
552 if ($id !== 0 && $id !== 'countall' && $coursecattreecache->has('countall')) {
553 // Tree was built, it means the non-existing $id was requested.
554 return false;
555 }
556 // Re-build the tree.
557 $sql = "SELECT cc.id, cc.parent, cc.visible
558 FROM {course_categories} cc
559 ORDER BY cc.sortorder";
560 $rs = $DB->get_recordset_sql($sql, array());
561 $all = array(0 => array(), '0i' => array());
562 $count = 0;
563 foreach ($rs as $record) {
564 $all[$record->id] = array();
565 $all[$record->id. 'i'] = array();
566 if (array_key_exists($record->parent, $all)) {
b33389d2 567 $all[$record->parent][] = $record->id;
eabbfa82
MG
568 if (!$record->visible) {
569 $all[$record->parent. 'i'][] = $record->id;
570 }
571 } else {
572 // parent not found. This is data consistency error but next fix_course_sortorder() should fix it
573 $all[0][] = $record->id;
b33389d2 574 }
eabbfa82
MG
575 $count++;
576 }
577 $rs->close();
578 if (!$count) {
579 // No categories found.
580 // This may happen after upgrade from very old moodle version. In new versions the default category is created on install.
581 $defcoursecat = self::create(array('name' => get_string('miscellaneous')));
582 set_config('defaultrequestcategory', $defcoursecat->id);
583 $all[0] = array($defcoursecat->id);
584 $all[$defcoursecat->id] = array();
585 $count++;
b33389d2 586 }
eabbfa82
MG
587 foreach ($all as $key => $children) {
588 $coursecattreecache->set($key, $children);
589 }
590 $coursecattreecache->set('countall', $count);
591 if (array_key_exists($id, $all)) {
592 return $all[$id];
593 }
594 return false;
b33389d2
MG
595 }
596
b33389d2
MG
597 /**
598 * Returns number of ALL categories in the system regardless if
599 * they are visible to current user or not
600 *
601 * @return int
602 */
15d50fff 603 public static function count_all() {
eabbfa82
MG
604 return self::get_tree('countall');
605 }
606
607 /**
608 * Retrieves number of records from course_categories table
609 *
610 * Only cached fields are retrieved. Records are ready for preloading context
611 *
612 * @param string $whereclause
613 * @param array $params
614 * @return array array of stdClass objects
615 */
616 protected static function get_records($whereclause, $params) {
617 global $DB;
618 // Retrieve from DB only the fields that need to be stored in cache
619 $fields = array_filter(array_keys(self::$coursecatfields), function ($element)
620 { return (self::$coursecatfields[$element] !== null); } );
621 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
622 $sql = "SELECT cc.". join(',cc.', $fields). ", $ctxselect
623 FROM {course_categories} cc
624 JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
625 WHERE ". $whereclause." ORDER BY cc.sortorder";
626 return $DB->get_records_sql($sql,
627 array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
628 }
629
630 /**
631 * Returns array of ids of children categories that current user can not see
632 *
633 * This data is cached in user session cache
634 *
635 * @return array
636 */
637 protected function get_not_visible_children_ids() {
638 global $DB;
639 $coursecatcache = cache::make('core', 'coursecat');
640 if (($invisibleids = $coursecatcache->get('ic'. $this->id)) === false) {
641 // we never checked visible children before
642 $hidden = self::get_tree($this->id.'i');
643 $invisibleids = array();
644 if ($hidden) {
645 // preload categories contexts
646 list($sql, $params) = $DB->get_in_or_equal($hidden, SQL_PARAMS_NAMED, 'id');
647 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
648 $contexts = $DB->get_records_sql("SELECT $ctxselect FROM {context} ctx
649 WHERE ctx.contextlevel = :contextcoursecat AND ctx.instanceid ".$sql,
650 array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
651 foreach ($contexts as $record) {
652 context_helper::preload_from_record($record);
653 }
654 // check that user has 'viewhiddencategories' capability for each hidden category
655 foreach ($hidden as $id) {
656 if (!has_capability('moodle/category:viewhiddencategories', context_coursecat::instance($id))) {
657 $invisibleids[] = $id;
658 }
659 }
660 }
661 $coursecatcache->set('ic'. $this->id, $invisibleids);
662 }
663 return $invisibleids;
664 }
665
666 /**
667 * Compares two records. For use in uasort()
668 *
669 * @param stdClass $a
670 * @param stdClass $b
671 * @param array $sortfields assoc array where key is the field to sort and value is 1 for asc or -1 for desc
672 * @return int
673 */
674 protected static function compare_records($a, $b, $sortfields) {
675 foreach ($sortfields as $field => $mult) {
676 if ($field === 'name' || $field === 'idnumber' || $field === 'path') {
677 // string fields
678 if ($cmp = strcmp($a->$field, $b->$field)) {
679 // TODO textlib?
680 return $mult * $cmp;
681 }
682 } else {
683 // int fields
684 if ($a->$field > $b->$field) {
685 return $mult;
686 }
687 if ($a->$field < $b->$field) {
688 return -$mult;
689 }
690 }
691 }
692 return 0;
b33389d2
MG
693 }
694
695 /**
696 * Returns array of children categories visible to the current user
697 *
eabbfa82
MG
698 * @param array $options options for retrieving children
699 * - sort - list of fields to sort. Example
700 * array('idnumber' => 1, 'name' => 1, 'id' => -1)
701 * will sort by idnumber asc, name asc and id desc.
702 * Default: array('sortorder' => 1)
703 * Only cached fields may be used for sorting!
704 * - offset
705 * - limit - maximum number of children to return, 0 or null for no limit
b33389d2
MG
706 * @return array of coursecat objects indexed by category id
707 */
eabbfa82
MG
708 public function get_children($options = array()) {
709 global $DB;
710 $coursecatcache = cache::make('core', 'coursecat');
711
712 // get default values for options
713 if (!empty($options['sort']) && is_array($options['sort'])) {
714 $sortfields = $options['sort'];
715 } else {
716 $sortfields = array('sortorder' => 1);
717 }
718 $limit = null;
719 if (!empty($options['limit']) && (int)$options['limit']) {
720 $limit = (int)$options['limit'];
721 }
722 $offset = 0;
723 if (!empty($options['offset']) && (int)$options['offset']) {
724 $offset = (int)$options['offset'];
725 }
726
727 // first retrieve list of user-visible and sorted children ids from cache
728 $sortedids = $coursecatcache->get('c'. $this->id. ':'. serialize($sortfields));
729 if ($sortedids === false) {
730 $sortfieldskeys = array_keys($sortfields);
731 if ($sortfieldskeys[0] === 'sortorder') {
732 // no DB requests required to build the list of ids sorted by sortorder.
733 // We can easily ignore other sort fields because sortorder is always different
734 $sortedids = self::get_tree($this->id);
735 if ($sortedids && ($invisibleids = $this->get_not_visible_children_ids())) {
736 $sortedids = array_diff($sortedids, $invisibleids);
737 if ($sortfields['sortorder'] == -1) {
738 $sortedids = array_reverse($sortedids, true);
739 }
b33389d2 740 }
eabbfa82
MG
741 } else {
742 // we need to retrieve and sort all children. Good thing that it is done only on first request
743 if ($invisibleids = $this->get_not_visible_children_ids()) {
744 list($sql, $params) = $DB->get_in_or_equal($invisibleids, SQL_PARAMS_NAMED, 'id', false);
745 $records = self::get_records('cc.parent = :parent AND cc.id '. $sql,
746 array('parent' => $this->id) + $params);
747 } else {
748 $records = self::get_records('cc.parent = :parent', array('parent' => $this->id));
749 }
750 uasort($records, function ($a, $b) use ($sortfields) { return self::compare_records($a, $b, $sortfields); });
751 $sortedids = array_keys($records);
752 }
753 $coursecatcache->set('c'. $this->id. ':'.serialize($sortfields), $sortedids);
754 }
755
756 if (empty($sortedids)) {
757 return array();
758 }
759
760 // now retrieive and return categories
761 if ($offset || $limit) {
762 $sortedids = array_slice($sortedids, $offset, $limit);
763 }
764 if (isset($records)) {
765 // easy, we have already retrieved records
766 if ($offset || $limit) {
767 $records = array_slice($records, $offset, $limit, true);
768 }
769 } else {
770 list($sql, $params) = $DB->get_in_or_equal($sortedids, SQL_PARAMS_NAMED, 'id');
771 $records = self::get_records('cc.id '. $sql,
772 array('parent' => $this->id) + $params);
773 }
774
775 $rv = array();
776 foreach ($sortedids as $id) {
777 if (isset($records[$id])) {
778 $rv[$id] = new coursecat($records[$id]);
b33389d2
MG
779 }
780 }
781 return $rv;
782 }
783
eabbfa82
MG
784 /**
785 * Returns number of subcategories visible to the current user
786 *
787 * @return int
788 */
789 public function get_children_count() {
790 $sortedids = self::get_tree($this->id);
791 $invisibleids = $this->get_not_visible_children_ids();
792 return count($sortedids) - count($invisibleids);
793 }
794
b33389d2
MG
795 /**
796 * Returns true if the category has ANY children, including those not visible to the user
797 *
798 * @return boolean
799 */
800 public function has_children() {
eabbfa82
MG
801 $allchildren = self::get_tree($this->id);
802 return !empty($allchildren);
b33389d2
MG
803 }
804
805 /**
806 * Returns true if the category has courses in it (count does not include courses
807 * in child categories)
808 *
809 * @return bool
810 */
811 public function has_courses() {
812 global $DB;
813 return $DB->record_exists_sql("select 1 from {course} where category = ?",
814 array($this->id));
815 }
816
817 /**
818 * Returns true if user can delete current category and all its contents
819 *
820 * To be able to delete course category the user must have permission
821 * 'moodle/category:manage' in ALL child course categories AND
822 * be able to delete all courses
823 *
824 * @return bool
825 */
826 public function can_delete_full() {
827 global $DB;
828 if (!$this->id) {
829 // fool-proof
830 return false;
831 }
832
833 $context = context_coursecat::instance($this->id);
834 if (!$this->is_uservisible() ||
835 !has_capability('moodle/category:manage', $context)) {
836 return false;
837 }
838
15d50fff
MG
839 // Check all child categories (not only direct children)
840 $sql = context_helper::get_preload_record_columns_sql('ctx');
841 $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
842 ' FROM {context} ctx '.
843 ' JOIN {course_categories} c ON c.id = ctx.instanceid'.
844 ' WHERE ctx.path like ? AND ctx.contextlevel = ?',
845 array($context->path. '/%', CONTEXT_COURSECAT));
846 foreach ($childcategories as $childcat) {
847 context_helper::preload_from_record($childcat);
848 $childcontext = context_coursecat::instance($childcat->id);
849 if ((!$childcat->visible && !has_capability('moodle/category:viewhiddencategories', $childcontext)) ||
850 !has_capability('moodle/category:manage', $childcontext)) {
851 return false;
b33389d2
MG
852 }
853 }
854
855 // Check courses
15d50fff
MG
856 $sql = context_helper::get_preload_record_columns_sql('ctx');
857 $coursescontexts = $DB->get_records_sql('SELECT ctx.instanceid AS courseid, '.
858 $sql. ' FROM {context} ctx '.
859 'WHERE ctx.path like :pathmask and ctx.contextlevel = :courselevel',
b33389d2
MG
860 array('pathmask' => $context->path. '/%',
861 'courselevel' => CONTEXT_COURSE));
15d50fff
MG
862 foreach ($coursescontexts as $ctxrecord) {
863 context_helper::preload_from_record($ctxrecord);
864 if (!can_delete_course($ctxrecord->courseid)) {
b33389d2
MG
865 return false;
866 }
867 }
868
869 return true;
870 }
871
872 /**
873 * Recursively delete category including all subcategories and courses
874 *
875 * Function {@link coursecat::can_delete_full()} MUST be called prior
876 * to calling this function because there is no capability check
877 * inside this function
878 *
879 * @param boolean $showfeedback display some notices
880 * @return array return deleted courses
881 */
15d50fff 882 public function delete_full($showfeedback = true) {
b33389d2
MG
883 global $CFG, $DB;
884 require_once($CFG->libdir.'/gradelib.php');
885 require_once($CFG->libdir.'/questionlib.php');
886 require_once($CFG->dirroot.'/cohort/lib.php');
887
888 $deletedcourses = array();
889
890 // Get children. Note, we don't want to use cache here because
891 // it would be rebuilt too often
892 $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC');
893 foreach ($children as $record) {
894 $coursecat = new coursecat($record);
895 $deletedcourses += $coursecat->delete_full($showfeedback);
896 }
897
898 if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) {
899 foreach ($courses as $course) {
900 if (!delete_course($course, false)) {
901 throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname);
902 }
903 $deletedcourses[] = $course;
904 }
905 }
906
907 // move or delete cohorts in this context
908 cohort_delete_category($this);
909
910 // now delete anything that may depend on course category context
911 grade_course_category_delete($this->id, 0, $showfeedback);
912 if (!question_delete_course_category($this, 0, $showfeedback)) {
913 throw new moodle_exception('cannotdeletecategoryquestions', '', '', $this->get_formatted_name());
914 }
915
916 // finally delete the category and it's context
917 $DB->delete_records('course_categories', array('id' => $this->id));
918 delete_context(CONTEXT_COURSECAT, $this->id);
919 add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");
920
eabbfa82 921 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
922
923 events_trigger('course_category_deleted', $this);
924
925 // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
926 if ($this->id == $CFG->defaultrequestcategory) {
927 set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
928 }
929 return $deletedcourses;
930 }
931
932 /**
933 * Checks if user can delete this category and move content (courses, subcategories and questions)
934 * to another category. If yes returns the array of possible target categories names
935 *
936 * If user can not manage this category or it is completely empty - empty array will be returned
937 *
938 * @return array
939 */
940 public function move_content_targets_list() {
941 global $CFG;
942 require_once($CFG->libdir . '/questionlib.php');
943 $context = context_coursecat::instance($this->id);
944 if (!$this->is_uservisible() ||
945 !has_capability('moodle/category:manage', $context)) {
946 // User is not able to manage current category, he is not able to delete it.
947 // No possible target categories.
948 return array();
949 }
950
951 $testcaps = array();
952 // If this category has courses in it, user must have 'course:create' capability in target category.
953 if ($this->has_courses()) {
954 $testcaps[] = 'moodle/course:create';
955 }
956 // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
957 if ($this->has_children() || question_context_has_any_questions($context)) {
958 $testcaps[] = 'moodle/category:manage';
959 }
960 if (!empty($testcaps)) {
961 // return list of categories excluding this one and it's children
962 return self::make_categories_list($testcaps, $this->id);
963 }
964
965 // Category is completely empty, no need in target for contents.
966 return array();
967 }
968
969 /**
970 * Checks if user has capability to move all category content to the new parent before
971 * removing this category
972 *
973 * @param int $newcatid
974 * @return bool
975 */
976 public function can_move_content_to($newcatid) {
977 global $CFG;
978 require_once($CFG->libdir . '/questionlib.php');
979 $context = context_coursecat::instance($this->id);
980 if (!$this->is_uservisible() ||
981 !has_capability('moodle/category:manage', $context)) {
982 return false;
983 }
984 $testcaps = array();
985 // If this category has courses in it, user must have 'course:create' capability in target category.
986 if ($this->has_courses()) {
987 $testcaps[] = 'moodle/course:create';
988 }
989 // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
990 if ($this->has_children() || question_context_has_any_questions($context)) {
991 $testcaps[] = 'moodle/category:manage';
992 }
993 if (!empty($testcaps)) {
994 return has_all_capabilities($testcaps, context_coursecat::instance($newcatid));
995 }
996
997 // there is no content but still return true
998 return true;
999 }
1000
1001 /**
1002 * Deletes a category and moves all content (children, courses and questions) to the new parent
1003 *
1004 * Note that this function does not check capabilities, {@link coursecat::can_move_content_to()}
1005 * must be called prior
1006 *
1007 * @param int $newparentid
1008 * @param bool $showfeedback
1009 * @return bool
1010 */
1011 public function delete_move($newparentid, $showfeedback = false) {
1012 global $CFG, $DB, $OUTPUT;
1013 require_once($CFG->libdir.'/gradelib.php');
1014 require_once($CFG->libdir.'/questionlib.php');
1015 require_once($CFG->dirroot.'/cohort/lib.php');
1016
1017 // get all objects and lists because later the caches will be reset so
1018 // we don't need to make extra queries
1019 $newparentcat = self::get($newparentid, MUST_EXIST, true);
1020 $catname = $this->get_formatted_name();
1021 $children = $this->get_children();
1022 $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', array('category' => $this->id));
1023 $context = context_coursecat::instance($this->id);
1024
1025 if ($children) {
1026 foreach ($children as $childcat) {
15d50fff 1027 $childcat->change_parent_raw($newparentcat);
b33389d2
MG
1028 // Log action.
1029 add_to_log(SITEID, "category", "move", "editcategory.php?id=$childcat->id", $childcat->id);
1030 }
1031 fix_course_sortorder();
1032 }
1033
1034 if ($coursesids) {
1035 if (!move_courses($coursesids, $newparentid)) {
1036 if ($showfeedback) {
1037 echo $OUTPUT->notification("Error moving courses");
1038 }
1039 return false;
1040 }
1041 if ($showfeedback) {
1042 echo $OUTPUT->notification(get_string('coursesmovedout', '', $catname), 'notifysuccess');
1043 }
1044 }
1045
1046 // move or delete cohorts in this context
1047 cohort_delete_category($this);
1048
1049 // now delete anything that may depend on course category context
1050 grade_course_category_delete($this->id, $newparentid, $showfeedback);
1051 if (!question_delete_course_category($this, $newparentcat, $showfeedback)) {
1052 if ($showfeedback) {
1053 echo $OUTPUT->notification(get_string('errordeletingquestionsfromcategory', 'question', $catname), 'notifysuccess');
1054 }
1055 return false;
1056 }
1057
1058 // finally delete the category and it's context
1059 $DB->delete_records('course_categories', array('id' => $this->id));
1060 $context->delete();
1061 add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");
1062
1063 events_trigger('course_category_deleted', $this);
1064
eabbfa82 1065 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
1066
1067 if ($showfeedback) {
1068 echo $OUTPUT->notification(get_string('coursecategorydeleted', '', $catname), 'notifysuccess');
1069 }
1070
1071 // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
1072 if ($this->id == $CFG->defaultrequestcategory) {
1073 set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
1074 }
1075 return true;
1076 }
1077
1078 /**
1079 * Checks if user can move current category to the new parent
1080 *
1081 * This checks if new parent category exists, user has manage cap there
1082 * and new parent is not a child of this category
1083 *
1084 * @param int|stdClass|coursecat $newparentcat
1085 * @return bool
1086 */
1087 public function can_change_parent($newparentcat) {
1088 if (!has_capability('moodle/category:manage', context_coursecat::instance($this->id))) {
1089 return false;
1090 }
1091 if (is_object($newparentcat)) {
1092 $newparentcat = self::get($newparentcat->id, IGNORE_MISSING);
1093 } else {
1094 $newparentcat = self::get((int)$newparentcat, IGNORE_MISSING);
1095 }
1096 if (!$newparentcat) {
1097 return false;
1098 }
15d50fff 1099 if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
b33389d2
MG
1100 // can not move to itself or it's own child
1101 return false;
1102 }
1103 return has_capability('moodle/category:manage', get_category_or_system_context($newparentcat->id));
1104 }
1105
1106 /**
1107 * Moves the category under another parent category. All associated contexts are moved as well
1108 *
1109 * This is protected function, use change_parent() or update() from outside of this class
1110 *
1111 * @see coursecat::change_parent()
1112 * @see coursecat::update()
1113 *
1114 * @param coursecat $newparentcat
1115 */
15d50fff 1116 protected function change_parent_raw(coursecat $newparentcat) {
b33389d2
MG
1117 global $DB;
1118
1119 $context = context_coursecat::instance($this->id);
1120
1121 $hidecat = false;
1122 if (empty($newparentcat->id)) {
1123 $DB->set_field('course_categories', 'parent', 0, array('id' => $this->id));
1124 $newparent = context_system::instance();
1125 } else {
15d50fff 1126 if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
b33389d2
MG
1127 // can not move to itself or it's own child
1128 throw new moodle_exception('cannotmovecategory');
1129 }
1130 $DB->set_field('course_categories', 'parent', $newparentcat->id, array('id' => $this->id));
1131 $newparent = context_coursecat::instance($newparentcat->id);
1132
1133 if (!$newparentcat->visible and $this->visible) {
1134 // better hide category when moving into hidden category, teachers may unhide afterwards and the hidden children will be restored properly
1135 $hidecat = true;
1136 }
1137 }
1138 $this->parent = $newparentcat->id;
1139
1140 context_moved($context, $newparent);
1141
1142 // now make it last in new category
1143 $DB->set_field('course_categories', 'sortorder', MAX_COURSES_IN_CATEGORY*MAX_COURSE_CATEGORIES, array('id' => $this->id));
1144
1145 if ($hidecat) {
1146 fix_course_sortorder();
1147 $this->restore();
1148 // Hide object but store 1 in visibleold, because when parent category visibility changes this category must become visible again.
15d50fff 1149 $this->hide_raw(1);
b33389d2
MG
1150 }
1151 }
1152
1153 /**
1154 * Efficiently moves a category - NOTE that this can have
1155 * a huge impact access-control-wise...
1156 *
1157 * Note that this function does not check capabilities.
1158 *
1159 * Example of usage:
1160 * $coursecat = coursecat::get($categoryid);
1161 * if ($coursecat->can_change_parent($newparentcatid)) {
1162 * $coursecat->change_parent($newparentcatid);
1163 * }
1164 *
1165 * This function does not update field course_categories.timemodified
1166 * If you want to update timemodified, use
1167 * $coursecat->update(array('parent' => $newparentcat));
1168 *
1169 * @param int|stdClass|coursecat $newparentcat
1170 */
1171 public function change_parent($newparentcat) {
1172 // Make sure parent category exists but do not check capabilities here that it is visible to current user.
1173 if (is_object($newparentcat)) {
1174 $newparentcat = self::get($newparentcat->id, MUST_EXIST, true);
1175 } else {
1176 $newparentcat = self::get((int)$newparentcat, MUST_EXIST, true);
1177 }
1178 if ($newparentcat->id != $this->parent) {
15d50fff 1179 $this->change_parent_raw($newparentcat);
b33389d2 1180 fix_course_sortorder();
eabbfa82 1181 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
1182 $this->restore();
1183 add_to_log(SITEID, "category", "move", "editcategory.php?id=$this->id", $this->id);
1184 }
1185 }
1186
1187 /**
1188 * Hide course category and child course and subcategories
1189 *
1190 * If this category has changed the parent and is moved under hidden
1191 * category we will want to store it's current visibility state in
1192 * the field 'visibleold'. If admin clicked 'hide' for this particular
1193 * category, the field 'visibleold' should become 0.
1194 *
1195 * All subcategories and courses will have their current visibility in the field visibleold
1196 *
1197 * This is protected function, use hide() or update() from outside of this class
1198 *
1199 * @see coursecat::hide()
1200 * @see coursecat::update()
1201 *
1202 * @param int $visibleold value to set in field $visibleold for this category
1203 * @return bool whether changes have been made and caches need to be purged afterwards
1204 */
15d50fff 1205 protected function hide_raw($visibleold = 0) {
b33389d2
MG
1206 global $DB;
1207 $changes = false;
1208
15d50fff
MG
1209 // Note that field 'visibleold' is not cached so we must retrieve it from DB if it is missing
1210 if ($this->id && $this->__get('visibleold') != $visibleold) {
b33389d2
MG
1211 $this->visibleold = $visibleold;
1212 $DB->set_field('course_categories', 'visibleold', $visibleold, array('id' => $this->id));
1213 $changes = true;
1214 }
1215 if (!$this->visible || !$this->id) {
1216 // already hidden or can not be hidden
1217 return $changes;
1218 }
1219
1220 $this->visible = 0;
1221 $DB->set_field('course_categories', 'visible', 0, array('id'=>$this->id));
1222 $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($this->id)); // store visible flag so that we can return to it if we immediately unhide
1223 $DB->set_field('course', 'visible', 0, array('category' => $this->id));
1224 // get all child categories and hide too
1225 if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visible')) {
1226 foreach ($subcats as $cat) {
1227 $DB->set_field('course_categories', 'visibleold', $cat->visible, array('id' => $cat->id));
1228 $DB->set_field('course_categories', 'visible', 0, array('id' => $cat->id));
1229 $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($cat->id));
1230 $DB->set_field('course', 'visible', 0, array('category' => $cat->id));
1231 }
1232 }
1233 return true;
1234 }
1235
1236 /**
1237 * Hide course category and child course and subcategories
1238 *
1239 * Note that there is no capability check inside this function
1240 *
1241 * This function does not update field course_categories.timemodified
1242 * If you want to update timemodified, use
1243 * $coursecat->update(array('visible' => 0));
1244 */
1245 public function hide() {
15d50fff 1246 if ($this->hide_raw(0)) {
eabbfa82 1247 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
1248 add_to_log(SITEID, "category", "hide", "editcategory.php?id=$this->id", $this->id);
1249 }
1250 }
1251
1252 /**
1253 * Show course category and restores visibility for child course and subcategories
1254 *
1255 * Note that there is no capability check inside this function
1256 *
1257 * This is protected function, use show() or update() from outside of this class
1258 *
1259 * @see coursecat::show()
1260 * @see coursecat::update()
1261 *
1262 * @return bool whether changes have been made and caches need to be purged afterwards
1263 */
15d50fff 1264 protected function show_raw() {
b33389d2
MG
1265 global $DB;
1266
1267 if ($this->visible) {
1268 // already visible
1269 return false;
1270 }
1271
1272 $this->visible = 1;
1273 $this->visibleold = 1;
1274 $DB->set_field('course_categories', 'visible', 1, array('id' => $this->id));
1275 $DB->set_field('course_categories', 'visibleold', 1, array('id' => $this->id));
1276 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($this->id));
1277 // get all child categories and unhide too
1278 if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visibleold')) {
1279 foreach ($subcats as $cat) {
1280 if ($cat->visibleold) {
1281 $DB->set_field('course_categories', 'visible', 1, array('id' => $cat->id));
1282 }
1283 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($cat->id));
1284 }
1285 }
1286 return true;
1287 }
1288
1289 /**
1290 * Show course category and restores visibility for child course and subcategories
1291 *
1292 * Note that there is no capability check inside this function
1293 *
1294 * This function does not update field course_categories.timemodified
1295 * If you want to update timemodified, use
1296 * $coursecat->update(array('visible' => 1));
1297 */
1298 public function show() {
15d50fff 1299 if ($this->show_raw()) {
eabbfa82 1300 cache_helper::purge_by_event('changesincoursecat');
b33389d2
MG
1301 add_to_log(SITEID, "category", "show", "editcategory.php?id=$this->id", $this->id);
1302 }
1303 }
1304
1305 /**
1306 * Returns name of the category formatted as a string
1307 *
1308 * @param array $options formatting options other than context
1309 * @return string
1310 */
1311 public function get_formatted_name($options = array()) {
1312 if ($this->id) {
1313 $context = context_coursecat::instance($this->id);
1314 return format_string($this->name, true, array('context' => $context) + $options);
1315 } else {
1316 return ''; // TODO 'Top'?
1317 }
1318 }
1319
1320 /**
15d50fff 1321 * Returns ids of all parents of the category. Last element in the return array is the direct parent
b33389d2
MG
1322 *
1323 * For example, if you have a tree of categories like:
1324 * Miscellaneous (id = 1)
1325 * Subcategory (id = 2)
1326 * Sub-subcategory (id = 4)
1327 * Other category (id = 3)
1328 *
15d50fff
MG
1329 * coursecat::get(1)->get_parents() == array()
1330 * coursecat::get(2)->get_parents() == array(1)
1331 * coursecat::get(4)->get_parents() == array(1, 2);
b33389d2
MG
1332 *
1333 * Note that this method does not check if all parents are accessible by current user
1334 *
15d50fff 1335 * @return array of category ids
b33389d2 1336 */
15d50fff
MG
1337 public function get_parents() {
1338 $parents = preg_split('|/|', $this->path, 0, PREG_SPLIT_NO_EMPTY);
1339 array_pop($parents);
1340 return $parents;
b33389d2
MG
1341 }
1342
1343 /**
1344 * This function recursively travels the categories, building up a nice list
1345 * for display or to use in a form <select> element
1346 *
1347 * For example, if you have a tree of categories like:
1348 * Miscellaneous (id = 1)
1349 * Subcategory (id = 2)
1350 * Sub-subcategory (id = 4)
1351 * Other category (id = 3)
1352 * Then after calling this function you will have
1353 * array(1 => 'Miscellaneous',
1354 * 2 => 'Miscellaneous / Subcategory',
1355 * 4 => 'Miscellaneous / Subcategory / Sub-subcategory',
1356 * 3 => 'Other category');
1357 *
1358 * If you specify $requiredcapability, then only categories where the current
1359 * user has that capability will be added to $list.
1360 * If you only have $requiredcapability in a child category, not the parent,
1361 * then the child catgegory will still be included.
1362 *
1363 * If you specify the option $excludeid, then that category, and all its children,
1364 * are omitted from the tree. This is useful when you are doing something like
1365 * moving categories, where you do not want to allow people to move a category
1366 * to be the child of itself.
1367 *
1368 * See also {@link make_categories_options()}
1369 *
1370 * @param string/array $requiredcapability if given, only categories where the current
1371 * user has this capability will be returned. Can also be an array of capabilities,
1372 * in which case they are all required.
1373 * @param integer $excludeid Exclude this category and its children from the lists built.
1374 * @param string $separator string to use as a separator between parent and child category. Default ' / '
1375 * @return array of strings
1376 */
1377 public static function make_categories_list($requiredcapability = '', $excludeid = 0, $separator = ' / ') {
1378 return self::get(0)->get_children_names($requiredcapability, $excludeid, $separator);
1379 }
1380
1381 /**
1382 * Helper function for {@link coursecat::make_categories_list()}
1383 *
1384 * @param string/array $requiredcapability if given, only categories where the current
1385 * user has this capability will be included in return value. Can also be
1386 * an array of capabilities, in which case they are all required.
1387 * @param integer $excludeid Omit this category and its children from the lists built.
1388 * @param string $separator string to use as a separator between parent and child category. Default ' / '
1389 * @param string $pathprefix For internal use, as part of recursive calls
1390 * @return array of strings
1391 */
1392 protected function get_children_names($requiredcapability = '', $excludeid = 0, $separator = ' / ', $pathprefix = '') {
1393 $list = array();
1394 if ($excludeid && $this->id == $excludeid) {
1395 return $list;
1396 }
1397
1398 if ($this->id) {
1399 // Update $path.
1400 if ($pathprefix) {
1401 $pathprefix .= $separator;
1402 }
1403 $pathprefix .= $this->get_formatted_name();
1404
1405 // Add this category to $list, if the permissions check out.
1406 if (empty($requiredcapability) ||
1407 has_all_capabilities((array)$requiredcapability, context_coursecat::instance($this->id))) {
1408 $list[$this->id] = $pathprefix;
1409 }
1410 }
1411
1412 // Add all the children recursively, while updating the parents array.
1413 foreach ($this->get_children() as $cat) {
1414 $list += $cat->get_children_names($requiredcapability, $excludeid, $separator, $pathprefix);
1415 }
1416
1417 return $list;
1418 }
1419
b33389d2
MG
1420 // ====== implementing method from interface cacheable_object ======
1421
1422 /**
1423 * Prepares the object for caching. Works like the __sleep method.
1424 *
1425 * @return array ready to be cached
1426 */
1427 public function prepare_to_cache() {
1428 $a = array();
1429 foreach (self::$coursecatfields as $property => $cachedirectives) {
1430 if ($cachedirectives !== null) {
1431 list($shortname, $defaultvalue) = $cachedirectives;
1432 if ($this->$property !== $defaultvalue) {
1433 $a[$shortname] = $this->$property;
1434 }
1435 }
1436 }
1437 $context = context_coursecat::instance($this->id);
1438 $a['xi'] = $context->id;
1439 $a['xp'] = $context->path;
1440 return $a;
1441 }
1442
1443 /**
1444 * Takes the data provided by prepare_to_cache and reinitialises an instance of the associated from it.
1445 *
1446 * @param array $a
1447 * @return coursecat
1448 */
1449 public static function wake_from_cache($a) {
1450 $record = new stdClass;
1451 foreach (self::$coursecatfields as $property => $cachedirectives) {
1452 if ($cachedirectives !== null) {
1453 list($shortname, $defaultvalue) = $cachedirectives;
1454 if (array_key_exists($shortname, $a)) {
1455 $record->$property = $a[$shortname];
1456 } else {
1457 $record->$property = $defaultvalue;
1458 }
1459 }
1460 }
1461 $record->ctxid = $a['xi'];
1462 $record->ctxpath = $a['xp'];
1463 $record->ctxdepth = $record->depth + 1;
1464 $record->ctxlevel = CONTEXT_COURSECAT;
1465 $record->ctxinstance = $record->id;
1466 return new coursecat($record, true);
1467 }
1468}