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