MDL-38147 Performance improvements to coursecat class:
[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 global $DB;
212 if (!$id) {
213 if (!isset(self::$coursecat0)) {
15d50fff
MG
214 $record = new stdClass();
215 $record->id = 0;
216 $record->visible = 1;
217 $record->depth = 0;
218 $record->path = '';
219 self::$coursecat0 = new coursecat($record);
b33389d2
MG
220 }
221 return self::$coursecat0;
222 }
223 $coursecatcache = cache::make('core', 'coursecat');
15d50fff
MG
224 $coursecat = $coursecatcache->get($id);
225 if ($coursecat === false) {
b33389d2
MG
226 $all = self::get_all_ids();
227 if (array_key_exists($id, $all)) {
15d50fff
MG
228 // Retrieve from DB only the fields that need to be stored in cache
229 $fields = array_filter(array_keys(self::$coursecatfields), function ($element)
230 { return (self::$coursecatfields[$element] !== null); } );
231 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
232 $sql = "SELECT cc.". join(',cc.', $fields). ", $ctxselect
b33389d2 233 FROM {course_categories} cc
15d50fff 234 JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = ?
b33389d2 235 WHERE cc.id = ?";
15d50fff 236 if ($record = $DB->get_record_sql($sql, array(CONTEXT_COURSECAT, $id))) {
b33389d2 237 $coursecat = new coursecat($record);
15d50fff 238 // Store in cache
b33389d2 239 $coursecatcache->set($id, $coursecat);
b33389d2
MG
240 }
241 }
242 }
15d50fff 243 if ($coursecat && ($alwaysreturnhidden || $coursecat->is_uservisible())) {
b33389d2
MG
244 return $coursecat;
245 } else {
246 if ($strictness == MUST_EXIST) {
247 throw new moodle_exception('unknowcategory');
248 }
249 }
250 return null;
251 }
252
253 /**
254 * Returns the first found category
255 *
256 * Note that if there are no categories visible to the current user on the first level,
257 * the invisible category may be returned
258 *
259 * @return coursecat
260 */
261 public static function get_default() {
262 if ($visiblechildren = self::get(0)->get_children()) {
263 $defcategory = reset($visiblechildren);
264 } else {
265 $all = $this->get_all_ids();
266 $defcategoryid = $all[0][0];
267 $defcategory = self::get($defcategoryid, MUST_EXIST, true);
268 }
269 return $defcategory;
270 }
271
272 /**
273 * Restores the object after it has been externally modified in DB for example
274 * during {@link fix_course_sortorder()}
275 */
276 protected function restore() {
277 // update all fields in the current object
278 $newrecord = self::get($this->id, MUST_EXIST, true);
279 foreach (self::$coursecatfields as $key => $unused) {
280 $this->$key = $newrecord->$key;
281 }
282 }
283
284 /**
285 * Creates a new category either from form data or from raw data
286 *
287 * Please note that this function does not verify access control.
288 *
289 * Exception is thrown if name is missing or idnumber is duplicating another one in the system.
290 *
291 * Category visibility is inherited from parent unless $data->visible = 0 is specified
292 *
293 * @param array|stdClass $data
294 * @param array $editoroptions if specified, the data is considered to be
295 * form data and file_postupdate_standard_editor() is being called to
296 * process images in description.
297 * @return coursecat
298 * @throws moodle_exception
299 */
300 public static function create($data, $editoroptions = null) {
301 global $DB, $CFG;
302 $data = (object)$data;
303 $newcategory = new stdClass();
304
305 $newcategory->descriptionformat = FORMAT_MOODLE;
306 $newcategory->description = '';
307 // copy all description* fields regardless of whether this is form data or direct field update
308 foreach ($data as $key => $value) {
309 if (preg_match("/^description/", $key)) {
310 $newcategory->$key = $value;
311 }
312 }
313
314 if (empty($data->name)) {
315 throw new moodle_exception('categorynamerequired');
316 }
317 if (textlib::strlen($data->name) > 255) {
318 throw new moodle_exception('categorytoolong');
319 }
320 $newcategory->name = $data->name;
321
322 // validate and set idnumber
323 if (!empty($data->idnumber)) {
324 if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) {
325 throw new moodle_exception('categoryidnumbertaken');
326 }
327 if (textlib::strlen($data->idnumber) > 100) {
328 throw new moodle_exception('idnumbertoolong');
329 }
330 }
331 if (isset($data->idnumber)) {
332 $newcategory->idnumber = $data->idnumber;
333 }
334
335 if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
336 $newcategory->theme = $data->theme;
337 }
338
339 if (empty($data->parent)) {
340 $parent = self::get(0);
341 } else {
342 $parent = self::get($data->parent, MUST_EXIST, true);
343 }
344 $newcategory->parent = $parent->id;
345 $newcategory->depth = $parent->depth + 1;
346
347 // By default category is visible, unless visible = 0 is specified or parent category is hidden
348 if (isset($data->visible) && !$data->visible) {
349 // create a hidden category
350 $newcategory->visible = $newcategory->visibleold = 0;
351 } else {
352 // create a category that inherits visibility from parent
353 $newcategory->visible = $parent->visible;
354 // in case parent is hidden, when it changes visibility this new subcategory will automatically become visible too
355 $newcategory->visibleold = 1;
356 }
357
358 $newcategory->sortorder = 0;
359 $newcategory->timemodified = time();
360
361 $newcategory->id = $DB->insert_record('course_categories', $newcategory);
362
363 // update path (only possible after we know the category id
364 $path = $parent->path . '/' . $newcategory->id;
365 $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));
366
367 // We should mark the context as dirty
368 context_coursecat::instance($newcategory->id)->mark_dirty();
369
370 fix_course_sortorder();
371
372 // if this is data from form results, save embedded files and update description
373 $categorycontext = context_coursecat::instance($newcategory->id);
374 if ($editoroptions) {
375 $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
376
377 // update only fields description and descriptionformat
378 $updatedata = array_intersect_key((array)$newcategory, array('id' => 1, 'description' => 1, 'descriptionformat' => 1));
379 $DB->update_record('course_categories', $updatedata);
380
381 self::purge_cache();
382 }
383
384 add_to_log(SITEID, "category", 'add', "editcategory.php?id=$newcategory->id", $newcategory->id);
385
386 return self::get($newcategory->id, MUST_EXIST, true);
387 }
388
389 /**
390 * Updates the record with either form data or raw data
391 *
392 * Please note that this function does not verify access control.
393 *
15d50fff
MG
394 * This function calls coursecat::change_parent_raw if field 'parent' is updated.
395 * It also calls coursecat::hide_raw or coursecat::show_raw if 'visible' is updated.
b33389d2
MG
396 * Visibility is changed first and then parent is changed. This means that
397 * if parent category is hidden, the current category will become hidden
398 * too and it may overwrite whatever was set in field 'visible'.
399 *
400 * Note that fields 'path' and 'depth' can not be updated manually
401 * Also coursecat::update() can not directly update the field 'sortoder'
402 *
403 * @param array|stdClass $data
404 * @param array $editoroptions if specified, the data is considered to be
405 * form data and file_postupdate_standard_editor() is being called to
406 * process images in description.
407 * @throws moodle_exception
408 */
409 public function update($data, $editoroptions = null) {
410 global $DB, $CFG;
411 if (!$this->id) {
412 // there is no actual DB record associated with root category
413 return;
414 }
415
416 $data = (object)$data;
417 $newcategory = new stdClass();
418 $newcategory->id = $this->id;
419
420 // copy all description* fields regardless of whether this is form data or direct field update
421 foreach ($data as $key => $value) {
422 if (preg_match("/^description/", $key)) {
423 $newcategory->$key = $value;
424 }
425 }
426
427 if (isset($data->name) && empty($data->name)) {
428 throw new moodle_exception('categorynamerequired');
429 }
430
431 if (!empty($data->name) && $data->name !== $this->name) {
432 if (textlib::strlen($data->name) > 255) {
433 throw new moodle_exception('categorytoolong');
434 }
435 $newcategory->name = $data->name;
436 }
437
438 if (isset($data->idnumber) && $data->idnumber != $this->idnumber) {
439 if (textlib::strlen($data->idnumber) > 100) {
440 throw new moodle_exception('idnumbertoolong');
441 }
442 if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) {
443 throw new moodle_exception('categoryidnumbertaken');
444 }
445 $newcategory->idnumber = $data->idnumber;
446 }
447
448 if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
449 $newcategory->theme = $data->theme;
450 }
451
452 $changes = false;
453 if (isset($data->visible)) {
454 if ($data->visible) {
15d50fff 455 $changes = $this->show_raw();
b33389d2 456 } else {
15d50fff 457 $changes = $this->hide_raw(0);
b33389d2
MG
458 }
459 }
460
461 if (isset($data->parent) && $data->parent != $this->parent) {
462 if ($changes) {
463 self::purge_cache();
464 }
465 $parentcat = self::get($data->parent, MUST_EXIST, true);
15d50fff 466 $this->change_parent_raw($parentcat);
b33389d2
MG
467 fix_course_sortorder();
468 }
469
470 $newcategory->timemodified = time();
471
472 if ($editoroptions) {
473 $categorycontext = context_coursecat::instance($this->id);
474 $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
475 }
476 $DB->update_record('course_categories', $newcategory);
477 add_to_log(SITEID, "category", 'update', "editcategory.php?id=$this->id", $this->id);
478 fix_course_sortorder();
479
480 // update all fields in the current object
481 $this->restore();
482 }
483
484 /**
485 * Checks if this course category is visible to current user
486 *
487 * Please note that methods coursecat::get (without 3rd argumet),
488 * coursecat::get_children(), etc. return only visible categories so it is
489 * usually not needed to call this function outside of this class
490 *
491 * @return bool
492 */
493 public function is_uservisible() {
494 return !$this->id || $this->visible ||
495 has_capability('moodle/category:viewhiddencategories',
496 context_coursecat::instance($this->id));
497 }
498
499 /**
500 * Returns all categories visible to the current user
501 *
502 * This is a generic function that returns an array of
503 * (category id => coursecat object) sorted by sortorder
504 *
505 * @see coursecat::get_children()
506 * @see coursecat::get_all_parents()
507 *
508 * @return cacheable_object_array array of coursecat objects
509 */
510 public static function get_all_visible() {
511 global $USER;
512 $coursecatcache = cache::make('core', 'coursecat');
513 $ids = $coursecatcache->get('user'. $USER->id);
514 if ($ids === false) {
515 $all = self::get_all_ids();
516 $parentvisible = $all[0];
517 $rv = array();
518 foreach ($all as $id => $children) {
519 if ($id && in_array($id, $parentvisible) &&
520 ($coursecat = self::get($id, IGNORE_MISSING)) &&
521 (!$coursecat->parent || isset($rv[$coursecat->parent]))) {
522 $rv[$id] = $coursecat;
523 $parentvisible += $children;
524 }
525 }
526 $coursecatcache->set('user'. $USER->id, array_keys($rv));
527 } else {
528 $rv = array();
529 foreach ($ids as $id) {
530 if ($coursecat = self::get($id, IGNORE_MISSING)) {
531 $rv[$id] = $coursecat;
532 }
533 }
534 }
535 return $rv;
536 }
537
538 /**
539 * Returns tree of categories ids
540 *
541 * Return array has categories ids as keys and list of children ids as values.
542 * Also there is an additional first element with key 0 with list of categories on the top level.
543 * Therefore the number of elements in the return array is one more than number of categories in the system.
544 *
545 * Also this method ensures that all categories are cached together with their contexts.
546 *
547 * @return array
548 */
549 protected static function get_all_ids() {
550 global $DB;
551 $coursecatcache = cache::make('core', 'coursecat');
552 $all = $coursecatcache->get('all');
553 if ($all === false) {
554 $coursecatcache->purge(); // it should be empty already but to be extra sure
555 $sql = "SELECT cc.id, cc.parent
556 FROM {course_categories} cc
557 ORDER BY cc.sortorder";
558 $rs = $DB->get_recordset_sql($sql, array());
559 $all = array(0 => array());
560 foreach ($rs as $record) {
561 $all[$record->id] = array();
562 $all[$record->parent][] = $record->id;
563 }
564 $rs->close();
565 if (!count($all[0])) {
566 // No categories found.
567 // This may happen after upgrade from very old moodle version. In new versions the default category is created on install.
568 $defcoursecat = self::create(array('name' => get_string('miscellaneous')));
569 $coursecatcache->set($defcoursecat->id, $defcoursecat);
570 set_config('defaultrequestcategory', $defcoursecat->id);
571 $all[0][$defcoursecat->id] = array();
572 }
573 $coursecatcache->set('all', $all);
574 }
575 return $all;
576 }
577
b33389d2
MG
578 /**
579 * Returns number of ALL categories in the system regardless if
580 * they are visible to current user or not
581 *
582 * @return int
583 */
15d50fff 584 public static function count_all() {
b33389d2
MG
585 $all = self::get_all_ids();
586 return count($all) - 1; // do not count 0-category
587 }
588
589 /**
590 * Returns array of children categories visible to the current user
591 *
592 * @return array of coursecat objects indexed by category id
593 */
594 public function get_children() {
595 $all = self::get_all_ids();
596 $rv = array();
597 if (!empty($all[$this->id])) {
598 foreach ($all[$this->id] as $id) {
599 if ($coursecat = self::get($id, IGNORE_MISSING)) {
600 // do not return invisible
601 $rv[$coursecat->id] = $coursecat;
602 }
603 }
604 }
605 return $rv;
606 }
607
608 /**
609 * Returns true if the category has ANY children, including those not visible to the user
610 *
611 * @return boolean
612 */
613 public function has_children() {
614 $all = self::get_all_ids();
615 return !empty($all[$this->id]);
616 }
617
618 /**
619 * Returns true if the category has courses in it (count does not include courses
620 * in child categories)
621 *
622 * @return bool
623 */
624 public function has_courses() {
625 global $DB;
626 return $DB->record_exists_sql("select 1 from {course} where category = ?",
627 array($this->id));
628 }
629
630 /**
631 * Returns true if user can delete current category and all its contents
632 *
633 * To be able to delete course category the user must have permission
634 * 'moodle/category:manage' in ALL child course categories AND
635 * be able to delete all courses
636 *
637 * @return bool
638 */
639 public function can_delete_full() {
640 global $DB;
641 if (!$this->id) {
642 // fool-proof
643 return false;
644 }
645
646 $context = context_coursecat::instance($this->id);
647 if (!$this->is_uservisible() ||
648 !has_capability('moodle/category:manage', $context)) {
649 return false;
650 }
651
15d50fff
MG
652 // Check all child categories (not only direct children)
653 $sql = context_helper::get_preload_record_columns_sql('ctx');
654 $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
655 ' FROM {context} ctx '.
656 ' JOIN {course_categories} c ON c.id = ctx.instanceid'.
657 ' WHERE ctx.path like ? AND ctx.contextlevel = ?',
658 array($context->path. '/%', CONTEXT_COURSECAT));
659 foreach ($childcategories as $childcat) {
660 context_helper::preload_from_record($childcat);
661 $childcontext = context_coursecat::instance($childcat->id);
662 if ((!$childcat->visible && !has_capability('moodle/category:viewhiddencategories', $childcontext)) ||
663 !has_capability('moodle/category:manage', $childcontext)) {
664 return false;
b33389d2
MG
665 }
666 }
667
668 // Check courses
15d50fff
MG
669 $sql = context_helper::get_preload_record_columns_sql('ctx');
670 $coursescontexts = $DB->get_records_sql('SELECT ctx.instanceid AS courseid, '.
671 $sql. ' FROM {context} ctx '.
672 'WHERE ctx.path like :pathmask and ctx.contextlevel = :courselevel',
b33389d2
MG
673 array('pathmask' => $context->path. '/%',
674 'courselevel' => CONTEXT_COURSE));
15d50fff
MG
675 foreach ($coursescontexts as $ctxrecord) {
676 context_helper::preload_from_record($ctxrecord);
677 if (!can_delete_course($ctxrecord->courseid)) {
b33389d2
MG
678 return false;
679 }
680 }
681
682 return true;
683 }
684
685 /**
686 * Recursively delete category including all subcategories and courses
687 *
688 * Function {@link coursecat::can_delete_full()} MUST be called prior
689 * to calling this function because there is no capability check
690 * inside this function
691 *
692 * @param boolean $showfeedback display some notices
693 * @return array return deleted courses
694 */
15d50fff 695 public function delete_full($showfeedback = true) {
b33389d2
MG
696 global $CFG, $DB;
697 require_once($CFG->libdir.'/gradelib.php');
698 require_once($CFG->libdir.'/questionlib.php');
699 require_once($CFG->dirroot.'/cohort/lib.php');
700
701 $deletedcourses = array();
702
703 // Get children. Note, we don't want to use cache here because
704 // it would be rebuilt too often
705 $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC');
706 foreach ($children as $record) {
707 $coursecat = new coursecat($record);
708 $deletedcourses += $coursecat->delete_full($showfeedback);
709 }
710
711 if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) {
712 foreach ($courses as $course) {
713 if (!delete_course($course, false)) {
714 throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname);
715 }
716 $deletedcourses[] = $course;
717 }
718 }
719
720 // move or delete cohorts in this context
721 cohort_delete_category($this);
722
723 // now delete anything that may depend on course category context
724 grade_course_category_delete($this->id, 0, $showfeedback);
725 if (!question_delete_course_category($this, 0, $showfeedback)) {
726 throw new moodle_exception('cannotdeletecategoryquestions', '', '', $this->get_formatted_name());
727 }
728
729 // finally delete the category and it's context
730 $DB->delete_records('course_categories', array('id' => $this->id));
731 delete_context(CONTEXT_COURSECAT, $this->id);
732 add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");
733
734 self::purge_cache();
735
736 events_trigger('course_category_deleted', $this);
737
738 // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
739 if ($this->id == $CFG->defaultrequestcategory) {
740 set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
741 }
742 return $deletedcourses;
743 }
744
745 /**
746 * Checks if user can delete this category and move content (courses, subcategories and questions)
747 * to another category. If yes returns the array of possible target categories names
748 *
749 * If user can not manage this category or it is completely empty - empty array will be returned
750 *
751 * @return array
752 */
753 public function move_content_targets_list() {
754 global $CFG;
755 require_once($CFG->libdir . '/questionlib.php');
756 $context = context_coursecat::instance($this->id);
757 if (!$this->is_uservisible() ||
758 !has_capability('moodle/category:manage', $context)) {
759 // User is not able to manage current category, he is not able to delete it.
760 // No possible target categories.
761 return array();
762 }
763
764 $testcaps = array();
765 // If this category has courses in it, user must have 'course:create' capability in target category.
766 if ($this->has_courses()) {
767 $testcaps[] = 'moodle/course:create';
768 }
769 // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
770 if ($this->has_children() || question_context_has_any_questions($context)) {
771 $testcaps[] = 'moodle/category:manage';
772 }
773 if (!empty($testcaps)) {
774 // return list of categories excluding this one and it's children
775 return self::make_categories_list($testcaps, $this->id);
776 }
777
778 // Category is completely empty, no need in target for contents.
779 return array();
780 }
781
782 /**
783 * Checks if user has capability to move all category content to the new parent before
784 * removing this category
785 *
786 * @param int $newcatid
787 * @return bool
788 */
789 public function can_move_content_to($newcatid) {
790 global $CFG;
791 require_once($CFG->libdir . '/questionlib.php');
792 $context = context_coursecat::instance($this->id);
793 if (!$this->is_uservisible() ||
794 !has_capability('moodle/category:manage', $context)) {
795 return false;
796 }
797 $testcaps = array();
798 // If this category has courses in it, user must have 'course:create' capability in target category.
799 if ($this->has_courses()) {
800 $testcaps[] = 'moodle/course:create';
801 }
802 // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
803 if ($this->has_children() || question_context_has_any_questions($context)) {
804 $testcaps[] = 'moodle/category:manage';
805 }
806 if (!empty($testcaps)) {
807 return has_all_capabilities($testcaps, context_coursecat::instance($newcatid));
808 }
809
810 // there is no content but still return true
811 return true;
812 }
813
814 /**
815 * Deletes a category and moves all content (children, courses and questions) to the new parent
816 *
817 * Note that this function does not check capabilities, {@link coursecat::can_move_content_to()}
818 * must be called prior
819 *
820 * @param int $newparentid
821 * @param bool $showfeedback
822 * @return bool
823 */
824 public function delete_move($newparentid, $showfeedback = false) {
825 global $CFG, $DB, $OUTPUT;
826 require_once($CFG->libdir.'/gradelib.php');
827 require_once($CFG->libdir.'/questionlib.php');
828 require_once($CFG->dirroot.'/cohort/lib.php');
829
830 // get all objects and lists because later the caches will be reset so
831 // we don't need to make extra queries
832 $newparentcat = self::get($newparentid, MUST_EXIST, true);
833 $catname = $this->get_formatted_name();
834 $children = $this->get_children();
835 $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', array('category' => $this->id));
836 $context = context_coursecat::instance($this->id);
837
838 if ($children) {
839 foreach ($children as $childcat) {
15d50fff 840 $childcat->change_parent_raw($newparentcat);
b33389d2
MG
841 // Log action.
842 add_to_log(SITEID, "category", "move", "editcategory.php?id=$childcat->id", $childcat->id);
843 }
844 fix_course_sortorder();
845 }
846
847 if ($coursesids) {
848 if (!move_courses($coursesids, $newparentid)) {
849 if ($showfeedback) {
850 echo $OUTPUT->notification("Error moving courses");
851 }
852 return false;
853 }
854 if ($showfeedback) {
855 echo $OUTPUT->notification(get_string('coursesmovedout', '', $catname), 'notifysuccess');
856 }
857 }
858
859 // move or delete cohorts in this context
860 cohort_delete_category($this);
861
862 // now delete anything that may depend on course category context
863 grade_course_category_delete($this->id, $newparentid, $showfeedback);
864 if (!question_delete_course_category($this, $newparentcat, $showfeedback)) {
865 if ($showfeedback) {
866 echo $OUTPUT->notification(get_string('errordeletingquestionsfromcategory', 'question', $catname), 'notifysuccess');
867 }
868 return false;
869 }
870
871 // finally delete the category and it's context
872 $DB->delete_records('course_categories', array('id' => $this->id));
873 $context->delete();
874 add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");
875
876 events_trigger('course_category_deleted', $this);
877
878 self::purge_cache();
879
880 if ($showfeedback) {
881 echo $OUTPUT->notification(get_string('coursecategorydeleted', '', $catname), 'notifysuccess');
882 }
883
884 // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
885 if ($this->id == $CFG->defaultrequestcategory) {
886 set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
887 }
888 return true;
889 }
890
891 /**
892 * Checks if user can move current category to the new parent
893 *
894 * This checks if new parent category exists, user has manage cap there
895 * and new parent is not a child of this category
896 *
897 * @param int|stdClass|coursecat $newparentcat
898 * @return bool
899 */
900 public function can_change_parent($newparentcat) {
901 if (!has_capability('moodle/category:manage', context_coursecat::instance($this->id))) {
902 return false;
903 }
904 if (is_object($newparentcat)) {
905 $newparentcat = self::get($newparentcat->id, IGNORE_MISSING);
906 } else {
907 $newparentcat = self::get((int)$newparentcat, IGNORE_MISSING);
908 }
909 if (!$newparentcat) {
910 return false;
911 }
15d50fff 912 if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
b33389d2
MG
913 // can not move to itself or it's own child
914 return false;
915 }
916 return has_capability('moodle/category:manage', get_category_or_system_context($newparentcat->id));
917 }
918
919 /**
920 * Moves the category under another parent category. All associated contexts are moved as well
921 *
922 * This is protected function, use change_parent() or update() from outside of this class
923 *
924 * @see coursecat::change_parent()
925 * @see coursecat::update()
926 *
927 * @param coursecat $newparentcat
928 */
15d50fff 929 protected function change_parent_raw(coursecat $newparentcat) {
b33389d2
MG
930 global $DB;
931
932 $context = context_coursecat::instance($this->id);
933
934 $hidecat = false;
935 if (empty($newparentcat->id)) {
936 $DB->set_field('course_categories', 'parent', 0, array('id' => $this->id));
937 $newparent = context_system::instance();
938 } else {
15d50fff 939 if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
b33389d2
MG
940 // can not move to itself or it's own child
941 throw new moodle_exception('cannotmovecategory');
942 }
943 $DB->set_field('course_categories', 'parent', $newparentcat->id, array('id' => $this->id));
944 $newparent = context_coursecat::instance($newparentcat->id);
945
946 if (!$newparentcat->visible and $this->visible) {
947 // better hide category when moving into hidden category, teachers may unhide afterwards and the hidden children will be restored properly
948 $hidecat = true;
949 }
950 }
951 $this->parent = $newparentcat->id;
952
953 context_moved($context, $newparent);
954
955 // now make it last in new category
956 $DB->set_field('course_categories', 'sortorder', MAX_COURSES_IN_CATEGORY*MAX_COURSE_CATEGORIES, array('id' => $this->id));
957
958 if ($hidecat) {
959 fix_course_sortorder();
960 $this->restore();
961 // Hide object but store 1 in visibleold, because when parent category visibility changes this category must become visible again.
15d50fff 962 $this->hide_raw(1);
b33389d2
MG
963 }
964 }
965
966 /**
967 * Efficiently moves a category - NOTE that this can have
968 * a huge impact access-control-wise...
969 *
970 * Note that this function does not check capabilities.
971 *
972 * Example of usage:
973 * $coursecat = coursecat::get($categoryid);
974 * if ($coursecat->can_change_parent($newparentcatid)) {
975 * $coursecat->change_parent($newparentcatid);
976 * }
977 *
978 * This function does not update field course_categories.timemodified
979 * If you want to update timemodified, use
980 * $coursecat->update(array('parent' => $newparentcat));
981 *
982 * @param int|stdClass|coursecat $newparentcat
983 */
984 public function change_parent($newparentcat) {
985 // Make sure parent category exists but do not check capabilities here that it is visible to current user.
986 if (is_object($newparentcat)) {
987 $newparentcat = self::get($newparentcat->id, MUST_EXIST, true);
988 } else {
989 $newparentcat = self::get((int)$newparentcat, MUST_EXIST, true);
990 }
991 if ($newparentcat->id != $this->parent) {
15d50fff 992 $this->change_parent_raw($newparentcat);
b33389d2
MG
993 fix_course_sortorder();
994 $this->restore();
995 add_to_log(SITEID, "category", "move", "editcategory.php?id=$this->id", $this->id);
996 }
997 }
998
999 /**
1000 * Hide course category and child course and subcategories
1001 *
1002 * If this category has changed the parent and is moved under hidden
1003 * category we will want to store it's current visibility state in
1004 * the field 'visibleold'. If admin clicked 'hide' for this particular
1005 * category, the field 'visibleold' should become 0.
1006 *
1007 * All subcategories and courses will have their current visibility in the field visibleold
1008 *
1009 * This is protected function, use hide() or update() from outside of this class
1010 *
1011 * @see coursecat::hide()
1012 * @see coursecat::update()
1013 *
1014 * @param int $visibleold value to set in field $visibleold for this category
1015 * @return bool whether changes have been made and caches need to be purged afterwards
1016 */
15d50fff 1017 protected function hide_raw($visibleold = 0) {
b33389d2
MG
1018 global $DB;
1019 $changes = false;
1020
15d50fff
MG
1021 // Note that field 'visibleold' is not cached so we must retrieve it from DB if it is missing
1022 if ($this->id && $this->__get('visibleold') != $visibleold) {
b33389d2
MG
1023 $this->visibleold = $visibleold;
1024 $DB->set_field('course_categories', 'visibleold', $visibleold, array('id' => $this->id));
1025 $changes = true;
1026 }
1027 if (!$this->visible || !$this->id) {
1028 // already hidden or can not be hidden
1029 return $changes;
1030 }
1031
1032 $this->visible = 0;
1033 $DB->set_field('course_categories', 'visible', 0, array('id'=>$this->id));
1034 $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
1035 $DB->set_field('course', 'visible', 0, array('category' => $this->id));
1036 // get all child categories and hide too
1037 if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visible')) {
1038 foreach ($subcats as $cat) {
1039 $DB->set_field('course_categories', 'visibleold', $cat->visible, array('id' => $cat->id));
1040 $DB->set_field('course_categories', 'visible', 0, array('id' => $cat->id));
1041 $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($cat->id));
1042 $DB->set_field('course', 'visible', 0, array('category' => $cat->id));
1043 }
1044 }
1045 return true;
1046 }
1047
1048 /**
1049 * Hide course category and child course and subcategories
1050 *
1051 * Note that there is no capability check inside this function
1052 *
1053 * This function does not update field course_categories.timemodified
1054 * If you want to update timemodified, use
1055 * $coursecat->update(array('visible' => 0));
1056 */
1057 public function hide() {
15d50fff 1058 if ($this->hide_raw(0)) {
b33389d2
MG
1059 self::purge_cache();
1060 add_to_log(SITEID, "category", "hide", "editcategory.php?id=$this->id", $this->id);
1061 }
1062 }
1063
1064 /**
1065 * Show course category and restores visibility for child course and subcategories
1066 *
1067 * Note that there is no capability check inside this function
1068 *
1069 * This is protected function, use show() or update() from outside of this class
1070 *
1071 * @see coursecat::show()
1072 * @see coursecat::update()
1073 *
1074 * @return bool whether changes have been made and caches need to be purged afterwards
1075 */
15d50fff 1076 protected function show_raw() {
b33389d2
MG
1077 global $DB;
1078
1079 if ($this->visible) {
1080 // already visible
1081 return false;
1082 }
1083
1084 $this->visible = 1;
1085 $this->visibleold = 1;
1086 $DB->set_field('course_categories', 'visible', 1, array('id' => $this->id));
1087 $DB->set_field('course_categories', 'visibleold', 1, array('id' => $this->id));
1088 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($this->id));
1089 // get all child categories and unhide too
1090 if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visibleold')) {
1091 foreach ($subcats as $cat) {
1092 if ($cat->visibleold) {
1093 $DB->set_field('course_categories', 'visible', 1, array('id' => $cat->id));
1094 }
1095 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($cat->id));
1096 }
1097 }
1098 return true;
1099 }
1100
1101 /**
1102 * Show course category and restores visibility for child course and subcategories
1103 *
1104 * Note that there is no capability check inside this function
1105 *
1106 * This function does not update field course_categories.timemodified
1107 * If you want to update timemodified, use
1108 * $coursecat->update(array('visible' => 1));
1109 */
1110 public function show() {
15d50fff 1111 if ($this->show_raw()) {
b33389d2
MG
1112 self::purge_cache();
1113 add_to_log(SITEID, "category", "show", "editcategory.php?id=$this->id", $this->id);
1114 }
1115 }
1116
1117 /**
1118 * Returns name of the category formatted as a string
1119 *
1120 * @param array $options formatting options other than context
1121 * @return string
1122 */
1123 public function get_formatted_name($options = array()) {
1124 if ($this->id) {
1125 $context = context_coursecat::instance($this->id);
1126 return format_string($this->name, true, array('context' => $context) + $options);
1127 } else {
1128 return ''; // TODO 'Top'?
1129 }
1130 }
1131
1132 /**
15d50fff 1133 * Returns ids of all parents of the category. Last element in the return array is the direct parent
b33389d2
MG
1134 *
1135 * For example, if you have a tree of categories like:
1136 * Miscellaneous (id = 1)
1137 * Subcategory (id = 2)
1138 * Sub-subcategory (id = 4)
1139 * Other category (id = 3)
1140 *
15d50fff
MG
1141 * coursecat::get(1)->get_parents() == array()
1142 * coursecat::get(2)->get_parents() == array(1)
1143 * coursecat::get(4)->get_parents() == array(1, 2);
b33389d2
MG
1144 *
1145 * Note that this method does not check if all parents are accessible by current user
1146 *
15d50fff 1147 * @return array of category ids
b33389d2 1148 */
15d50fff
MG
1149 public function get_parents() {
1150 $parents = preg_split('|/|', $this->path, 0, PREG_SPLIT_NO_EMPTY);
1151 array_pop($parents);
1152 return $parents;
b33389d2
MG
1153 }
1154
1155 /**
1156 * This function recursively travels the categories, building up a nice list
1157 * for display or to use in a form <select> element
1158 *
1159 * For example, if you have a tree of categories like:
1160 * Miscellaneous (id = 1)
1161 * Subcategory (id = 2)
1162 * Sub-subcategory (id = 4)
1163 * Other category (id = 3)
1164 * Then after calling this function you will have
1165 * array(1 => 'Miscellaneous',
1166 * 2 => 'Miscellaneous / Subcategory',
1167 * 4 => 'Miscellaneous / Subcategory / Sub-subcategory',
1168 * 3 => 'Other category');
1169 *
1170 * If you specify $requiredcapability, then only categories where the current
1171 * user has that capability will be added to $list.
1172 * If you only have $requiredcapability in a child category, not the parent,
1173 * then the child catgegory will still be included.
1174 *
1175 * If you specify the option $excludeid, then that category, and all its children,
1176 * are omitted from the tree. This is useful when you are doing something like
1177 * moving categories, where you do not want to allow people to move a category
1178 * to be the child of itself.
1179 *
1180 * See also {@link make_categories_options()}
1181 *
1182 * @param string/array $requiredcapability if given, only categories where the current
1183 * user has this capability will be returned. Can also be an array of capabilities,
1184 * in which case they are all required.
1185 * @param integer $excludeid Exclude this category and its children from the lists built.
1186 * @param string $separator string to use as a separator between parent and child category. Default ' / '
1187 * @return array of strings
1188 */
1189 public static function make_categories_list($requiredcapability = '', $excludeid = 0, $separator = ' / ') {
1190 return self::get(0)->get_children_names($requiredcapability, $excludeid, $separator);
1191 }
1192
1193 /**
1194 * Helper function for {@link coursecat::make_categories_list()}
1195 *
1196 * @param string/array $requiredcapability if given, only categories where the current
1197 * user has this capability will be included in return value. Can also be
1198 * an array of capabilities, in which case they are all required.
1199 * @param integer $excludeid Omit this category and its children from the lists built.
1200 * @param string $separator string to use as a separator between parent and child category. Default ' / '
1201 * @param string $pathprefix For internal use, as part of recursive calls
1202 * @return array of strings
1203 */
1204 protected function get_children_names($requiredcapability = '', $excludeid = 0, $separator = ' / ', $pathprefix = '') {
1205 $list = array();
1206 if ($excludeid && $this->id == $excludeid) {
1207 return $list;
1208 }
1209
1210 if ($this->id) {
1211 // Update $path.
1212 if ($pathprefix) {
1213 $pathprefix .= $separator;
1214 }
1215 $pathprefix .= $this->get_formatted_name();
1216
1217 // Add this category to $list, if the permissions check out.
1218 if (empty($requiredcapability) ||
1219 has_all_capabilities((array)$requiredcapability, context_coursecat::instance($this->id))) {
1220 $list[$this->id] = $pathprefix;
1221 }
1222 }
1223
1224 // Add all the children recursively, while updating the parents array.
1225 foreach ($this->get_children() as $cat) {
1226 $list += $cat->get_children_names($requiredcapability, $excludeid, $separator, $pathprefix);
1227 }
1228
1229 return $list;
1230 }
1231
1232 /**
1233 * Call to reset caches after any modification of course categories
1234 */
1235 public static function purge_cache() {
1236 $coursecatcache = cache::make('core', 'coursecat');
1237 $coursecatcache->purge();
1238 }
1239
1240 // ====== implementing method from interface cacheable_object ======
1241
1242 /**
1243 * Prepares the object for caching. Works like the __sleep method.
1244 *
1245 * @return array ready to be cached
1246 */
1247 public function prepare_to_cache() {
1248 $a = array();
1249 foreach (self::$coursecatfields as $property => $cachedirectives) {
1250 if ($cachedirectives !== null) {
1251 list($shortname, $defaultvalue) = $cachedirectives;
1252 if ($this->$property !== $defaultvalue) {
1253 $a[$shortname] = $this->$property;
1254 }
1255 }
1256 }
1257 $context = context_coursecat::instance($this->id);
1258 $a['xi'] = $context->id;
1259 $a['xp'] = $context->path;
1260 return $a;
1261 }
1262
1263 /**
1264 * Takes the data provided by prepare_to_cache and reinitialises an instance of the associated from it.
1265 *
1266 * @param array $a
1267 * @return coursecat
1268 */
1269 public static function wake_from_cache($a) {
1270 $record = new stdClass;
1271 foreach (self::$coursecatfields as $property => $cachedirectives) {
1272 if ($cachedirectives !== null) {
1273 list($shortname, $defaultvalue) = $cachedirectives;
1274 if (array_key_exists($shortname, $a)) {
1275 $record->$property = $a[$shortname];
1276 } else {
1277 $record->$property = $defaultvalue;
1278 }
1279 }
1280 }
1281 $record->ctxid = $a['xi'];
1282 $record->ctxpath = $a['xp'];
1283 $record->ctxdepth = $record->depth + 1;
1284 $record->ctxlevel = CONTEXT_COURSECAT;
1285 $record->ctxinstance = $record->id;
1286 return new coursecat($record, true);
1287 }
1288}