Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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