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