weekly release 3.9dev
[moodle.git] / course / classes / category.php
CommitLineData
442f12f8
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 core_course_category responsible 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 * @property-read int $id
32 * @property-read string $name
33 * @property-read string $idnumber
34 * @property-read string $description
35 * @property-read int $descriptionformat
36 * @property-read int $parent
37 * @property-read int $sortorder
38 * @property-read int $coursecount
39 * @property-read int $visible
40 * @property-read int $visibleold
41 * @property-read int $timemodified
42 * @property-read int $depth
43 * @property-read string $path
44 * @property-read string $theme
45 *
46 * @package core
47 * @subpackage course
48 * @copyright 2013 Marina Glancy
49 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
50 */
51class core_course_category implements renderable, cacheable_object, IteratorAggregate {
52 /** @var core_course_category stores pseudo category with id=0. Use core_course_category::get(0) to retrieve */
53 protected static $coursecat0;
54
55 /** @var array list of all fields and their short name and default value for caching */
56 protected static $coursecatfields = array(
57 'id' => array('id', 0),
58 'name' => array('na', ''),
59 'idnumber' => array('in', null),
60 'description' => null, // Not cached.
61 'descriptionformat' => null, // Not cached.
62 'parent' => array('pa', 0),
63 'sortorder' => array('so', 0),
64 'coursecount' => array('cc', 0),
65 'visible' => array('vi', 1),
66 'visibleold' => null, // Not cached.
67 'timemodified' => null, // Not cached.
68 'depth' => array('dh', 1),
69 'path' => array('ph', null),
70 'theme' => null, // Not cached.
71 );
72
73 /** @var int */
74 protected $id;
75
76 /** @var string */
77 protected $name = '';
78
79 /** @var string */
80 protected $idnumber = null;
81
82 /** @var string */
83 protected $description = false;
84
85 /** @var int */
86 protected $descriptionformat = false;
87
88 /** @var int */
89 protected $parent = 0;
90
91 /** @var int */
92 protected $sortorder = 0;
93
94 /** @var int */
95 protected $coursecount = false;
96
97 /** @var int */
98 protected $visible = 1;
99
100 /** @var int */
101 protected $visibleold = false;
102
103 /** @var int */
104 protected $timemodified = false;
105
106 /** @var int */
107 protected $depth = 0;
108
109 /** @var string */
110 protected $path = '';
111
112 /** @var string */
113 protected $theme = false;
114
115 /** @var bool */
beff3806 116 protected $fromcache = false;
442f12f8
MG
117
118 /**
119 * Magic setter method, we do not want anybody to modify properties from the outside
120 *
121 * @param string $name
122 * @param mixed $value
123 */
124 public function __set($name, $value) {
125 debugging('Can not change core_course_category instance properties!', DEBUG_DEVELOPER);
126 }
127
128 /**
129 * Magic method getter, redirects to read only values. Queries from DB the fields that were not cached
130 *
131 * @param string $name
132 * @return mixed
133 */
134 public function __get($name) {
135 global $DB;
136 if (array_key_exists($name, self::$coursecatfields)) {
137 if ($this->$name === false) {
138 // Property was not retrieved from DB, retrieve all not retrieved fields.
139 $notretrievedfields = array_diff_key(self::$coursecatfields, array_filter(self::$coursecatfields));
140 $record = $DB->get_record('course_categories', array('id' => $this->id),
141 join(',', array_keys($notretrievedfields)), MUST_EXIST);
142 foreach ($record as $key => $value) {
143 $this->$key = $value;
144 }
145 }
146 return $this->$name;
147 }
148 debugging('Invalid core_course_category property accessed! '.$name, DEBUG_DEVELOPER);
149 return null;
150 }
151
152 /**
153 * Full support for isset on our magic read only properties.
154 *
155 * @param string $name
156 * @return bool
157 */
158 public function __isset($name) {
159 if (array_key_exists($name, self::$coursecatfields)) {
160 return isset($this->$name);
161 }
162 return false;
163 }
164
165 /**
166 * All properties are read only, sorry.
167 *
168 * @param string $name
169 */
170 public function __unset($name) {
171 debugging('Can not unset core_course_category instance properties!', DEBUG_DEVELOPER);
172 }
173
174 /**
175 * Create an iterator because magic vars can't be seen by 'foreach'.
176 *
177 * implementing method from interface IteratorAggregate
178 *
179 * @return ArrayIterator
180 */
181 public function getIterator() {
182 $ret = array();
183 foreach (self::$coursecatfields as $property => $unused) {
184 if ($this->$property !== false) {
185 $ret[$property] = $this->$property;
186 }
187 }
188 return new ArrayIterator($ret);
189 }
190
191 /**
192 * Constructor
193 *
194 * Constructor is protected, use core_course_category::get($id) to retrieve category
195 *
196 * @param stdClass $record record from DB (may not contain all fields)
197 * @param bool $fromcache whether it is being restored from cache
198 */
199 protected function __construct(stdClass $record, $fromcache = false) {
200 context_helper::preload_from_record($record);
201 foreach ($record as $key => $val) {
202 if (array_key_exists($key, self::$coursecatfields)) {
203 $this->$key = $val;
204 }
205 }
206 $this->fromcache = $fromcache;
207 }
208
209 /**
210 * Returns coursecat object for requested category
211 *
212 * If category is not visible to the given user, it is treated as non existing
213 * unless $alwaysreturnhidden is set to true
214 *
215 * If id is 0, the pseudo object for root category is returned (convenient
216 * for calling other functions such as get_children())
217 *
218 * @param int $id category id
219 * @param int $strictness whether to throw an exception (MUST_EXIST) or
220 * return null (IGNORE_MISSING) in case the category is not found or
221 * not visible to current user
222 * @param bool $alwaysreturnhidden set to true if you want an object to be
223 * returned even if this category is not visible to the current user
224 * (category is hidden and user does not have
225 * 'moodle/category:viewhiddencategories' capability). Use with care!
226 * @param int|stdClass $user The user id or object. By default (null) checks the visibility to the current user.
227 * @return null|self
228 * @throws moodle_exception
229 */
230 public static function get($id, $strictness = MUST_EXIST, $alwaysreturnhidden = false, $user = null) {
231 if (!$id) {
beff3806
MG
232 // Top-level category.
233 if ($alwaysreturnhidden || self::top()->is_uservisible()) {
234 return self::top();
442f12f8 235 }
beff3806
MG
236 if ($strictness == MUST_EXIST) {
237 throw new moodle_exception('cannotviewcategory');
238 }
239 return null;
442f12f8 240 }
beff3806
MG
241
242 // Try to get category from cache or retrieve from the DB.
442f12f8
MG
243 $coursecatrecordcache = cache::make('core', 'coursecatrecords');
244 $coursecat = $coursecatrecordcache->get($id);
245 if ($coursecat === false) {
246 if ($records = self::get_records('cc.id = :id', array('id' => $id))) {
247 $record = reset($records);
248 $coursecat = new self($record);
249 // Store in cache.
250 $coursecatrecordcache->set($id, $coursecat);
251 }
252 }
beff3806
MG
253
254 if (!$coursecat) {
255 // Course category not found.
442f12f8
MG
256 if ($strictness == MUST_EXIST) {
257 throw new moodle_exception('unknowncategory');
258 }
beff3806
MG
259 $coursecat = null;
260 } else if (!$alwaysreturnhidden && !$coursecat->is_uservisible($user)) {
261 // Course category is found but user can not access it.
262 if ($strictness == MUST_EXIST) {
263 throw new moodle_exception('cannotviewcategory');
264 }
265 $coursecat = null;
442f12f8 266 }
beff3806
MG
267 return $coursecat;
268 }
269
270 /**
271 * Returns the pseudo-category representing the whole system (id=0, context_system)
272 *
273 * @return core_course_category
274 */
275 public static function top() {
276 if (!isset(self::$coursecat0)) {
277 $record = new stdClass();
278 $record->id = 0;
279 $record->visible = 1;
280 $record->depth = 0;
281 $record->path = '';
282 $record->locked = 0;
283 self::$coursecat0 = new self($record);
284 }
285 return self::$coursecat0;
286 }
287
288 /**
289 * Returns the top-most category for the current user
290 *
291 * Examples:
292 * 1. User can browse courses everywhere - return self::top() - pseudo-category with id=0
293 * 2. User does not have capability to browse courses on the system level but
294 * has it in ONE course category - return this course category
295 * 3. User has capability to browse courses in two course categories - return self::top()
296 *
297 * @return core_course_category|null
298 */
299 public static function user_top() {
300 $children = self::top()->get_children();
301 if (count($children) == 1) {
302 // User has access to only one category on the top level. Return this category as "user top category".
303 return reset($children);
304 }
305 if (count($children) > 1) {
306 // User has access to more than one category on the top level. Return the top as "user top category".
3bc57350 307 // In this case user actually may not have capability 'moodle/category:viewcourselist' on the top level.
beff3806
MG
308 return self::top();
309 }
310 // User can not access any categories on the top level.
311 // TODO MDL-10965 find ANY/ALL categories in the tree where user has access to.
312 return self::get(0, IGNORE_MISSING);
442f12f8
MG
313 }
314
315 /**
316 * Load many core_course_category objects.
317 *
318 * @param array $ids An array of category ID's to load.
319 * @return core_course_category[]
320 */
321 public static function get_many(array $ids) {
322 global $DB;
323 $coursecatrecordcache = cache::make('core', 'coursecatrecords');
324 $categories = $coursecatrecordcache->get_many($ids);
325 $toload = array();
326 foreach ($categories as $id => $result) {
327 if ($result === false) {
328 $toload[] = $id;
329 }
330 }
331 if (!empty($toload)) {
332 list($where, $params) = $DB->get_in_or_equal($toload, SQL_PARAMS_NAMED);
333 $records = self::get_records('cc.id '.$where, $params);
334 $toset = array();
335 foreach ($records as $record) {
336 $categories[$record->id] = new self($record);
337 $toset[$record->id] = $categories[$record->id];
338 }
339 $coursecatrecordcache->set_many($toset);
340 }
341 return $categories;
342 }
343
344 /**
345 * Load all core_course_category objects.
346 *
347 * @param array $options Options:
348 * - returnhidden Return categories even if they are hidden
349 * @return core_course_category[]
350 */
351 public static function get_all($options = []) {
352 global $DB;
353
354 $coursecatrecordcache = cache::make('core', 'coursecatrecords');
355
356 $catcontextsql = \context_helper::get_preload_record_columns_sql('ctx');
357 $catsql = "SELECT cc.*, {$catcontextsql}
358 FROM {course_categories} cc
359 JOIN {context} ctx ON cc.id = ctx.instanceid";
360 $catsqlwhere = "WHERE ctx.contextlevel = :contextlevel";
361 $catsqlorder = "ORDER BY cc.depth ASC, cc.sortorder ASC";
362
363 $catrs = $DB->get_recordset_sql("{$catsql} {$catsqlwhere} {$catsqlorder}", [
364 'contextlevel' => CONTEXT_COURSECAT,
365 ]);
366
367 $types['categories'] = [];
368 $categories = [];
369 $toset = [];
370 foreach ($catrs as $record) {
371 $category = new self($record);
372 $toset[$category->id] = $category;
373
374 if (!empty($options['returnhidden']) || $category->is_uservisible()) {
375 $categories[$record->id] = $category;
376 }
377 }
378 $catrs->close();
379
380 $coursecatrecordcache->set_many($toset);
381
382 return $categories;
383
384 }
385
386 /**
387 * Returns the first found category
388 *
389 * Note that if there are no categories visible to the current user on the first level,
390 * the invisible category may be returned
391 *
392 * @return core_course_category
393 */
394 public static function get_default() {
beff3806 395 if ($visiblechildren = self::top()->get_children()) {
442f12f8
MG
396 $defcategory = reset($visiblechildren);
397 } else {
398 $toplevelcategories = self::get_tree(0);
399 $defcategoryid = $toplevelcategories[0];
400 $defcategory = self::get($defcategoryid, MUST_EXIST, true);
401 }
402 return $defcategory;
403 }
404
405 /**
406 * Restores the object after it has been externally modified in DB for example
407 * during {@link fix_course_sortorder()}
408 */
409 protected function restore() {
beff3806
MG
410 if (!$this->id) {
411 return;
412 }
442f12f8
MG
413 // Update all fields in the current object.
414 $newrecord = self::get($this->id, MUST_EXIST, true);
415 foreach (self::$coursecatfields as $key => $unused) {
416 $this->$key = $newrecord->$key;
417 }
418 }
419
420 /**
421 * Creates a new category either from form data or from raw data
422 *
423 * Please note that this function does not verify access control.
424 *
425 * Exception is thrown if name is missing or idnumber is duplicating another one in the system.
426 *
427 * Category visibility is inherited from parent unless $data->visible = 0 is specified
428 *
429 * @param array|stdClass $data
430 * @param array $editoroptions if specified, the data is considered to be
431 * form data and file_postupdate_standard_editor() is being called to
432 * process images in description.
433 * @return core_course_category
434 * @throws moodle_exception
435 */
436 public static function create($data, $editoroptions = null) {
437 global $DB, $CFG;
438 $data = (object)$data;
439 $newcategory = new stdClass();
440
441 $newcategory->descriptionformat = FORMAT_MOODLE;
442 $newcategory->description = '';
443 // Copy all description* fields regardless of whether this is form data or direct field update.
444 foreach ($data as $key => $value) {
445 if (preg_match("/^description/", $key)) {
446 $newcategory->$key = $value;
447 }
448 }
449
450 if (empty($data->name)) {
451 throw new moodle_exception('categorynamerequired');
452 }
453 if (core_text::strlen($data->name) > 255) {
454 throw new moodle_exception('categorytoolong');
455 }
456 $newcategory->name = $data->name;
457
458 // Validate and set idnumber.
459 if (isset($data->idnumber)) {
460 if (core_text::strlen($data->idnumber) > 100) {
461 throw new moodle_exception('idnumbertoolong');
462 }
463 if (strval($data->idnumber) !== '' && $DB->record_exists('course_categories', array('idnumber' => $data->idnumber))) {
464 throw new moodle_exception('categoryidnumbertaken');
465 }
466 $newcategory->idnumber = $data->idnumber;
467 }
468
469 if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
470 $newcategory->theme = $data->theme;
471 }
472
473 if (empty($data->parent)) {
beff3806 474 $parent = self::top();
442f12f8
MG
475 } else {
476 $parent = self::get($data->parent, MUST_EXIST, true);
477 }
478 $newcategory->parent = $parent->id;
479 $newcategory->depth = $parent->depth + 1;
480
481 // By default category is visible, unless visible = 0 is specified or parent category is hidden.
482 if (isset($data->visible) && !$data->visible) {
483 // Create a hidden category.
484 $newcategory->visible = $newcategory->visibleold = 0;
485 } else {
486 // Create a category that inherits visibility from parent.
487 $newcategory->visible = $parent->visible;
488 // In case parent is hidden, when it changes visibility this new subcategory will automatically become visible too.
489 $newcategory->visibleold = 1;
490 }
491
492 $newcategory->sortorder = 0;
493 $newcategory->timemodified = time();
494
495 $newcategory->id = $DB->insert_record('course_categories', $newcategory);
496
497 // Update path (only possible after we know the category id.
498 $path = $parent->path . '/' . $newcategory->id;
499 $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));
500
442f12f8
MG
501 fix_course_sortorder();
502
503 // If this is data from form results, save embedded files and update description.
504 $categorycontext = context_coursecat::instance($newcategory->id);
505 if ($editoroptions) {
506 $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext,
507 'coursecat', 'description', 0);
508
509 // Update only fields description and descriptionformat.
510 $updatedata = new stdClass();
511 $updatedata->id = $newcategory->id;
512 $updatedata->description = $newcategory->description;
513 $updatedata->descriptionformat = $newcategory->descriptionformat;
514 $DB->update_record('course_categories', $updatedata);
515 }
516
517 $event = \core\event\course_category_created::create(array(
518 'objectid' => $newcategory->id,
519 'context' => $categorycontext
520 ));
521 $event->trigger();
522
523 cache_helper::purge_by_event('changesincoursecat');
524
525 return self::get($newcategory->id, MUST_EXIST, true);
526 }
527
528 /**
529 * Updates the record with either form data or raw data
530 *
531 * Please note that this function does not verify access control.
532 *
533 * This function calls core_course_category::change_parent_raw if field 'parent' is updated.
534 * It also calls core_course_category::hide_raw or core_course_category::show_raw if 'visible' is updated.
535 * Visibility is changed first and then parent is changed. This means that
536 * if parent category is hidden, the current category will become hidden
537 * too and it may overwrite whatever was set in field 'visible'.
538 *
539 * Note that fields 'path' and 'depth' can not be updated manually
540 * Also core_course_category::update() can not directly update the field 'sortoder'
541 *
542 * @param array|stdClass $data
543 * @param array $editoroptions if specified, the data is considered to be
544 * form data and file_postupdate_standard_editor() is being called to
545 * process images in description.
546 * @throws moodle_exception
547 */
548 public function update($data, $editoroptions = null) {
549 global $DB, $CFG;
550 if (!$this->id) {
551 // There is no actual DB record associated with root category.
552 return;
553 }
554
555 $data = (object)$data;
556 $newcategory = new stdClass();
557 $newcategory->id = $this->id;
558
559 // Copy all description* fields regardless of whether this is form data or direct field update.
560 foreach ($data as $key => $value) {
561 if (preg_match("/^description/", $key)) {
562 $newcategory->$key = $value;
563 }
564 }
565
566 if (isset($data->name) && empty($data->name)) {
567 throw new moodle_exception('categorynamerequired');
568 }
569
570 if (!empty($data->name) && $data->name !== $this->name) {
571 if (core_text::strlen($data->name) > 255) {
572 throw new moodle_exception('categorytoolong');
573 }
574 $newcategory->name = $data->name;
575 }
576
577 if (isset($data->idnumber) && $data->idnumber !== $this->idnumber) {
578 if (core_text::strlen($data->idnumber) > 100) {
579 throw new moodle_exception('idnumbertoolong');
580 }
581 if (strval($data->idnumber) !== '' && $DB->record_exists('course_categories', array('idnumber' => $data->idnumber))) {
582 throw new moodle_exception('categoryidnumbertaken');
583 }
584 $newcategory->idnumber = $data->idnumber;
585 }
586
587 if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
588 $newcategory->theme = $data->theme;
589 }
590
591 $changes = false;
592 if (isset($data->visible)) {
593 if ($data->visible) {
594 $changes = $this->show_raw();
595 } else {
596 $changes = $this->hide_raw(0);
597 }
598 }
599
600 if (isset($data->parent) && $data->parent != $this->parent) {
601 if ($changes) {
602 cache_helper::purge_by_event('changesincoursecat');
603 }
604 $parentcat = self::get($data->parent, MUST_EXIST, true);
605 $this->change_parent_raw($parentcat);
606 fix_course_sortorder();
607 }
608
609 $newcategory->timemodified = time();
610
611 $categorycontext = $this->get_context();
612 if ($editoroptions) {
613 $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext,
614 'coursecat', 'description', 0);
615 }
616 $DB->update_record('course_categories', $newcategory);
617
618 $event = \core\event\course_category_updated::create(array(
619 'objectid' => $newcategory->id,
620 'context' => $categorycontext
621 ));
622 $event->trigger();
623
624 fix_course_sortorder();
625 // Purge cache even if fix_course_sortorder() did not do it.
626 cache_helper::purge_by_event('changesincoursecat');
627
628 // Update all fields in the current object.
629 $this->restore();
630 }
631
632
633 /**
634 * Checks if this course category is visible to a user.
635 *
636 * Please note that methods core_course_category::get (without 3rd argumet),
637 * core_course_category::get_children(), etc. return only visible categories so it is
638 * usually not needed to call this function outside of this class
639 *
640 * @param int|stdClass $user The user id or object. By default (null) checks the visibility to the current user.
641 * @return bool
642 */
643 public function is_uservisible($user = null) {
beff3806
MG
644 return self::can_view_category($this, $user);
645 }
646
647 /**
648 * Checks if current user has access to the category
649 *
650 * @param stdClass|core_course_category $category
651 * @param int|stdClass $user The user id or object. By default (null) checks access for the current user.
652 * @return bool
653 */
654 public static function can_view_category($category, $user = null) {
655 if (!$category->id) {
3bc57350 656 return has_capability('moodle/category:viewcourselist', context_system::instance(), $user);
beff3806
MG
657 }
658 $context = context_coursecat::instance($category->id);
659 if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context, $user)) {
660 return false;
661 }
3bc57350 662 return has_capability('moodle/category:viewcourselist', $context, $user);
beff3806
MG
663 }
664
665 /**
666 * Checks if current user can view course information or enrolment page.
667 *
668 * This method does not check if user is already enrolled in the course
669 *
670 * @param stdClass $course course object (must have 'id', 'visible' and 'category' fields)
671 * @param null|stdClass $user The user id or object. By default (null) checks access for the current user.
672 */
673 public static function can_view_course_info($course, $user = null) {
674 if ($course->id == SITEID) {
675 return true;
676 }
677 if (!$course->visible) {
678 $coursecontext = context_course::instance($course->id);
679 if (!has_capability('moodle/course:viewhiddencourses', $coursecontext, $user)) {
680 return false;
681 }
682 }
683 $categorycontext = isset($course->category) ? context_coursecat::instance($course->category) :
684 context_course::instance($course->id)->get_parent_context();
3bc57350 685 return has_capability('moodle/category:viewcourselist', $categorycontext, $user);
442f12f8
MG
686 }
687
688 /**
689 * Returns the complete corresponding record from DB table course_categories
690 *
691 * Mostly used in deprecated functions
692 *
693 * @return stdClass
694 */
695 public function get_db_record() {
696 global $DB;
697 if ($record = $DB->get_record('course_categories', array('id' => $this->id))) {
698 return $record;
699 } else {
700 return (object)convert_to_array($this);
701 }
702 }
703
704 /**
705 * Returns the entry from categories tree and makes sure the application-level tree cache is built
706 *
707 * The following keys can be requested:
708 *
709 * 'countall' - total number of categories in the system (always present)
710 * 0 - array of ids of top-level categories (always present)
711 * '0i' - array of ids of top-level categories that have visible=0 (always present but may be empty array)
712 * $id (int) - array of ids of categories that are direct children of category with id $id. If
713 * category with id $id does not exist returns false. If category has no children returns empty array
714 * $id.'i' - array of ids of children categories that have visible=0
715 *
716 * @param int|string $id
717 * @return mixed
718 */
719 protected static function get_tree($id) {
442f12f8
MG
720 $coursecattreecache = cache::make('core', 'coursecattree');
721 $rv = $coursecattreecache->get($id);
722 if ($rv !== false) {
723 return $rv;
724 }
96e44049
MJ
725 // Might need to rebuild the tree. Put a lock in place to ensure other requests don't try and do this in parallel.
726 $lockfactory = \core\lock\lock_config::get_lock_factory('core_coursecattree');
727 $lock = $lockfactory->get_lock('core_coursecattree_cache',
728 course_modinfo::COURSE_CACHE_LOCK_WAIT, course_modinfo::COURSE_CACHE_LOCK_EXPIRY);
729 if ($lock === false) {
730 // Couldn't get a lock to rebuild the tree.
731 return [];
732 }
733 $rv = $coursecattreecache->get($id);
734 if ($rv !== false) {
735 // Tree was built while we were waiting for the lock.
736 $lock->release();
737 return $rv;
738 }
442f12f8 739 // Re-build the tree.
96e44049
MJ
740 try {
741 $all = self::rebuild_coursecattree_cache_contents();
742 $coursecattreecache->set_many($all);
743 } finally {
744 $lock->release();
745 }
746 if (array_key_exists($id, $all)) {
747 return $all[$id];
748 }
749 // Requested non-existing category.
750 return array();
751 }
752
753 /**
754 * Rebuild the course category tree as an array, including an extra "countall" field.
755 *
756 * @return array
757 * @throws coding_exception
758 * @throws dml_exception
759 * @throws moodle_exception
760 */
761 private static function rebuild_coursecattree_cache_contents() : array {
762 global $DB;
442f12f8
MG
763 $sql = "SELECT cc.id, cc.parent, cc.visible
764 FROM {course_categories} cc
765 ORDER BY cc.sortorder";
766 $rs = $DB->get_recordset_sql($sql, array());
767 $all = array(0 => array(), '0i' => array());
768 $count = 0;
769 foreach ($rs as $record) {
770 $all[$record->id] = array();
771 $all[$record->id. 'i'] = array();
772 if (array_key_exists($record->parent, $all)) {
773 $all[$record->parent][] = $record->id;
774 if (!$record->visible) {
775 $all[$record->parent. 'i'][] = $record->id;
776 }
777 } else {
778 // Parent not found. This is data consistency error but next fix_course_sortorder() should fix it.
779 $all[0][] = $record->id;
780 if (!$record->visible) {
781 $all['0i'][] = $record->id;
782 }
783 }
784 $count++;
785 }
786 $rs->close();
787 if (!$count) {
788 // No categories found.
789 // This may happen after upgrade of a very old moodle version.
790 // In new versions the default category is created on install.
791 $defcoursecat = self::create(array('name' => get_string('miscellaneous')));
792 set_config('defaultrequestcategory', $defcoursecat->id);
793 $all[0] = array($defcoursecat->id);
794 $all[$defcoursecat->id] = array();
795 $count++;
796 }
797 // We must add countall to all in case it was the requested ID.
798 $all['countall'] = $count;
96e44049 799 return $all;
442f12f8
MG
800 }
801
802 /**
803 * Returns number of ALL categories in the system regardless if
804 * they are visible to current user or not
805 *
beff3806 806 * @deprecated since Moodle 3.7
442f12f8
MG
807 * @return int
808 */
809 public static function count_all() {
beff3806
MG
810 debugging('Method core_course_category::count_all() is deprecated. Please use ' .
811 'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
442f12f8
MG
812 return self::get_tree('countall');
813 }
814
beff3806
MG
815 /**
816 * Checks if the site has only one category and it is visible and available.
817 *
818 * In many situations we won't show this category at all
819 * @return bool
820 */
821 public static function is_simple_site() {
822 if (self::get_tree('countall') != 1) {
823 return false;
824 }
825 $default = self::get_default();
826 return $default->visible && $default->is_uservisible();
827 }
828
442f12f8
MG
829 /**
830 * Retrieves number of records from course_categories table
831 *
832 * Only cached fields are retrieved. Records are ready for preloading context
833 *
834 * @param string $whereclause
835 * @param array $params
836 * @return array array of stdClass objects
837 */
838 protected static function get_records($whereclause, $params) {
839 global $DB;
840 // Retrieve from DB only the fields that need to be stored in cache.
841 $fields = array_keys(array_filter(self::$coursecatfields));
842 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
843 $sql = "SELECT cc.". join(',cc.', $fields). ", $ctxselect
844 FROM {course_categories} cc
845 JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
846 WHERE ". $whereclause." ORDER BY cc.sortorder";
847 return $DB->get_records_sql($sql,
848 array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
849 }
850
851 /**
852 * Resets course contact caches when role assignments were changed
853 *
854 * @param int $roleid role id that was given or taken away
855 * @param context $context context where role assignment has been changed
856 */
857 public static function role_assignment_changed($roleid, $context) {
858 global $CFG, $DB;
859
860 if ($context->contextlevel > CONTEXT_COURSE) {
861 // No changes to course contacts if role was assigned on the module/block level.
862 return;
863 }
864
865 // Trigger a purge for all caches listening for changes to category enrolment.
866 cache_helper::purge_by_event('changesincategoryenrolment');
867
868 if (!$CFG->coursecontact || !in_array($roleid, explode(',', $CFG->coursecontact))) {
869 // The role is not one of course contact roles.
870 return;
871 }
872
873 // Remove from cache course contacts of all affected courses.
874 $cache = cache::make('core', 'coursecontacts');
875 if ($context->contextlevel == CONTEXT_COURSE) {
876 $cache->delete($context->instanceid);
877 } else if ($context->contextlevel == CONTEXT_SYSTEM) {
878 $cache->purge();
879 } else {
880 $sql = "SELECT ctx.instanceid
881 FROM {context} ctx
882 WHERE ctx.path LIKE ? AND ctx.contextlevel = ?";
883 $params = array($context->path . '/%', CONTEXT_COURSE);
884 if ($courses = $DB->get_fieldset_sql($sql, $params)) {
885 $cache->delete_many($courses);
886 }
887 }
888 }
889
890 /**
891 * Executed when user enrolment was changed to check if course
892 * contacts cache needs to be cleared
893 *
894 * @param int $courseid course id
895 * @param int $userid user id
896 * @param int $status new enrolment status (0 - active, 1 - suspended)
897 * @param int $timestart new enrolment time start
898 * @param int $timeend new enrolment time end
899 */
900 public static function user_enrolment_changed($courseid, $userid,
901 $status, $timestart = null, $timeend = null) {
902 $cache = cache::make('core', 'coursecontacts');
903 $contacts = $cache->get($courseid);
904 if ($contacts === false) {
905 // The contacts for the affected course were not cached anyway.
906 return;
907 }
908 $enrolmentactive = ($status == 0) &&
909 (!$timestart || $timestart < time()) &&
910 (!$timeend || $timeend > time());
911 if (!$enrolmentactive) {
912 $isincontacts = false;
913 foreach ($contacts as $contact) {
914 if ($contact->id == $userid) {
915 $isincontacts = true;
916 }
917 }
918 if (!$isincontacts) {
919 // Changed user's enrolment does not exist or is not active,
920 // and he is not in cached course contacts, no changes to be made.
921 return;
922 }
923 }
924 // Either enrolment of manager was deleted/suspended
925 // or user enrolment was added or activated.
926 // In order to see if the course contacts for this course need
927 // changing we would need to make additional queries, they will
928 // slow down bulk enrolment changes. It is better just to remove
929 // course contacts cache for this course.
930 $cache->delete($courseid);
931 }
932
933 /**
934 * Given list of DB records from table course populates each record with list of users with course contact roles
935 *
936 * This function fills the courses with raw information as {@link get_role_users()} would do.
937 * See also {@link core_course_list_element::get_course_contacts()} for more readable return
938 *
939 * $courses[$i]->managers = array(
940 * $roleassignmentid => $roleuser,
941 * ...
942 * );
943 *
944 * where $roleuser is an stdClass with the following properties:
945 *
946 * $roleuser->raid - role assignment id
947 * $roleuser->id - user id
948 * $roleuser->username
949 * $roleuser->firstname
950 * $roleuser->lastname
951 * $roleuser->rolecoursealias
952 * $roleuser->rolename
953 * $roleuser->sortorder - role sortorder
954 * $roleuser->roleid
955 * $roleuser->roleshortname
956 *
957 * @todo MDL-38596 minimize number of queries to preload contacts for the list of courses
958 *
959 * @param array $courses
960 */
961 public static function preload_course_contacts(&$courses) {
962 global $CFG, $DB;
963 if (empty($courses) || empty($CFG->coursecontact)) {
964 return;
965 }
966 $managerroles = explode(',', $CFG->coursecontact);
967 $cache = cache::make('core', 'coursecontacts');
968 $cacheddata = $cache->get_many(array_keys($courses));
969 $courseids = array();
970 foreach (array_keys($courses) as $id) {
971 if ($cacheddata[$id] !== false) {
972 $courses[$id]->managers = $cacheddata[$id];
973 } else {
974 $courseids[] = $id;
975 }
976 }
977
978 // Array $courseids now stores list of ids of courses for which we still need to retrieve contacts.
979 if (empty($courseids)) {
980 return;
981 }
982
983 // First build the array of all context ids of the courses and their categories.
984 $allcontexts = array();
985 foreach ($courseids as $id) {
986 $context = context_course::instance($id);
987 $courses[$id]->managers = array();
988 foreach (preg_split('|/|', $context->path, 0, PREG_SPLIT_NO_EMPTY) as $ctxid) {
989 if (!isset($allcontexts[$ctxid])) {
990 $allcontexts[$ctxid] = array();
991 }
992 $allcontexts[$ctxid][] = $id;
993 }
994 }
995
996 // Fetch list of all users with course contact roles in any of the courses contexts or parent contexts.
997 list($sql1, $params1) = $DB->get_in_or_equal(array_keys($allcontexts), SQL_PARAMS_NAMED, 'ctxid');
998 list($sql2, $params2) = $DB->get_in_or_equal($managerroles, SQL_PARAMS_NAMED, 'rid');
999 list($sort, $sortparams) = users_order_by_sql('u');
1000 $notdeleted = array('notdeleted' => 0);
1001 $allnames = get_all_user_name_fields(true, 'u');
1002 $sql = "SELECT ra.contextid, ra.id AS raid,
1003 r.id AS roleid, r.name AS rolename, r.shortname AS roleshortname,
1004 rn.name AS rolecoursealias, u.id, u.username, $allnames
1005 FROM {role_assignments} ra
1006 JOIN {user} u ON ra.userid = u.id
1007 JOIN {role} r ON ra.roleid = r.id
1008 LEFT JOIN {role_names} rn ON (rn.contextid = ra.contextid AND rn.roleid = r.id)
1009 WHERE ra.contextid ". $sql1." AND ra.roleid ". $sql2." AND u.deleted = :notdeleted
1010 ORDER BY r.sortorder, $sort";
1011 $rs = $DB->get_recordset_sql($sql, $params1 + $params2 + $notdeleted + $sortparams);
1012 $checkenrolments = array();
1013 foreach ($rs as $ra) {
1014 foreach ($allcontexts[$ra->contextid] as $id) {
1015 $courses[$id]->managers[$ra->raid] = $ra;
1016 if (!isset($checkenrolments[$id])) {
1017 $checkenrolments[$id] = array();
1018 }
1019 $checkenrolments[$id][] = $ra->id;
1020 }
1021 }
1022 $rs->close();
1023
1024 // Remove from course contacts users who are not enrolled in the course.
1025 $enrolleduserids = self::ensure_users_enrolled($checkenrolments);
1026 foreach ($checkenrolments as $id => $userids) {
1027 if (empty($enrolleduserids[$id])) {
1028 $courses[$id]->managers = array();
1029 } else if ($notenrolled = array_diff($userids, $enrolleduserids[$id])) {
1030 foreach ($courses[$id]->managers as $raid => $ra) {
1031 if (in_array($ra->id, $notenrolled)) {
1032 unset($courses[$id]->managers[$raid]);
1033 }
1034 }
1035 }
1036 }
1037
1038 // Set the cache.
1039 $values = array();
1040 foreach ($courseids as $id) {
1041 $values[$id] = $courses[$id]->managers;
1042 }
1043 $cache->set_many($values);
1044 }
1045
c1e15d20
MG
1046 /**
1047 * Preloads the custom fields values in bulk
1048 *
1049 * @param array $records
1050 */
1051 public static function preload_custom_fields(array &$records) {
1052 $customfields = \core_course\customfield\course_handler::create()->get_instances_data(array_keys($records));
1053 foreach ($customfields as $courseid => $data) {
1054 $records[$courseid]->customfields = $data;
1055 }
1056 }
1057
442f12f8
MG
1058 /**
1059 * Verify user enrollments for multiple course-user combinations
1060 *
1061 * @param array $courseusers array where keys are course ids and values are array
1062 * of users in this course whose enrolment we wish to verify
1063 * @return array same structure as input array but values list only users from input
1064 * who are enrolled in the course
1065 */
1066 protected static function ensure_users_enrolled($courseusers) {
1067 global $DB;
1068 // If the input array is too big, split it into chunks.
1069 $maxcoursesinquery = 20;
1070 if (count($courseusers) > $maxcoursesinquery) {
1071 $rv = array();
1072 for ($offset = 0; $offset < count($courseusers); $offset += $maxcoursesinquery) {
1073 $chunk = array_slice($courseusers, $offset, $maxcoursesinquery, true);
1074 $rv = $rv + self::ensure_users_enrolled($chunk);
1075 }
1076 return $rv;
1077 }
1078
1079 // Create a query verifying valid user enrolments for the number of courses.
1080 $sql = "SELECT DISTINCT e.courseid, ue.userid
1081 FROM {user_enrolments} ue
1082 JOIN {enrol} e ON e.id = ue.enrolid
1083 WHERE ue.status = :active
1084 AND e.status = :enabled
1085 AND ue.timestart < :now1 AND (ue.timeend = 0 OR ue.timeend > :now2)";
1086 $now = round(time(), -2); // Rounding helps caching in DB.
1087 $params = array('enabled' => ENROL_INSTANCE_ENABLED,
1088 'active' => ENROL_USER_ACTIVE,
1089 'now1' => $now, 'now2' => $now);
1090 $cnt = 0;
1091 $subsqls = array();
1092 $enrolled = array();
1093 foreach ($courseusers as $id => $userids) {
1094 $enrolled[$id] = array();
1095 if (count($userids)) {
1096 list($sql2, $params2) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'userid'.$cnt.'_');
1097 $subsqls[] = "(e.courseid = :courseid$cnt AND ue.userid ".$sql2.")";
1098 $params = $params + array('courseid'.$cnt => $id) + $params2;
1099 $cnt++;
1100 }
1101 }
1102 if (count($subsqls)) {
1103 $sql .= "AND (". join(' OR ', $subsqls).")";
1104 $rs = $DB->get_recordset_sql($sql, $params);
1105 foreach ($rs as $record) {
1106 $enrolled[$record->courseid][] = $record->userid;
1107 }
1108 $rs->close();
1109 }
1110 return $enrolled;
1111 }
1112
1113 /**
1114 * Retrieves number of records from course table
1115 *
1116 * Not all fields are retrieved. Records are ready for preloading context
1117 *
1118 * @param string $whereclause
1119 * @param array $params
beff3806 1120 * @param array $options may indicate that summary needs to be retrieved
442f12f8 1121 * @param bool $checkvisibility if true, capability 'moodle/course:viewhiddencourses' will be checked
3bc57350 1122 * on not visible courses and 'moodle/category:viewcourselist' on all courses
442f12f8
MG
1123 * @return array array of stdClass objects
1124 */
1125 protected static function get_course_records($whereclause, $params, $options, $checkvisibility = false) {
1126 global $DB;
1127 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
1128 $fields = array('c.id', 'c.category', 'c.sortorder',
1129 'c.shortname', 'c.fullname', 'c.idnumber',
1130 'c.startdate', 'c.enddate', 'c.visible', 'c.cacherev');
1131 if (!empty($options['summary'])) {
1132 $fields[] = 'c.summary';
1133 $fields[] = 'c.summaryformat';
1134 } else {
1135 $fields[] = $DB->sql_substr('c.summary', 1, 1). ' as hassummary';
1136 }
1137 $sql = "SELECT ". join(',', $fields). ", $ctxselect
1138 FROM {course} c
1139 JOIN {context} ctx ON c.id = ctx.instanceid AND ctx.contextlevel = :contextcourse
1140 WHERE ". $whereclause." ORDER BY c.sortorder";
1141 $list = $DB->get_records_sql($sql,
1142 array('contextcourse' => CONTEXT_COURSE) + $params);
1143
1144 if ($checkvisibility) {
beff3806 1145 $mycourses = enrol_get_my_courses();
442f12f8
MG
1146 // Loop through all records and make sure we only return the courses accessible by user.
1147 foreach ($list as $course) {
1148 if (isset($list[$course->id]->hassummary)) {
1149 $list[$course->id]->hassummary = strlen($list[$course->id]->hassummary) > 0;
1150 }
beff3806
MG
1151 context_helper::preload_from_record($course);
1152 $context = context_course::instance($course->id);
1153 // Check that course is accessible by user.
1154 if (!array_key_exists($course->id, $mycourses) && !self::can_view_course_info($course)) {
1155 unset($list[$course->id]);
442f12f8
MG
1156 }
1157 }
1158 }
1159
442f12f8
MG
1160 return $list;
1161 }
1162
1163 /**
1164 * Returns array of ids of children categories that current user can not see
1165 *
1166 * This data is cached in user session cache
1167 *
1168 * @return array
1169 */
1170 protected function get_not_visible_children_ids() {
1171 global $DB;
1172 $coursecatcache = cache::make('core', 'coursecat');
1173 if (($invisibleids = $coursecatcache->get('ic'. $this->id)) === false) {
1174 // We never checked visible children before.
1175 $hidden = self::get_tree($this->id.'i');
beff3806 1176 $catids = self::get_tree($this->id);
442f12f8 1177 $invisibleids = array();
beff3806 1178 if ($catids) {
442f12f8 1179 // Preload categories contexts.
beff3806 1180 list($sql, $params) = $DB->get_in_or_equal($catids, SQL_PARAMS_NAMED, 'id');
442f12f8
MG
1181 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
1182 $contexts = $DB->get_records_sql("SELECT $ctxselect FROM {context} ctx
1183 WHERE ctx.contextlevel = :contextcoursecat AND ctx.instanceid ".$sql,
1184 array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
1185 foreach ($contexts as $record) {
1186 context_helper::preload_from_record($record);
1187 }
beff3806
MG
1188 // Check access for each category.
1189 foreach ($catids as $id) {
1190 $cat = (object)['id' => $id, 'visible' => in_array($id, $hidden) ? 0 : 1];
1191 if (!self::can_view_category($cat)) {
442f12f8
MG
1192 $invisibleids[] = $id;
1193 }
1194 }
1195 }
1196 $coursecatcache->set('ic'. $this->id, $invisibleids);
1197 }
1198 return $invisibleids;
1199 }
1200
1201 /**
1202 * Sorts list of records by several fields
1203 *
1204 * @param array $records array of stdClass objects
1205 * @param array $sortfields assoc array where key is the field to sort and value is 1 for asc or -1 for desc
1206 * @return int
1207 */
1208 protected static function sort_records(&$records, $sortfields) {
1209 if (empty($records)) {
1210 return;
1211 }
1212 // If sorting by course display name, calculate it (it may be fullname or shortname+fullname).
1213 if (array_key_exists('displayname', $sortfields)) {
1214 foreach ($records as $key => $record) {
1215 if (!isset($record->displayname)) {
1216 $records[$key]->displayname = get_course_display_name_for_list($record);
1217 }
1218 }
1219 }
1220 // Sorting by one field - use core_collator.
1221 if (count($sortfields) == 1) {
1222 $property = key($sortfields);
1223 if (in_array($property, array('sortorder', 'id', 'visible', 'parent', 'depth'))) {
1224 $sortflag = core_collator::SORT_NUMERIC;
1225 } else if (in_array($property, array('idnumber', 'displayname', 'name', 'shortname', 'fullname'))) {
1226 $sortflag = core_collator::SORT_STRING;
1227 } else {
1228 $sortflag = core_collator::SORT_REGULAR;
1229 }
1230 core_collator::asort_objects_by_property($records, $property, $sortflag);
1231 if ($sortfields[$property] < 0) {
1232 $records = array_reverse($records, true);
1233 }
1234 return;
1235 }
1236
1237 // Sort by multiple fields - use custom sorting.
1238 uasort($records, function($a, $b) use ($sortfields) {
1239 foreach ($sortfields as $field => $mult) {
1240 // Nulls first.
1241 if (is_null($a->$field) && !is_null($b->$field)) {
1242 return -$mult;
1243 }
1244 if (is_null($b->$field) && !is_null($a->$field)) {
1245 return $mult;
1246 }
1247
1248 if (is_string($a->$field) || is_string($b->$field)) {
1249 // String fields.
1250 if ($cmp = strcoll($a->$field, $b->$field)) {
1251 return $mult * $cmp;
1252 }
1253 } else {
1254 // Int fields.
1255 if ($a->$field > $b->$field) {
1256 return $mult;
1257 }
1258 if ($a->$field < $b->$field) {
1259 return -$mult;
1260 }
1261 }
1262 }
1263 return 0;
1264 });
1265 }
1266
1267 /**
1268 * Returns array of children categories visible to the current user
1269 *
1270 * @param array $options options for retrieving children
1271 * - sort - list of fields to sort. Example
1272 * array('idnumber' => 1, 'name' => 1, 'id' => -1)
1273 * will sort by idnumber asc, name asc and id desc.
1274 * Default: array('sortorder' => 1)
1275 * Only cached fields may be used for sorting!
1276 * - offset
1277 * - limit - maximum number of children to return, 0 or null for no limit
1278 * @return core_course_category[] Array of core_course_category objects indexed by category id
1279 */
1280 public function get_children($options = array()) {
1281 global $DB;
1282 $coursecatcache = cache::make('core', 'coursecat');
1283
1284 // Get default values for options.
1285 if (!empty($options['sort']) && is_array($options['sort'])) {
1286 $sortfields = $options['sort'];
1287 } else {
1288 $sortfields = array('sortorder' => 1);
1289 }
1290 $limit = null;
1291 if (!empty($options['limit']) && (int)$options['limit']) {
1292 $limit = (int)$options['limit'];
1293 }
1294 $offset = 0;
1295 if (!empty($options['offset']) && (int)$options['offset']) {
1296 $offset = (int)$options['offset'];
1297 }
1298
1299 // First retrieve list of user-visible and sorted children ids from cache.
1300 $sortedids = $coursecatcache->get('c'. $this->id. ':'. serialize($sortfields));
1301 if ($sortedids === false) {
1302 $sortfieldskeys = array_keys($sortfields);
1303 if ($sortfieldskeys[0] === 'sortorder') {
1304 // No DB requests required to build the list of ids sorted by sortorder.
1305 // We can easily ignore other sort fields because sortorder is always different.
1306 $sortedids = self::get_tree($this->id);
1307 if ($sortedids && ($invisibleids = $this->get_not_visible_children_ids())) {
1308 $sortedids = array_diff($sortedids, $invisibleids);
1309 if ($sortfields['sortorder'] == -1) {
1310 $sortedids = array_reverse($sortedids, true);
1311 }
1312 }
1313 } else {
1314 // We need to retrieve and sort all children. Good thing that it is done only on first request.
1315 if ($invisibleids = $this->get_not_visible_children_ids()) {
1316 list($sql, $params) = $DB->get_in_or_equal($invisibleids, SQL_PARAMS_NAMED, 'id', false);
1317 $records = self::get_records('cc.parent = :parent AND cc.id '. $sql,
1318 array('parent' => $this->id) + $params);
1319 } else {
1320 $records = self::get_records('cc.parent = :parent', array('parent' => $this->id));
1321 }
1322 self::sort_records($records, $sortfields);
1323 $sortedids = array_keys($records);
1324 }
1325 $coursecatcache->set('c'. $this->id. ':'.serialize($sortfields), $sortedids);
1326 }
1327
1328 if (empty($sortedids)) {
1329 return array();
1330 }
1331
1332 // Now retrieive and return categories.
1333 if ($offset || $limit) {
1334 $sortedids = array_slice($sortedids, $offset, $limit);
1335 }
1336 if (isset($records)) {
1337 // Easy, we have already retrieved records.
1338 if ($offset || $limit) {
1339 $records = array_slice($records, $offset, $limit, true);
1340 }
1341 } else {
1342 list($sql, $params) = $DB->get_in_or_equal($sortedids, SQL_PARAMS_NAMED, 'id');
1343 $records = self::get_records('cc.id '. $sql, array('parent' => $this->id) + $params);
1344 }
1345
1346 $rv = array();
1347 foreach ($sortedids as $id) {
1348 if (isset($records[$id])) {
1349 $rv[$id] = new self($records[$id]);
1350 }
1351 }
1352 return $rv;
1353 }
1354
1355 /**
1356 * Returns an array of ids of categories that are (direct and indirect) children
1357 * of this category.
1358 *
1359 * @return int[]
1360 */
1361 public function get_all_children_ids() {
1362 $children = [];
1363 $walk = [$this->id];
1364 while (count($walk) > 0) {
1365 $catid = array_pop($walk);
1366 $directchildren = self::get_tree($catid);
1367 if ($directchildren !== false && count($directchildren) > 0) {
1368 $walk = array_merge($walk, $directchildren);
1369 $children = array_merge($children, $directchildren);
1370 }
1371 }
1372
1373 return $children;
1374 }
1375
1376 /**
1377 * Returns true if the user has the manage capability on any category.
1378 *
1379 * This method uses the coursecat cache and an entry `has_manage_capability` to speed up
1380 * calls to this method.
1381 *
1382 * @return bool
1383 */
1384 public static function has_manage_capability_on_any() {
1385 return self::has_capability_on_any('moodle/category:manage');
1386 }
1387
1388 /**
1389 * Checks if the user has at least one of the given capabilities on any category.
1390 *
1391 * @param array|string $capabilities One or more capabilities to check. Check made is an OR.
1392 * @return bool
1393 */
1394 public static function has_capability_on_any($capabilities) {
1395 global $DB;
1396 if (!isloggedin() || isguestuser()) {
1397 return false;
1398 }
1399
1400 if (!is_array($capabilities)) {
1401 $capabilities = array($capabilities);
1402 }
1403 $keys = array();
1404 foreach ($capabilities as $capability) {
1405 $keys[$capability] = sha1($capability);
1406 }
1407
1408 /** @var cache_session $cache */
1409 $cache = cache::make('core', 'coursecat');
1410 $hascapability = $cache->get_many($keys);
1411 $needtoload = false;
1412 foreach ($hascapability as $capability) {
1413 if ($capability === '1') {
1414 return true;
1415 } else if ($capability === false) {
1416 $needtoload = true;
1417 }
1418 }
1419 if ($needtoload === false) {
1420 // All capabilities were retrieved and the user didn't have any.
1421 return false;
1422 }
1423
1424 $haskey = null;
1425 $fields = context_helper::get_preload_record_columns_sql('ctx');
1426 $sql = "SELECT ctx.instanceid AS categoryid, $fields
1427 FROM {context} ctx
1428 WHERE contextlevel = :contextlevel
1429 ORDER BY depth ASC";
1430 $params = array('contextlevel' => CONTEXT_COURSECAT);
1431 $recordset = $DB->get_recordset_sql($sql, $params);
1432 foreach ($recordset as $context) {
1433 context_helper::preload_from_record($context);
1434 $context = context_coursecat::instance($context->categoryid);
1435 foreach ($capabilities as $capability) {
1436 if (has_capability($capability, $context)) {
1437 $haskey = $capability;
1438 break 2;
1439 }
1440 }
1441 }
1442 $recordset->close();
1443 if ($haskey === null) {
1444 $data = array();
1445 foreach ($keys as $key) {
1446 $data[$key] = '0';
1447 }
1448 $cache->set_many($data);
1449 return false;
1450 } else {
1451 $cache->set($haskey, '1');
1452 return true;
1453 }
1454 }
1455
1456 /**
1457 * Returns true if the user can resort any category.
1458 * @return bool
1459 */
1460 public static function can_resort_any() {
1461 return self::has_manage_capability_on_any();
1462 }
1463
1464 /**
1465 * Returns true if the user can change the parent of any category.
1466 * @return bool
1467 */
1468 public static function can_change_parent_any() {
1469 return self::has_manage_capability_on_any();
1470 }
1471
1472 /**
1473 * Returns number of subcategories visible to the current user
1474 *
1475 * @return int
1476 */
1477 public function get_children_count() {
1478 $sortedids = self::get_tree($this->id);
1479 $invisibleids = $this->get_not_visible_children_ids();
1480 return count($sortedids) - count($invisibleids);
1481 }
1482
1483 /**
1484 * Returns true if the category has ANY children, including those not visible to the user
1485 *
1486 * @return boolean
1487 */
1488 public function has_children() {
1489 $allchildren = self::get_tree($this->id);
1490 return !empty($allchildren);
1491 }
1492
1493 /**
1494 * Returns true if the category has courses in it (count does not include courses
1495 * in child categories)
1496 *
1497 * @return bool
1498 */
1499 public function has_courses() {
1500 global $DB;
1501 return $DB->record_exists_sql("select 1 from {course} where category = ?",
1502 array($this->id));
1503 }
1504
1505 /**
1506 * Get the link used to view this course category.
1507 *
1508 * @return \moodle_url
1509 */
1510 public function get_view_link() {
1511 return new \moodle_url('/course/index.php', [
1512 'categoryid' => $this->id,
1513 ]);
1514 }
1515
1516 /**
1517 * Searches courses
1518 *
1519 * List of found course ids is cached for 10 minutes. Cache may be purged prior
1520 * to this when somebody edits courses or categories, however it is very
1521 * difficult to keep track of all possible changes that may affect list of courses.
1522 *
1523 * @param array $search contains search criterias, such as:
1524 * - search - search string
1525 * - blocklist - id of block (if we are searching for courses containing specific block0
1526 * - modulelist - name of module (if we are searching for courses containing specific module
1527 * - tagid - id of tag
76f2d894 1528 * - onlywithcompletion - set to true if we only need courses with completion enabled
442f12f8
MG
1529 * @param array $options display options, same as in get_courses() except 'recursive' is ignored -
1530 * search is always category-independent
1531 * @param array $requiredcapabilities List of capabilities required to see return course.
1532 * @return core_course_list_element[]
1533 */
1534 public static function search_courses($search, $options = array(), $requiredcapabilities = array()) {
1535 global $DB;
1536 $offset = !empty($options['offset']) ? $options['offset'] : 0;
1537 $limit = !empty($options['limit']) ? $options['limit'] : null;
1538 $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
1539
1540 $coursecatcache = cache::make('core', 'coursecat');
1541 $cachekey = 's-'. serialize(
1542 $search + array('sort' => $sortfields) + array('requiredcapabilities' => $requiredcapabilities)
1543 );
1544 $cntcachekey = 'scnt-'. serialize($search);
1545
1546 $ids = $coursecatcache->get($cachekey);
1547 if ($ids !== false) {
1548 // We already cached last search result.
1549 $ids = array_slice($ids, $offset, $limit);
1550 $courses = array();
1551 if (!empty($ids)) {
1552 list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id');
1553 $records = self::get_course_records("c.id ". $sql, $params, $options);
1554 // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1555 if (!empty($options['coursecontacts'])) {
1556 self::preload_course_contacts($records);
1557 }
c1e15d20
MG
1558 // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1559 if (!empty($options['customfields'])) {
1560 self::preload_custom_fields($records);
1561 }
442f12f8
MG
1562 // If option 'idonly' is specified no further action is needed, just return list of ids.
1563 if (!empty($options['idonly'])) {
1564 return array_keys($records);
1565 }
1566 // Prepare the list of core_course_list_element objects.
1567 foreach ($ids as $id) {
1568 $courses[$id] = new core_course_list_element($records[$id]);
1569 }
1570 }
1571 return $courses;
1572 }
1573
1574 $preloadcoursecontacts = !empty($options['coursecontacts']);
1575 unset($options['coursecontacts']);
1576
1577 // Empty search string will return all results.
1578 if (!isset($search['search'])) {
1579 $search['search'] = '';
1580 }
1581
1582 if (empty($search['blocklist']) && empty($search['modulelist']) && empty($search['tagid'])) {
1583 // Search courses that have specified words in their names/summaries.
1584 $searchterms = preg_split('|\s+|', trim($search['search']), 0, PREG_SPLIT_NO_EMPTY);
76f2d894
MG
1585 $searchcond = $searchcondparams = [];
1586 if (!empty($search['onlywithcompletion'])) {
1587 $searchcond = ['c.enablecompletion = :p1'];
1588 $searchcondparams = ['p1' => 1];
1589 }
1590 $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount,
1591 $requiredcapabilities, $searchcond, $searchcondparams);
442f12f8
MG
1592 self::sort_records($courselist, $sortfields);
1593 $coursecatcache->set($cachekey, array_keys($courselist));
1594 $coursecatcache->set($cntcachekey, $totalcount);
1595 $records = array_slice($courselist, $offset, $limit, true);
1596 } else {
1597 if (!empty($search['blocklist'])) {
1598 // Search courses that have block with specified id.
1599 $blockname = $DB->get_field('block', 'name', array('id' => $search['blocklist']));
1600 $where = 'ctx.id in (SELECT distinct bi.parentcontextid FROM {block_instances} bi
1601 WHERE bi.blockname = :blockname)';
1602 $params = array('blockname' => $blockname);
1603 } else if (!empty($search['modulelist'])) {
1604 // Search courses that have module with specified name.
1605 $where = "c.id IN (SELECT DISTINCT module.course ".
1606 "FROM {".$search['modulelist']."} module)";
1607 $params = array();
1608 } else if (!empty($search['tagid'])) {
1609 // Search courses that are tagged with the specified tag.
1610 $where = "c.id IN (SELECT t.itemid ".
1611 "FROM {tag_instance} t WHERE t.tagid = :tagid AND t.itemtype = :itemtype AND t.component = :component)";
1612 $params = array('tagid' => $search['tagid'], 'itemtype' => 'course', 'component' => 'core');
1613 if (!empty($search['ctx'])) {
1614 $rec = isset($search['rec']) ? $search['rec'] : true;
1615 $parentcontext = context::instance_by_id($search['ctx']);
1616 if ($parentcontext->contextlevel == CONTEXT_SYSTEM && $rec) {
1617 // Parent context is system context and recursive is set to yes.
1618 // Nothing to filter - all courses fall into this condition.
1619 } else if ($rec) {
1620 // Filter all courses in the parent context at any level.
1621 $where .= ' AND ctx.path LIKE :contextpath';
1622 $params['contextpath'] = $parentcontext->path . '%';
1623 } else if ($parentcontext->contextlevel == CONTEXT_COURSECAT) {
1624 // All courses in the given course category.
1625 $where .= ' AND c.category = :category';
1626 $params['category'] = $parentcontext->instanceid;
1627 } else {
1628 // No courses will satisfy the context criterion, do not bother searching.
1629 $where = '1=0';
1630 }
1631 }
1632 } else {
1633 debugging('No criteria is specified while searching courses', DEBUG_DEVELOPER);
1634 return array();
1635 }
1636 $courselist = self::get_course_records($where, $params, $options, true);
1637 if (!empty($requiredcapabilities)) {
1638 foreach ($courselist as $key => $course) {
1639 context_helper::preload_from_record($course);
1640 $coursecontext = context_course::instance($course->id);
1641 if (!has_all_capabilities($requiredcapabilities, $coursecontext)) {
1642 unset($courselist[$key]);
1643 }
1644 }
1645 }
1646 self::sort_records($courselist, $sortfields);
1647 $coursecatcache->set($cachekey, array_keys($courselist));
1648 $coursecatcache->set($cntcachekey, count($courselist));
1649 $records = array_slice($courselist, $offset, $limit, true);
1650 }
1651
1652 // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1653 if (!empty($preloadcoursecontacts)) {
1654 self::preload_course_contacts($records);
1655 }
c1e15d20
MG
1656 // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1657 if (!empty($options['customfields'])) {
1658 self::preload_custom_fields($records);
1659 }
442f12f8
MG
1660 // If option 'idonly' is specified no further action is needed, just return list of ids.
1661 if (!empty($options['idonly'])) {
1662 return array_keys($records);
1663 }
1664 // Prepare the list of core_course_list_element objects.
1665 $courses = array();
1666 foreach ($records as $record) {
1667 $courses[$record->id] = new core_course_list_element($record);
1668 }
1669 return $courses;
1670 }
1671
1672 /**
1673 * Returns number of courses in the search results
1674 *
1675 * It is recommended to call this function after {@link core_course_category::search_courses()}
1676 * and not before because only course ids are cached. Otherwise search_courses() may
1677 * perform extra DB queries.
1678 *
1679 * @param array $search search criteria, see method search_courses() for more details
1680 * @param array $options display options. They do not affect the result but
1681 * the 'sort' property is used in cache key for storing list of course ids
1682 * @param array $requiredcapabilities List of capabilities required to see return course.
1683 * @return int
1684 */
1685 public static function search_courses_count($search, $options = array(), $requiredcapabilities = array()) {
1686 $coursecatcache = cache::make('core', 'coursecat');
1687 $cntcachekey = 'scnt-'. serialize($search) . serialize($requiredcapabilities);
1688 if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
1689 // Cached value not found. Retrieve ALL courses and return their count.
1690 unset($options['offset']);
1691 unset($options['limit']);
1692 unset($options['summary']);
1693 unset($options['coursecontacts']);
1694 $options['idonly'] = true;
1695 $courses = self::search_courses($search, $options, $requiredcapabilities);
1696 $cnt = count($courses);
1697 }
1698 return $cnt;
1699 }
1700
1701 /**
1702 * Retrieves the list of courses accessible by user
1703 *
1704 * Not all information is cached, try to avoid calling this method
1705 * twice in the same request.
1706 *
1707 * The following fields are always retrieved:
1708 * - id, visible, fullname, shortname, idnumber, category, sortorder
1709 *
1710 * If you plan to use properties/methods core_course_list_element::$summary and/or
1711 * core_course_list_element::get_course_contacts()
1712 * you can preload this information using appropriate 'options'. Otherwise
1713 * they will be retrieved from DB on demand and it may end with bigger DB load.
1714 *
1715 * Note that method core_course_list_element::has_summary() will not perform additional
1716 * DB queries even if $options['summary'] is not specified
1717 *
1718 * List of found course ids is cached for 10 minutes. Cache may be purged prior
1719 * to this when somebody edits courses or categories, however it is very
1720 * difficult to keep track of all possible changes that may affect list of courses.
1721 *
1722 * @param array $options options for retrieving children
1723 * - recursive - return courses from subcategories as well. Use with care,
1724 * this may be a huge list!
1725 * - summary - preloads fields 'summary' and 'summaryformat'
1726 * - coursecontacts - preloads course contacts
1727 * - sort - list of fields to sort. Example
1728 * array('idnumber' => 1, 'shortname' => 1, 'id' => -1)
1729 * will sort by idnumber asc, shortname asc and id desc.
1730 * Default: array('sortorder' => 1)
1731 * Only cached fields may be used for sorting!
1732 * - offset
1733 * - limit - maximum number of children to return, 0 or null for no limit
1734 * - idonly - returns the array or course ids instead of array of objects
1735 * used only in get_courses_count()
1736 * @return core_course_list_element[]
1737 */
1738 public function get_courses($options = array()) {
1739 global $DB;
1740 $recursive = !empty($options['recursive']);
1741 $offset = !empty($options['offset']) ? $options['offset'] : 0;
1742 $limit = !empty($options['limit']) ? $options['limit'] : null;
1743 $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
1744
beff3806
MG
1745 if (!$this->id && !$recursive) {
1746 // There are no courses on system level unless we need recursive list.
1747 return [];
442f12f8
MG
1748 }
1749
1750 $coursecatcache = cache::make('core', 'coursecat');
1751 $cachekey = 'l-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '').
1752 '-'. serialize($sortfields);
1753 $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '');
1754
1755 // Check if we have already cached results.
1756 $ids = $coursecatcache->get($cachekey);
1757 if ($ids !== false) {
1758 // We already cached last search result and it did not expire yet.
1759 $ids = array_slice($ids, $offset, $limit);
1760 $courses = array();
1761 if (!empty($ids)) {
1762 list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id');
1763 $records = self::get_course_records("c.id ". $sql, $params, $options);
1764 // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1765 if (!empty($options['coursecontacts'])) {
1766 self::preload_course_contacts($records);
1767 }
1768 // If option 'idonly' is specified no further action is needed, just return list of ids.
1769 if (!empty($options['idonly'])) {
1770 return array_keys($records);
1771 }
c1e15d20
MG
1772 // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1773 if (!empty($options['customfields'])) {
1774 self::preload_custom_fields($records);
1775 }
442f12f8
MG
1776 // Prepare the list of core_course_list_element objects.
1777 foreach ($ids as $id) {
1778 $courses[$id] = new core_course_list_element($records[$id]);
1779 }
1780 }
1781 return $courses;
1782 }
1783
1784 // Retrieve list of courses in category.
1785 $where = 'c.id <> :siteid';
1786 $params = array('siteid' => SITEID);
1787 if ($recursive) {
1788 if ($this->id) {
1789 $context = context_coursecat::instance($this->id);
1790 $where .= ' AND ctx.path like :path';
1791 $params['path'] = $context->path. '/%';
1792 }
1793 } else {
1794 $where .= ' AND c.category = :categoryid';
1795 $params['categoryid'] = $this->id;
1796 }
1797 // Get list of courses without preloaded coursecontacts because we don't need them for every course.
1798 $list = $this->get_course_records($where, $params, array_diff_key($options, array('coursecontacts' => 1)), true);
1799
1800 // Sort and cache list.
1801 self::sort_records($list, $sortfields);
1802 $coursecatcache->set($cachekey, array_keys($list));
1803 $coursecatcache->set($cntcachekey, count($list));
1804
1805 // Apply offset/limit, convert to core_course_list_element and return.
1806 $courses = array();
1807 if (isset($list)) {
1808 if ($offset || $limit) {
1809 $list = array_slice($list, $offset, $limit, true);
1810 }
1811 // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1812 if (!empty($options['coursecontacts'])) {
1813 self::preload_course_contacts($list);
1814 }
c1e15d20
MG
1815 // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1816 if (!empty($options['customfields'])) {
1817 self::preload_custom_fields($list);
1818 }
442f12f8
MG
1819 // If option 'idonly' is specified no further action is needed, just return list of ids.
1820 if (!empty($options['idonly'])) {
1821 return array_keys($list);
1822 }
1823 // Prepare the list of core_course_list_element objects.
1824 foreach ($list as $record) {
1825 $courses[$record->id] = new core_course_list_element($record);
1826 }
1827 }
1828 return $courses;
1829 }
1830
1831 /**
1832 * Returns number of courses visible to the user
1833 *
1834 * @param array $options similar to get_courses() except some options do not affect
1835 * number of courses (i.e. sort, summary, offset, limit etc.)
1836 * @return int
1837 */
1838 public function get_courses_count($options = array()) {
1839 $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '');
1840 $coursecatcache = cache::make('core', 'coursecat');
1841 if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
1842 // Cached value not found. Retrieve ALL courses and return their count.
1843 unset($options['offset']);
1844 unset($options['limit']);
1845 unset($options['summary']);
1846 unset($options['coursecontacts']);
1847 $options['idonly'] = true;
1848 $courses = $this->get_courses($options);
1849 $cnt = count($courses);
1850 }
1851 return $cnt;
1852 }
1853
1854 /**
1855 * Returns true if the user is able to delete this category.
1856 *
1857 * Note if this category contains any courses this isn't a full check, it will need to be accompanied by a call to either
1858 * {@link core_course_category::can_delete_full()} or {@link core_course_category::can_move_content_to()}
1859 * depending upon what the user wished to do.
1860 *
1861 * @return boolean
1862 */
1863 public function can_delete() {
1864 if (!$this->has_manage_capability()) {
1865 return false;
1866 }
1867 return $this->parent_has_manage_capability();
1868 }
1869
1870 /**
1871 * Returns true if user can delete current category and all its contents
1872 *
1873 * To be able to delete course category the user must have permission
1874 * 'moodle/category:manage' in ALL child course categories AND
1875 * be able to delete all courses
1876 *
1877 * @return bool
1878 */
1879 public function can_delete_full() {
1880 global $DB;
1881 if (!$this->id) {
1882 // Fool-proof.
1883 return false;
1884 }
1885
1886 $context = $this->get_context();
1887 if (!$this->is_uservisible() ||
1888 !has_capability('moodle/category:manage', $context)) {
1889 return false;
1890 }
1891
1892 // Check all child categories (not only direct children).
1893 $sql = context_helper::get_preload_record_columns_sql('ctx');
1894 $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
1895 ' FROM {context} ctx '.
1896 ' JOIN {course_categories} c ON c.id = ctx.instanceid'.
1897 ' WHERE ctx.path like ? AND ctx.contextlevel = ?',
1898 array($context->path. '/%', CONTEXT_COURSECAT));
1899 foreach ($childcategories as $childcat) {
1900 context_helper::preload_from_record($childcat);
1901 $childcontext = context_coursecat::instance($childcat->id);
1902 if ((!$childcat->visible && !has_capability('moodle/category:viewhiddencategories', $childcontext)) ||
1903 !has_capability('moodle/category:manage', $childcontext)) {
1904 return false;
1905 }
1906 }
1907
1908 // Check courses.
1909 $sql = context_helper::get_preload_record_columns_sql('ctx');
1910 $coursescontexts = $DB->get_records_sql('SELECT ctx.instanceid AS courseid, '.
1911 $sql. ' FROM {context} ctx '.
1912 'WHERE ctx.path like :pathmask and ctx.contextlevel = :courselevel',
1913 array('pathmask' => $context->path. '/%',
1914 'courselevel' => CONTEXT_COURSE));
1915 foreach ($coursescontexts as $ctxrecord) {
1916 context_helper::preload_from_record($ctxrecord);
1917 if (!can_delete_course($ctxrecord->courseid)) {
1918 return false;
1919 }
1920 }
1921
1922 return true;
1923 }
1924
1925 /**
1926 * Recursively delete category including all subcategories and courses
1927 *
1928 * Function {@link core_course_category::can_delete_full()} MUST be called prior
1929 * to calling this function because there is no capability check
1930 * inside this function
1931 *
1932 * @param boolean $showfeedback display some notices
1933 * @return array return deleted courses
1934 * @throws moodle_exception
1935 */
1936 public function delete_full($showfeedback = true) {
1937 global $CFG, $DB;
1938
1939 require_once($CFG->libdir.'/gradelib.php');
1940 require_once($CFG->libdir.'/questionlib.php');
1941 require_once($CFG->dirroot.'/cohort/lib.php');
1942
1943 // Make sure we won't timeout when deleting a lot of courses.
1944 $settimeout = core_php_time_limit::raise();
1945
1946 // Allow plugins to use this category before we completely delete it.
1947 if ($pluginsfunction = get_plugins_with_function('pre_course_category_delete')) {
1948 $category = $this->get_db_record();
1949 foreach ($pluginsfunction as $plugintype => $plugins) {
1950 foreach ($plugins as $pluginfunction) {
1951 $pluginfunction($category);
1952 }
1953 }
1954 }
1955
1956 $deletedcourses = array();
1957
1958 // Get children. Note, we don't want to use cache here because it would be rebuilt too often.
1959 $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC');
1960 foreach ($children as $record) {
1961 $coursecat = new self($record);
1962 $deletedcourses += $coursecat->delete_full($showfeedback);
1963 }
1964
1965 if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) {
1966 foreach ($courses as $course) {
1967 if (!delete_course($course, false)) {
1968 throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname);
1969 }
1970 $deletedcourses[] = $course;
1971 }
1972 }
1973
1974 // Move or delete cohorts in this context.
1975 cohort_delete_category($this);
1976
1977 // Now delete anything that may depend on course category context.
1978 grade_course_category_delete($this->id, 0, $showfeedback);
1979 if (!question_delete_course_category($this, 0, $showfeedback)) {
1980 throw new moodle_exception('cannotdeletecategoryquestions', '', '', $this->get_formatted_name());
1981 }
1982
1983 // Delete all events in the category.
1984 $DB->delete_records('event', array('categoryid' => $this->id));
1985
1986 // Finally delete the category and it's context.
1987 $DB->delete_records('course_categories', array('id' => $this->id));
1988
1989 $coursecatcontext = context_coursecat::instance($this->id);
1990 $coursecatcontext->delete();
1991
1992 cache_helper::purge_by_event('changesincoursecat');
1993
1994 // Trigger a course category deleted event.
1995 /** @var \core\event\course_category_deleted $event */
1996 $event = \core\event\course_category_deleted::create(array(
1997 'objectid' => $this->id,
1998 'context' => $coursecatcontext,
1999 'other' => array('name' => $this->name)
2000 ));
2001 $event->set_coursecat($this);
2002 $event->trigger();
2003
2004 // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
2005 if ($this->id == $CFG->defaultrequestcategory) {
2006 set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
2007 }
2008 return $deletedcourses;
2009 }
2010
2011 /**
2012 * Checks if user can delete this category and move content (courses, subcategories and questions)
2013 * to another category. If yes returns the array of possible target categories names
2014 *
2015 * If user can not manage this category or it is completely empty - empty array will be returned
2016 *
2017 * @return array
2018 */
2019 public function move_content_targets_list() {
2020 global $CFG;
2021 require_once($CFG->libdir . '/questionlib.php');
2022 $context = $this->get_context();
2023 if (!$this->is_uservisible() ||
2024 !has_capability('moodle/category:manage', $context)) {
2025 // User is not able to manage current category, he is not able to delete it.
2026 // No possible target categories.
2027 return array();
2028 }
2029
2030 $testcaps = array();
2031 // If this category has courses in it, user must have 'course:create' capability in target category.
2032 if ($this->has_courses()) {
2033 $testcaps[] = 'moodle/course:create';
2034 }
2035 // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
2036 if ($this->has_children() || question_context_has_any_questions($context)) {
2037 $testcaps[] = 'moodle/category:manage';
2038 }
2039 if (!empty($testcaps)) {
2040 // Return list of categories excluding this one and it's children.
2041 return self::make_categories_list($testcaps, $this->id);
2042 }
2043
2044 // Category is completely empty, no need in target for contents.
2045 return array();
2046 }
2047
2048 /**
2049 * Checks if user has capability to move all category content to the new parent before
2050 * removing this category
2051 *
2052 * @param int $newcatid
2053 * @return bool
2054 */
2055 public function can_move_content_to($newcatid) {
2056 global $CFG;
2057 require_once($CFG->libdir . '/questionlib.php');
2058 $context = $this->get_context();
2059 if (!$this->is_uservisible() ||
2060 !has_capability('moodle/category:manage', $context)) {
2061 return false;
2062 }
2063 $testcaps = array();
2064 // If this category has courses in it, user must have 'course:create' capability in target category.
2065 if ($this->has_courses()) {
2066 $testcaps[] = 'moodle/course:create';
2067 }
2068 // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
2069 if ($this->has_children() || question_context_has_any_questions($context)) {
2070 $testcaps[] = 'moodle/category:manage';
2071 }
2072 if (!empty($testcaps)) {
2073 return has_all_capabilities($testcaps, context_coursecat::instance($newcatid));
2074 }
2075
2076 // There is no content but still return true.
2077 return true;
2078 }
2079
2080 /**
2081 * Deletes a category and moves all content (children, courses and questions) to the new parent
2082 *
2083 * Note that this function does not check capabilities, {@link core_course_category::can_move_content_to()}
2084 * must be called prior
2085 *
2086 * @param int $newparentid
2087 * @param bool $showfeedback
2088 * @return bool
2089 */
2090 public function delete_move($newparentid, $showfeedback = false) {
2091 global $CFG, $DB, $OUTPUT;
2092
2093 require_once($CFG->libdir.'/gradelib.php');
2094 require_once($CFG->libdir.'/questionlib.php');
2095 require_once($CFG->dirroot.'/cohort/lib.php');
2096
2097 // Get all objects and lists because later the caches will be reset so.
2098 // We don't need to make extra queries.
2099 $newparentcat = self::get($newparentid, MUST_EXIST, true);
2100 $catname = $this->get_formatted_name();
2101 $children = $this->get_children();
2102 $params = array('category' => $this->id);
2103 $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', $params);
2104 $context = $this->get_context();
2105
2106 if ($children) {
2107 foreach ($children as $childcat) {
2108 $childcat->change_parent_raw($newparentcat);
2109 // Log action.
2110 $event = \core\event\course_category_updated::create(array(
2111 'objectid' => $childcat->id,
2112 'context' => $childcat->get_context()
2113 ));
2114 $event->set_legacy_logdata(array(SITEID, 'category', 'move', 'editcategory.php?id=' . $childcat->id,
2115 $childcat->id));
2116 $event->trigger();
2117 }
2118 fix_course_sortorder();
2119 }
2120
2121 if ($coursesids) {
2122 require_once($CFG->dirroot.'/course/lib.php');
2123 if (!move_courses($coursesids, $newparentid)) {
2124 if ($showfeedback) {
2125 echo $OUTPUT->notification("Error moving courses");
2126 }
2127 return false;
2128 }
2129 if ($showfeedback) {
2130 echo $OUTPUT->notification(get_string('coursesmovedout', '', $catname), 'notifysuccess');
2131 }
2132 }
2133
2134 // Move or delete cohorts in this context.
2135 cohort_delete_category($this);
2136
2137 // Now delete anything that may depend on course category context.
2138 grade_course_category_delete($this->id, $newparentid, $showfeedback);
2139 if (!question_delete_course_category($this, $newparentcat, $showfeedback)) {
2140 if ($showfeedback) {
2141 echo $OUTPUT->notification(get_string('errordeletingquestionsfromcategory', 'question', $catname), 'notifysuccess');
2142 }
2143 return false;
2144 }
2145
2146 // Finally delete the category and it's context.
2147 $DB->delete_records('course_categories', array('id' => $this->id));
2148 $context->delete();
2149
2150 // Trigger a course category deleted event.
2151 /** @var \core\event\course_category_deleted $event */
2152 $event = \core\event\course_category_deleted::create(array(
2153 'objectid' => $this->id,
2154 'context' => $context,
2155 'other' => array('name' => $this->name)
2156 ));
2157 $event->set_coursecat($this);
2158 $event->trigger();
2159
2160 cache_helper::purge_by_event('changesincoursecat');
2161
2162 if ($showfeedback) {
2163 echo $OUTPUT->notification(get_string('coursecategorydeleted', '', $catname), 'notifysuccess');
2164 }
2165
2166 // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
2167 if ($this->id == $CFG->defaultrequestcategory) {
2168 set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
2169 }
2170 return true;
2171 }
2172
2173 /**
2174 * Checks if user can move current category to the new parent
2175 *
2176 * This checks if new parent category exists, user has manage cap there
2177 * and new parent is not a child of this category
2178 *
2179 * @param int|stdClass|core_course_category $newparentcat
2180 * @return bool
2181 */
2182 public function can_change_parent($newparentcat) {
2183 if (!has_capability('moodle/category:manage', $this->get_context())) {
2184 return false;
2185 }
2186 if (is_object($newparentcat)) {
2187 $newparentcat = self::get($newparentcat->id, IGNORE_MISSING);
2188 } else {
2189 $newparentcat = self::get((int)$newparentcat, IGNORE_MISSING);
2190 }
2191 if (!$newparentcat) {
2192 return false;
2193 }
2194 if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
2195 // Can not move to itself or it's own child.
2196 return false;
2197 }
2198 if ($newparentcat->id) {
2199 return has_capability('moodle/category:manage', context_coursecat::instance($newparentcat->id));
2200 } else {
2201 return has_capability('moodle/category:manage', context_system::instance());
2202 }
2203 }
2204
2205 /**
2206 * Moves the category under another parent category. All associated contexts are moved as well
2207 *
2208 * This is protected function, use change_parent() or update() from outside of this class
2209 *
2210 * @see core_course_category::change_parent()
2211 * @see core_course_category::update()
2212 *
2213 * @param core_course_category $newparentcat
2214 * @throws moodle_exception
2215 */
2216 protected function change_parent_raw(core_course_category $newparentcat) {
2217 global $DB;
2218
2219 $context = $this->get_context();
2220
2221 $hidecat = false;
2222 if (empty($newparentcat->id)) {
2223 $DB->set_field('course_categories', 'parent', 0, array('id' => $this->id));
2224 $newparent = context_system::instance();
2225 } else {
2226 if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
2227 // Can not move to itself or it's own child.
2228 throw new moodle_exception('cannotmovecategory');
2229 }
2230 $DB->set_field('course_categories', 'parent', $newparentcat->id, array('id' => $this->id));
2231 $newparent = context_coursecat::instance($newparentcat->id);
2232
2233 if (!$newparentcat->visible and $this->visible) {
2234 // Better hide category when moving into hidden category, teachers may unhide afterwards and the hidden children
2235 // will be restored properly.
2236 $hidecat = true;
2237 }
2238 }
2239 $this->parent = $newparentcat->id;
2240
2241 $context->update_moved($newparent);
2242
2243 // Now make it last in new category.
2244 $DB->set_field('course_categories', 'sortorder', MAX_COURSES_IN_CATEGORY * MAX_COURSE_CATEGORIES, ['id' => $this->id]);
2245
2246 if ($hidecat) {
2247 fix_course_sortorder();
2248 $this->restore();
2249 // Hide object but store 1 in visibleold, because when parent category visibility changes this category must
2250 // become visible again.
2251 $this->hide_raw(1);
2252 }
2253 }
2254
2255 /**
2256 * Efficiently moves a category - NOTE that this can have
2257 * a huge impact access-control-wise...
2258 *
2259 * Note that this function does not check capabilities.
2260 *
2261 * Example of usage:
2262 * $coursecat = core_course_category::get($categoryid);
2263 * if ($coursecat->can_change_parent($newparentcatid)) {
2264 * $coursecat->change_parent($newparentcatid);
2265 * }
2266 *
2267 * This function does not update field course_categories.timemodified
2268 * If you want to update timemodified, use
2269 * $coursecat->update(array('parent' => $newparentcat));
2270 *
2271 * @param int|stdClass|core_course_category $newparentcat
2272 */
2273 public function change_parent($newparentcat) {
2274 // Make sure parent category exists but do not check capabilities here that it is visible to current user.
2275 if (is_object($newparentcat)) {
2276 $newparentcat = self::get($newparentcat->id, MUST_EXIST, true);
2277 } else {
2278 $newparentcat = self::get((int)$newparentcat, MUST_EXIST, true);
2279 }
2280 if ($newparentcat->id != $this->parent) {
2281 $this->change_parent_raw($newparentcat);
2282 fix_course_sortorder();
2283 cache_helper::purge_by_event('changesincoursecat');
2284 $this->restore();
2285
2286 $event = \core\event\course_category_updated::create(array(
2287 'objectid' => $this->id,
2288 'context' => $this->get_context()
2289 ));
2290 $event->set_legacy_logdata(array(SITEID, 'category', 'move', 'editcategory.php?id=' . $this->id, $this->id));
2291 $event->trigger();
2292 }
2293 }
2294
2295 /**
2296 * Hide course category and child course and subcategories
2297 *
2298 * If this category has changed the parent and is moved under hidden
2299 * category we will want to store it's current visibility state in
2300 * the field 'visibleold'. If admin clicked 'hide' for this particular
2301 * category, the field 'visibleold' should become 0.
2302 *
2303 * All subcategories and courses will have their current visibility in the field visibleold
2304 *
2305 * This is protected function, use hide() or update() from outside of this class
2306 *
2307 * @see core_course_category::hide()
2308 * @see core_course_category::update()
2309 *
2310 * @param int $visibleold value to set in field $visibleold for this category
2311 * @return bool whether changes have been made and caches need to be purged afterwards
2312 */
2313 protected function hide_raw($visibleold = 0) {
2314 global $DB;
2315 $changes = false;
2316
2317 // Note that field 'visibleold' is not cached so we must retrieve it from DB if it is missing.
2318 if ($this->id && $this->__get('visibleold') != $visibleold) {
2319 $this->visibleold = $visibleold;
2320 $DB->set_field('course_categories', 'visibleold', $visibleold, array('id' => $this->id));
2321 $changes = true;
2322 }
2323 if (!$this->visible || !$this->id) {
2324 // Already hidden or can not be hidden.
2325 return $changes;
2326 }
2327
2328 $this->visible = 0;
2329 $DB->set_field('course_categories', 'visible', 0, array('id' => $this->id));
2330 // Store visible flag so that we can return to it if we immediately unhide.
2331 $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($this->id));
2332 $DB->set_field('course', 'visible', 0, array('category' => $this->id));
2333 // Get all child categories and hide too.
2334 if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visible')) {
2335 foreach ($subcats as $cat) {
2336 $DB->set_field('course_categories', 'visibleold', $cat->visible, array('id' => $cat->id));
2337 $DB->set_field('course_categories', 'visible', 0, array('id' => $cat->id));
2338 $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($cat->id));
2339 $DB->set_field('course', 'visible', 0, array('category' => $cat->id));
2340 }
2341 }
2342 return true;
2343 }
2344
2345 /**
2346 * Hide course category and child course and subcategories
2347 *
2348 * Note that there is no capability check inside this function
2349 *
2350 * This function does not update field course_categories.timemodified
2351 * If you want to update timemodified, use
2352 * $coursecat->update(array('visible' => 0));
2353 */
2354 public function hide() {
2355 if ($this->hide_raw(0)) {
2356 cache_helper::purge_by_event('changesincoursecat');
2357
2358 $event = \core\event\course_category_updated::create(array(
2359 'objectid' => $this->id,
2360 'context' => $this->get_context()
2361 ));
2362 $event->set_legacy_logdata(array(SITEID, 'category', 'hide', 'editcategory.php?id=' . $this->id, $this->id));
2363 $event->trigger();
2364 }
2365 }
2366
2367 /**
2368 * Show course category and restores visibility for child course and subcategories
2369 *
2370 * Note that there is no capability check inside this function
2371 *
2372 * This is protected function, use show() or update() from outside of this class
2373 *
2374 * @see core_course_category::show()
2375 * @see core_course_category::update()
2376 *
2377 * @return bool whether changes have been made and caches need to be purged afterwards
2378 */
2379 protected function show_raw() {
2380 global $DB;
2381
2382 if ($this->visible) {
2383 // Already visible.
2384 return false;
2385 }
2386
2387 $this->visible = 1;
2388 $this->visibleold = 1;
2389 $DB->set_field('course_categories', 'visible', 1, array('id' => $this->id));
2390 $DB->set_field('course_categories', 'visibleold', 1, array('id' => $this->id));
2391 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($this->id));
2392 // Get all child categories and unhide too.
2393 if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visibleold')) {
2394 foreach ($subcats as $cat) {
2395 if ($cat->visibleold) {
2396 $DB->set_field('course_categories', 'visible', 1, array('id' => $cat->id));
2397 }
2398 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($cat->id));
2399 }
2400 }
2401 return true;
2402 }
2403
2404 /**
2405 * Show course category and restores visibility for child course and subcategories
2406 *
2407 * Note that there is no capability check inside this function
2408 *
2409 * This function does not update field course_categories.timemodified
2410 * If you want to update timemodified, use
2411 * $coursecat->update(array('visible' => 1));
2412 */
2413 public function show() {
2414 if ($this->show_raw()) {
2415 cache_helper::purge_by_event('changesincoursecat');
2416
2417 $event = \core\event\course_category_updated::create(array(
2418 'objectid' => $this->id,
2419 'context' => $this->get_context()
2420 ));
2421 $event->set_legacy_logdata(array(SITEID, 'category', 'show', 'editcategory.php?id=' . $this->id, $this->id));
2422 $event->trigger();
2423 }
2424 }
2425
2426 /**
2427 * Returns name of the category formatted as a string
2428 *
2429 * @param array $options formatting options other than context
2430 * @return string
2431 */
2432 public function get_formatted_name($options = array()) {
2433 if ($this->id) {
2434 $context = $this->get_context();
2435 return format_string($this->name, true, array('context' => $context) + $options);
2436 } else {
2437 return get_string('top');
2438 }
2439 }
2440
2441 /**
2442 * Get the nested name of this category, with all of it's parents.
2443 *
2444 * @param bool $includelinks Whether to wrap each name in the view link for that category.
2445 * @param string $separator The string between each name.
2446 * @param array $options Formatting options.
2447 * @return string
2448 */
2449 public function get_nested_name($includelinks = true, $separator = ' / ', $options = []) {
2450 // Get the name of hierarchical name of this category.
2451 $parents = $this->get_parents();
2452 $categories = static::get_many($parents);
2453 $categories[] = $this;
2454
2455 $names = array_map(function($category) use ($options, $includelinks) {
2456 if ($includelinks) {
2457 return html_writer::link($category->get_view_link(), $category->get_formatted_name($options));
2458 } else {
2459 return $category->get_formatted_name($options);
2460 }
2461
2462 }, $categories);
2463
2464 return implode($separator, $names);
2465 }
2466
2467 /**
2468 * Returns ids of all parents of the category. Last element in the return array is the direct parent
2469 *
2470 * For example, if you have a tree of categories like:
2471 * Miscellaneous (id = 1)
2472 * Subcategory (id = 2)
2473 * Sub-subcategory (id = 4)
2474 * Other category (id = 3)
2475 *
2476 * core_course_category::get(1)->get_parents() == array()
2477 * core_course_category::get(2)->get_parents() == array(1)
2478 * core_course_category::get(4)->get_parents() == array(1, 2);
2479 *
2480 * Note that this method does not check if all parents are accessible by current user
2481 *
2482 * @return array of category ids
2483 */
2484 public function get_parents() {
2485 $parents = preg_split('|/|', $this->path, 0, PREG_SPLIT_NO_EMPTY);
2486 array_pop($parents);
2487 return $parents;
2488 }
2489
2490 /**
2491 * This function returns a nice list representing category tree
2492 * for display or to use in a form <select> element
2493 *
2494 * List is cached for 10 minutes
2495 *
2496 * For example, if you have a tree of categories like:
2497 * Miscellaneous (id = 1)
2498 * Subcategory (id = 2)
2499 * Sub-subcategory (id = 4)
2500 * Other category (id = 3)
2501 * Then after calling this function you will have
2502 * array(1 => 'Miscellaneous',
2503 * 2 => 'Miscellaneous / Subcategory',
2504 * 4 => 'Miscellaneous / Subcategory / Sub-subcategory',
2505 * 3 => 'Other category');
2506 *
2507 * If you specify $requiredcapability, then only categories where the current
2508 * user has that capability will be added to $list.
2509 * If you only have $requiredcapability in a child category, not the parent,
2510 * then the child catgegory will still be included.
2511 *
2512 * If you specify the option $excludeid, then that category, and all its children,
2513 * are omitted from the tree. This is useful when you are doing something like
2514 * moving categories, where you do not want to allow people to move a category
2515 * to be the child of itself.
2516 *
2517 * See also {@link make_categories_options()}
2518 *
2519 * @param string/array $requiredcapability if given, only categories where the current
2520 * user has this capability will be returned. Can also be an array of capabilities,
2521 * in which case they are all required.
2522 * @param integer $excludeid Exclude this category and its children from the lists built.
2523 * @param string $separator string to use as a separator between parent and child category. Default ' / '
2524 * @return array of strings
2525 */
2526 public static function make_categories_list($requiredcapability = '', $excludeid = 0, $separator = ' / ') {
2527 global $DB;
2528 $coursecatcache = cache::make('core', 'coursecat');
2529
2530 // Check if we cached the complete list of user-accessible category names ($baselist) or list of ids
2531 // with requried cap ($thislist).
2532 $currentlang = current_language();
2533 $basecachekey = $currentlang . '_catlist';
2534 $baselist = $coursecatcache->get($basecachekey);
2535 $thislist = false;
2536 $thiscachekey = null;
2537 if (!empty($requiredcapability)) {
2538 $requiredcapability = (array)$requiredcapability;
2539 $thiscachekey = 'catlist:'. serialize($requiredcapability);
2540 if ($baselist !== false && ($thislist = $coursecatcache->get($thiscachekey)) !== false) {
2541 $thislist = preg_split('|,|', $thislist, -1, PREG_SPLIT_NO_EMPTY);
2542 }
2543 } else if ($baselist !== false) {
beff3806
MG
2544 $thislist = array_keys(array_filter($baselist, function($el) {
2545 return $el['name'] !== false;
2546 }));
442f12f8
MG
2547 }
2548
2549 if ($baselist === false) {
2550 // We don't have $baselist cached, retrieve it. Retrieve $thislist again in any case.
2551 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
2552 $sql = "SELECT cc.id, cc.sortorder, cc.name, cc.visible, cc.parent, cc.path, $ctxselect
2553 FROM {course_categories} cc
2554 JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
2555 ORDER BY cc.sortorder";
2556 $rs = $DB->get_recordset_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
2557 $baselist = array();
2558 $thislist = array();
2559 foreach ($rs as $record) {
beff3806
MG
2560 context_helper::preload_from_record($record);
2561 $context = context_coursecat::instance($record->id);
2562 $canview = self::can_view_category($record);
2563 $baselist[$record->id] = array(
2564 'name' => $canview ? format_string($record->name, true, array('context' => $context)) : false,
2565 'path' => $record->path
2566 );
2567 if (!$canview || (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context))) {
2568 // No required capability, added to $baselist but not to $thislist.
2569 continue;
442f12f8 2570 }
beff3806 2571 $thislist[] = $record->id;
442f12f8
MG
2572 }
2573 $rs->close();
2574 $coursecatcache->set($basecachekey, $baselist);
2575 if (!empty($requiredcapability)) {
2576 $coursecatcache->set($thiscachekey, join(',', $thislist));
2577 }
2578 } else if ($thislist === false) {
2579 // We have $baselist cached but not $thislist. Simplier query is used to retrieve.
2580 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
2581 $sql = "SELECT ctx.instanceid AS id, $ctxselect
2582 FROM {context} ctx WHERE ctx.contextlevel = :contextcoursecat";
2583 $contexts = $DB->get_records_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
2584 $thislist = array();
2585 foreach (array_keys($baselist) as $id) {
beff3806
MG
2586 if ($baselist[$id]['name'] !== false) {
2587 context_helper::preload_from_record($contexts[$id]);
2588 if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
2589 $thislist[] = $id;
2590 }
442f12f8
MG
2591 }
2592 }
2593 $coursecatcache->set($thiscachekey, join(',', $thislist));
2594 }
2595
2596 // Now build the array of strings to return, mind $separator and $excludeid.
2597 $names = array();
2598 foreach ($thislist as $id) {
2599 $path = preg_split('|/|', $baselist[$id]['path'], -1, PREG_SPLIT_NO_EMPTY);
2600 if (!$excludeid || !in_array($excludeid, $path)) {
2601 $namechunks = array();
2602 foreach ($path as $parentid) {
beff3806
MG
2603 if (array_key_exists($parentid, $baselist) && $baselist[$parentid]['name'] !== false) {
2604 $namechunks[] = $baselist[$parentid]['name'];
2605 }
442f12f8
MG
2606 }
2607 $names[$id] = join($separator, $namechunks);
2608 }
2609 }
2610 return $names;
2611 }
2612
2613 /**
2614 * Prepares the object for caching. Works like the __sleep method.
2615 *
2616 * implementing method from interface cacheable_object
2617 *
2618 * @return array ready to be cached
2619 */
2620 public function prepare_to_cache() {
2621 $a = array();
2622 foreach (self::$coursecatfields as $property => $cachedirectives) {
2623 if ($cachedirectives !== null) {
2624 list($shortname, $defaultvalue) = $cachedirectives;
2625 if ($this->$property !== $defaultvalue) {
2626 $a[$shortname] = $this->$property;
2627 }
2628 }
2629 }
2630 $context = $this->get_context();
2631 $a['xi'] = $context->id;
2632 $a['xp'] = $context->path;
0616f045 2633 $a['xl'] = $context->locked;
442f12f8
MG
2634 return $a;
2635 }
2636
2637 /**
2638 * Takes the data provided by prepare_to_cache and reinitialises an instance of the associated from it.
2639 *
2640 * implementing method from interface cacheable_object
2641 *
2642 * @param array $a
2643 * @return core_course_category
2644 */
2645 public static function wake_from_cache($a) {
2646 $record = new stdClass;
2647 foreach (self::$coursecatfields as $property => $cachedirectives) {
2648 if ($cachedirectives !== null) {
2649 list($shortname, $defaultvalue) = $cachedirectives;
2650 if (array_key_exists($shortname, $a)) {
2651 $record->$property = $a[$shortname];
2652 } else {
2653 $record->$property = $defaultvalue;
2654 }
2655 }
2656 }
2657 $record->ctxid = $a['xi'];
2658 $record->ctxpath = $a['xp'];
2659 $record->ctxdepth = $record->depth + 1;
2660 $record->ctxlevel = CONTEXT_COURSECAT;
2661 $record->ctxinstance = $record->id;
0616f045 2662 $record->ctxlocked = $a['xl'];
442f12f8
MG
2663 return new self($record, true);
2664 }
2665
2666 /**
2667 * Returns true if the user is able to create a top level category.
2668 * @return bool
2669 */
2670 public static function can_create_top_level_category() {
beff3806 2671 return self::top()->has_manage_capability();
442f12f8
MG
2672 }
2673
2674 /**
2675 * Returns the category context.
2676 * @return context_coursecat
2677 */
2678 public function get_context() {
2679 if ($this->id === 0) {
2680 // This is the special top level category object.
2681 return context_system::instance();
2682 } else {
2683 return context_coursecat::instance($this->id);
2684 }
2685 }
2686
2687 /**
2688 * Returns true if the user is able to manage this category.
2689 * @return bool
2690 */
2691 public function has_manage_capability() {
beff3806
MG
2692 if (!$this->is_uservisible()) {
2693 return false;
442f12f8 2694 }
beff3806 2695 return has_capability('moodle/category:manage', $this->get_context());
442f12f8
MG
2696 }
2697
2698 /**
2699 * Returns true if the user has the manage capability on the parent category.
2700 * @return bool
2701 */
2702 public function parent_has_manage_capability() {
beff3806 2703 return ($parent = $this->get_parent_coursecat()) && $parent->has_manage_capability();
442f12f8
MG
2704 }
2705
2706 /**
2707 * Returns true if the current user can create subcategories of this category.
2708 * @return bool
2709 */
2710 public function can_create_subcategory() {
2711 return $this->has_manage_capability();
2712 }
2713
2714 /**
2715 * Returns true if the user can resort this categories sub categories and courses.
2716 * Must have manage capability and be able to see all subcategories.
2717 * @return bool
2718 */
2719 public function can_resort_subcategories() {
2720 return $this->has_manage_capability() && !$this->get_not_visible_children_ids();
2721 }
2722
2723 /**
2724 * Returns true if the user can resort the courses within this category.
2725 * Must have manage capability and be able to see all courses.
2726 * @return bool
2727 */
2728 public function can_resort_courses() {
2729 return $this->has_manage_capability() && $this->coursecount == $this->get_courses_count();
2730 }
2731
2732 /**
2733 * Returns true of the user can change the sortorder of this category (resort in the parent category)
2734 * @return bool
2735 */
2736 public function can_change_sortorder() {
beff3806 2737 return ($parent = $this->get_parent_coursecat()) && $parent->can_resort_subcategories();
442f12f8
MG
2738 }
2739
2740 /**
2741 * Returns true if the current user can create a course within this category.
2742 * @return bool
2743 */
2744 public function can_create_course() {
beff3806 2745 return $this->is_uservisible() && has_capability('moodle/course:create', $this->get_context());
442f12f8
MG
2746 }
2747
2748 /**
2749 * Returns true if the current user can edit this categories settings.
2750 * @return bool
2751 */
2752 public function can_edit() {
2753 return $this->has_manage_capability();
2754 }
2755
2756 /**
2757 * Returns true if the current user can review role assignments for this category.
2758 * @return bool
2759 */
2760 public function can_review_roles() {
beff3806 2761 return $this->is_uservisible() && has_capability('moodle/role:assign', $this->get_context());
442f12f8
MG
2762 }
2763
2764 /**
2765 * Returns true if the current user can review permissions for this category.
2766 * @return bool
2767 */
2768 public function can_review_permissions() {
beff3806
MG
2769 return $this->is_uservisible() &&
2770 has_any_capability(array(
442f12f8
MG
2771 'moodle/role:assign',
2772 'moodle/role:safeoverride',
2773 'moodle/role:override',
2774 'moodle/role:assign'
2775 ), $this->get_context());
2776 }
2777
2778 /**
2779 * Returns true if the current user can review cohorts for this category.
2780 * @return bool
2781 */
2782 public function can_review_cohorts() {
beff3806
MG
2783 return $this->is_uservisible() &&
2784 has_any_capability(array('moodle/cohort:view', 'moodle/cohort:manage'), $this->get_context());
442f12f8
MG
2785 }
2786
2787 /**
2788 * Returns true if the current user can review filter settings for this category.
2789 * @return bool
2790 */
2791 public function can_review_filters() {
beff3806
MG
2792 return $this->is_uservisible() &&
2793 has_capability('moodle/filter:manage', $this->get_context()) &&
2794 count(filter_get_available_in_context($this->get_context())) > 0;
442f12f8
MG
2795 }
2796
2797 /**
2798 * Returns true if the current user is able to change the visbility of this category.
2799 * @return bool
2800 */
2801 public function can_change_visibility() {
2802 return $this->parent_has_manage_capability();
2803 }
2804
2805 /**
2806 * Returns true if the user can move courses out of this category.
2807 * @return bool
2808 */
2809 public function can_move_courses_out_of() {
2810 return $this->has_manage_capability();
2811 }
2812
2813 /**
2814 * Returns true if the user can move courses into this category.
2815 * @return bool
2816 */
2817 public function can_move_courses_into() {
2818 return $this->has_manage_capability();
2819 }
2820
2821 /**
2822 * Returns true if the user is able to restore a course into this category as a new course.
2823 * @return bool
2824 */
2825 public function can_restore_courses_into() {
beff3806 2826 return $this->is_uservisible() && has_capability('moodle/restore:restorecourse', $this->get_context());
442f12f8
MG
2827 }
2828
2829 /**
2830 * Resorts the sub categories of this category by the given field.
2831 *
2832 * @param string $field One of name, idnumber or descending values of each (appended desc)
2833 * @param bool $cleanup If true cleanup will be done, if false you will need to do it manually later.
2834 * @return bool True on success.
2835 * @throws coding_exception
2836 */
2837 public function resort_subcategories($field, $cleanup = true) {
2838 global $DB;
2839 $desc = false;
2840 if (substr($field, -4) === "desc") {
2841 $desc = true;
2842 $field = substr($field, 0, -4); // Remove "desc" from field name.
2843 }
2844 if ($field !== 'name' && $field !== 'idnumber') {
2845 throw new coding_exception('Invalid field requested');
2846 }
2847 $children = $this->get_children();
2848 core_collator::asort_objects_by_property($children, $field, core_collator::SORT_NATURAL);
2849 if (!empty($desc)) {
2850 $children = array_reverse($children);
2851 }
2852 $i = 1;
2853 foreach ($children as $cat) {
2854 $i++;
2855 $DB->set_field('course_categories', 'sortorder', $i, array('id' => $cat->id));
2856 $i += $cat->coursecount;
2857 }
2858 if ($cleanup) {
2859 self::resort_categories_cleanup();
2860 }
2861 return true;
2862 }
2863
2864 /**
2865 * Cleans things up after categories have been resorted.
2866 * @param bool $includecourses If set to true we know courses have been resorted as well.
2867 */
2868 public static function resort_categories_cleanup($includecourses = false) {
2869 // This should not be needed but we do it just to be safe.
2870 fix_course_sortorder();
2871 cache_helper::purge_by_event('changesincoursecat');
2872 if ($includecourses) {
2873 cache_helper::purge_by_event('changesincourse');
2874 }
2875 }
2876
2877 /**
2878 * Resort the courses within this category by the given field.
2879 *
2880 * @param string $field One of fullname, shortname, idnumber or descending values of each (appended desc)
2881 * @param bool $cleanup
2882 * @return bool True for success.
2883 * @throws coding_exception
2884 */
2885 public function resort_courses($field, $cleanup = true) {
2886 global $DB;
2887 $desc = false;
2888 if (substr($field, -4) === "desc") {
2889 $desc = true;
2890 $field = substr($field, 0, -4); // Remove "desc" from field name.
2891 }
2892 if ($field !== 'fullname' && $field !== 'shortname' && $field !== 'idnumber' && $field !== 'timecreated') {
2893 // This is ultra important as we use $field in an SQL statement below this.
2894 throw new coding_exception('Invalid field requested');
2895 }
2896 $ctxfields = context_helper::get_preload_record_columns_sql('ctx');
2897 $sql = "SELECT c.id, c.sortorder, c.{$field}, $ctxfields
2898 FROM {course} c
2899 LEFT JOIN {context} ctx ON ctx.instanceid = c.id
2900 WHERE ctx.contextlevel = :ctxlevel AND
2901 c.category = :categoryid";
2902 $params = array(
2903 'ctxlevel' => CONTEXT_COURSE,
2904 'categoryid' => $this->id
2905 );
2906 $courses = $DB->get_records_sql($sql, $params);
2907 if (count($courses) > 0) {
2908 foreach ($courses as $courseid => $course) {
2909 context_helper::preload_from_record($course);
2910 if ($field === 'idnumber') {
2911 $course->sortby = $course->idnumber;
2912 } else {
2913 // It'll require formatting.
2914 $options = array(
2915 'context' => context_course::instance($course->id)
2916 );
2917 // We format the string first so that it appears as the user would see it.
2918 // This ensures the sorting makes sense to them. However it won't necessarily make
2919 // sense to everyone if things like multilang filters are enabled.
2920 // We then strip any tags as we don't want things such as image tags skewing the
2921 // sort results.
2922 $course->sortby = strip_tags(format_string($course->$field, true, $options));
2923 }
2924 // We set it back here rather than using references as there is a bug with using
2925 // references in a foreach before passing as an arg by reference.
2926 $courses[$courseid] = $course;
2927 }
2928 // Sort the courses.
2929 core_collator::asort_objects_by_property($courses, 'sortby', core_collator::SORT_NATURAL);
2930 if (!empty($desc)) {
2931 $courses = array_reverse($courses);
2932 }
2933 $i = 1;
2934 foreach ($courses as $course) {
2935 $DB->set_field('course', 'sortorder', $this->sortorder + $i, array('id' => $course->id));
2936 $i++;
2937 }
2938 if ($cleanup) {
2939 // This should not be needed but we do it just to be safe.
2940 fix_course_sortorder();
2941 cache_helper::purge_by_event('changesincourse');
2942 }
2943 }
2944 return true;
2945 }
2946
2947 /**
2948 * Changes the sort order of this categories parent shifting this category up or down one.
2949 *
2950 * @param bool $up If set to true the category is shifted up one spot, else its moved down.
2951 * @return bool True on success, false otherwise.
2952 */
2953 public function change_sortorder_by_one($up) {
2954 global $DB;
2955 $params = array($this->sortorder, $this->parent);
2956 if ($up) {
2957 $select = 'sortorder < ? AND parent = ?';
2958 $sort = 'sortorder DESC';
2959 } else {
2960 $select = 'sortorder > ? AND parent = ?';
2961 $sort = 'sortorder ASC';
2962 }
2963 fix_course_sortorder();
2964 $swapcategory = $DB->get_records_select('course_categories', $select, $params, $sort, '*', 0, 1);
2965 $swapcategory = reset($swapcategory);
2966 if ($swapcategory) {
2967 $DB->set_field('course_categories', 'sortorder', $swapcategory->sortorder, array('id' => $this->id));
2968 $DB->set_field('course_categories', 'sortorder', $this->sortorder, array('id' => $swapcategory->id));
2969 $this->sortorder = $swapcategory->sortorder;
2970
2971 $event = \core\event\course_category_updated::create(array(
2972 'objectid' => $this->id,
2973 'context' => $this->get_context()
2974 ));
2975 $event->set_legacy_logdata(array(SITEID, 'category', 'move', 'management.php?categoryid=' . $this->id,
2976 $this->id));
2977 $event->trigger();
2978
2979 // Finally reorder courses.
2980 fix_course_sortorder();
2981 cache_helper::purge_by_event('changesincoursecat');
2982 return true;
2983 }
2984 return false;
2985 }
2986
2987 /**
2988 * Returns the parent core_course_category object for this category.
2989 *
beff3806
MG
2990 * Only returns parent if it exists and is visible to the current user
2991 *
2992 * @return core_course_category|null
442f12f8
MG
2993 */
2994 public function get_parent_coursecat() {
beff3806
MG
2995 if (!$this->id) {
2996 return null;
2997 }
2998 return self::get($this->parent, IGNORE_MISSING);
442f12f8
MG
2999 }
3000
3001
3002 /**
3003 * Returns true if the user is able to request a new course be created.
3004 * @return bool
3005 */
3006 public function can_request_course() {
3e15abe5 3007 return course_request::can_request($this->get_context());
442f12f8
MG
3008 }
3009
3010 /**
3011 * Returns true if the user can approve course requests.
3012 * @return bool
3013 */
3014 public static function can_approve_course_requests() {
3015 global $CFG, $DB;
3016 if (empty($CFG->enablecourserequests)) {
3017 return false;
3018 }
3019 $context = context_system::instance();
3020 if (!has_capability('moodle/site:approvecourse', $context)) {
3021 return false;
3022 }
3023 if (!$DB->record_exists('course_request', array())) {
3024 return false;
3025 }
3026 return true;
3027 }
3028}