Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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
 
1441 ariadna 26
use core\exception\moodle_exception;
1 efrain 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) {
1441 ariadna 275
                throw new moodle_exception('unknowncategory', a: $id);
1 efrain 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.
1441 ariadna 1669
                if (array_key_exists($search['modulelist'], core_component::get_plugin_list('mod'))) {
1670
                    // If module plugin exists, use module name as table name.
1671
                    $where = "c.id IN (SELECT DISTINCT module.course FROM {{$search['modulelist']}} module)";
1672
                } else {
1673
                    // Otherwise, return empty list of courses.
1674
                    $where = '1=0';
1675
                }
1 efrain 1676
                $params = array();
1677
            } else if (!empty($search['tagid'])) {
1678
                // Search courses that are tagged with the specified tag.
1679
                $where = "c.id IN (SELECT t.itemid ".
1680
                        "FROM {tag_instance} t WHERE t.tagid = :tagid AND t.itemtype = :itemtype AND t.component = :component)";
1681
                $params = array('tagid' => $search['tagid'], 'itemtype' => 'course', 'component' => 'core');
1682
                if (!empty($search['ctx'])) {
1683
                    $rec = isset($search['rec']) ? $search['rec'] : true;
1684
                    $parentcontext = context::instance_by_id($search['ctx']);
1685
                    if ($parentcontext->contextlevel == CONTEXT_SYSTEM && $rec) {
1686
                        // Parent context is system context and recursive is set to yes.
1687
                        // Nothing to filter - all courses fall into this condition.
1688
                    } else if ($rec) {
1689
                        // Filter all courses in the parent context at any level.
1690
                        $where .= ' AND ctx.path LIKE :contextpath';
1691
                        $params['contextpath'] = $parentcontext->path . '%';
1692
                    } else if ($parentcontext->contextlevel == CONTEXT_COURSECAT) {
1693
                        // All courses in the given course category.
1694
                        $where .= ' AND c.category = :category';
1695
                        $params['category'] = $parentcontext->instanceid;
1696
                    } else {
1697
                        // No courses will satisfy the context criterion, do not bother searching.
1698
                        $where = '1=0';
1699
                    }
1700
                }
1701
            } else {
1702
                debugging('No criteria is specified while searching courses', DEBUG_DEVELOPER);
1703
                return array();
1704
            }
1705
            if (!empty($courseidsearch)) {
1706
                $where .= ' AND ' . $courseidsearch;
1707
                $params = array_merge($params, $courseidparams);
1708
            }
1709
 
1710
            $courselist = self::get_course_records($where, $params, $options, true);
1711
            if (!empty($requiredcapabilities)) {
1712
                foreach ($courselist as $key => $course) {
1713
                    context_helper::preload_from_record($course);
1714
                    $coursecontext = context_course::instance($course->id);
1715
                    if (!has_all_capabilities($requiredcapabilities, $coursecontext)) {
1716
                        unset($courselist[$key]);
1717
                    }
1718
                }
1719
            }
1720
            self::sort_records($courselist, $sortfields);
1721
            $coursecatcache->set($cachekey, array_keys($courselist));
1722
            $coursecatcache->set($cntcachekey, count($courselist));
1723
            $records = array_slice($courselist, $offset, $limit, true);
1724
        }
1725
 
1726
        // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1727
        if (!empty($preloadcoursecontacts)) {
1728
            self::preload_course_contacts($records);
1729
        }
1730
        // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1731
        if (!empty($options['customfields'])) {
1732
            self::preload_custom_fields($records);
1733
        }
1734
        // If option 'idonly' is specified no further action is needed, just return list of ids.
1735
        if (!empty($options['idonly'])) {
1736
            return array_keys($records);
1737
        }
1738
        // Prepare the list of core_course_list_element objects.
1739
        $courses = array();
1740
        foreach ($records as $record) {
1741
            $courses[$record->id] = new core_course_list_element($record);
1742
        }
1743
        return $courses;
1744
    }
1745
 
1746
    /**
1747
     * Returns number of courses in the search results
1748
     *
1749
     * It is recommended to call this function after {@link core_course_category::search_courses()}
1750
     * and not before because only course ids are cached. Otherwise search_courses() may
1751
     * perform extra DB queries.
1752
     *
1753
     * @param array $search search criteria, see method search_courses() for more details
1754
     * @param array $options display options. They do not affect the result but
1755
     *     the 'sort' property is used in cache key for storing list of course ids
1756
     * @param array $requiredcapabilities List of capabilities required to see return course.
1757
     * @return int
1758
     */
1759
    public static function search_courses_count($search, $options = array(), $requiredcapabilities = array()) {
1760
        $coursecatcache = cache::make('core', 'coursecat');
1761
        $cntcachekey = 'scnt-'. serialize($search) . serialize($requiredcapabilities);
1762
        if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
1763
            // Cached value not found. Retrieve ALL courses and return their count.
1764
            unset($options['offset']);
1765
            unset($options['limit']);
1766
            unset($options['summary']);
1767
            unset($options['coursecontacts']);
1768
            $options['idonly'] = true;
1769
            $courses = self::search_courses($search, $options, $requiredcapabilities);
1770
            $cnt = count($courses);
1771
        }
1772
        return $cnt;
1773
    }
1774
 
1775
    /**
1776
     * Retrieves the list of courses accessible by user
1777
     *
1778
     * Not all information is cached, try to avoid calling this method
1779
     * twice in the same request.
1780
     *
1781
     * The following fields are always retrieved:
1782
     * - id, visible, fullname, shortname, idnumber, category, sortorder
1783
     *
1784
     * If you plan to use properties/methods core_course_list_element::$summary and/or
1785
     * core_course_list_element::get_course_contacts()
1786
     * you can preload this information using appropriate 'options'. Otherwise
1787
     * they will be retrieved from DB on demand and it may end with bigger DB load.
1788
     *
1789
     * Note that method core_course_list_element::has_summary() will not perform additional
1790
     * DB queries even if $options['summary'] is not specified
1791
     *
1792
     * List of found course ids is cached for 10 minutes. Cache may be purged prior
1793
     * to this when somebody edits courses or categories, however it is very
1794
     * difficult to keep track of all possible changes that may affect list of courses.
1795
     *
1796
     * @param array $options options for retrieving children
1797
     *    - recursive - return courses from subcategories as well. Use with care,
1798
     *      this may be a huge list!
1799
     *    - summary - preloads fields 'summary' and 'summaryformat'
1800
     *    - coursecontacts - preloads course contacts
1801
     *    - sort - list of fields to sort. Example
1802
     *             array('idnumber' => 1, 'shortname' => 1, 'id' => -1)
1803
     *             will sort by idnumber asc, shortname asc and id desc.
1804
     *             Default: array('sortorder' => 1)
1805
     *             Only cached fields may be used for sorting!
1806
     *    - offset
1807
     *    - limit - maximum number of children to return, 0 or null for no limit
1808
     *    - idonly - returns the array or course ids instead of array of objects
1809
     *               used only in get_courses_count()
1810
     * @return core_course_list_element[]
1811
     */
1812
    public function get_courses($options = array()) {
1813
        global $DB;
1814
        $recursive = !empty($options['recursive']);
1815
        $offset = !empty($options['offset']) ? $options['offset'] : 0;
1816
        $limit = !empty($options['limit']) ? $options['limit'] : null;
1817
        $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
1818
 
1819
        if (!$this->id && !$recursive) {
1820
            // There are no courses on system level unless we need recursive list.
1821
            return [];
1822
        }
1823
 
1824
        $coursecatcache = cache::make('core', 'coursecat');
1825
        $cachekey = 'l-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '').
1826
                 '-'. serialize($sortfields);
1827
        $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '');
1828
 
1829
        // Check if we have already cached results.
1830
        $ids = $coursecatcache->get($cachekey);
1831
        if ($ids !== false) {
1832
            // We already cached last search result and it did not expire yet.
1833
            $ids = array_slice($ids, $offset, $limit);
1834
            $courses = array();
1835
            if (!empty($ids)) {
1836
                list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id');
1837
                $records = self::get_course_records("c.id ". $sql, $params, $options);
1838
                // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1839
                if (!empty($options['coursecontacts'])) {
1840
                    self::preload_course_contacts($records);
1841
                }
1842
                // If option 'idonly' is specified no further action is needed, just return list of ids.
1843
                if (!empty($options['idonly'])) {
1844
                    return array_keys($records);
1845
                }
1846
                // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1847
                if (!empty($options['customfields'])) {
1848
                    self::preload_custom_fields($records);
1849
                }
1850
                // Prepare the list of core_course_list_element objects.
1851
                foreach ($ids as $id) {
1852
                    // If a course is deleted after we got the cache entry it may not exist in the database anymore.
1853
                    if (!empty($records[$id])) {
1854
                        $courses[$id] = new core_course_list_element($records[$id]);
1855
                    }
1856
                }
1857
            }
1858
            return $courses;
1859
        }
1860
 
1861
        // Retrieve list of courses in category.
1862
        $where = 'c.id <> :siteid';
1863
        $params = array('siteid' => SITEID);
1864
        if ($recursive) {
1865
            if ($this->id) {
1866
                $context = context_coursecat::instance($this->id);
1867
                $where .= ' AND ctx.path like :path';
1868
                $params['path'] = $context->path. '/%';
1869
            }
1870
        } else {
1871
            $where .= ' AND c.category = :categoryid';
1872
            $params['categoryid'] = $this->id;
1873
        }
1874
        // Get list of courses without preloaded coursecontacts because we don't need them for every course.
1875
        $list = $this->get_course_records($where, $params, array_diff_key($options, array('coursecontacts' => 1)), true);
1876
 
1877
        // Sort and cache list.
1878
        self::sort_records($list, $sortfields);
1879
        $coursecatcache->set($cachekey, array_keys($list));
1880
        $coursecatcache->set($cntcachekey, count($list));
1881
 
1882
        // Apply offset/limit, convert to core_course_list_element and return.
1883
        $courses = array();
1884
        if (isset($list)) {
1885
            if ($offset || $limit) {
1886
                $list = array_slice($list, $offset, $limit, true);
1887
            }
1888
            // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1889
            if (!empty($options['coursecontacts'])) {
1890
                self::preload_course_contacts($list);
1891
            }
1892
            // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
1893
            if (!empty($options['customfields'])) {
1894
                self::preload_custom_fields($list);
1895
            }
1896
            // If option 'idonly' is specified no further action is needed, just return list of ids.
1897
            if (!empty($options['idonly'])) {
1898
                return array_keys($list);
1899
            }
1900
            // Prepare the list of core_course_list_element objects.
1901
            foreach ($list as $record) {
1902
                $courses[$record->id] = new core_course_list_element($record);
1903
            }
1904
        }
1905
        return $courses;
1906
    }
1907
 
1908
    /**
1909
     * Returns number of courses visible to the user
1910
     *
1911
     * @param array $options similar to get_courses() except some options do not affect
1912
     *     number of courses (i.e. sort, summary, offset, limit etc.)
1913
     * @return int
1914
     */
1915
    public function get_courses_count($options = array()) {
1916
        $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '');
1917
        $coursecatcache = cache::make('core', 'coursecat');
1918
        if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
1919
            // Cached value not found. Retrieve ALL courses and return their count.
1920
            unset($options['offset']);
1921
            unset($options['limit']);
1922
            unset($options['summary']);
1923
            unset($options['coursecontacts']);
1924
            $options['idonly'] = true;
1925
            $courses = $this->get_courses($options);
1926
            $cnt = count($courses);
1927
        }
1928
        return $cnt;
1929
    }
1930
 
1931
    /**
1932
     * Returns true if the user is able to delete this category.
1933
     *
1934
     * Note if this category contains any courses this isn't a full check, it will need to be accompanied by a call to either
1935
     * {@link core_course_category::can_delete_full()} or {@link core_course_category::can_move_content_to()}
1936
     * depending upon what the user wished to do.
1937
     *
1938
     * @return boolean
1939
     */
1940
    public function can_delete() {
1941
        if (!$this->has_manage_capability()) {
1942
            return false;
1943
        }
1944
        return $this->parent_has_manage_capability();
1945
    }
1946
 
1947
    /**
1948
     * Returns true if user can delete current category and all its contents
1949
     *
1950
     * To be able to delete course category the user must have permission
1951
     * 'moodle/category:manage' in ALL child course categories AND
1952
     * be able to delete all courses
1953
     *
1954
     * @return bool
1955
     */
1956
    public function can_delete_full() {
1957
        global $DB;
1958
        if (!$this->id) {
1959
            // Fool-proof.
1960
            return false;
1961
        }
1962
 
1963
        if (!$this->has_manage_capability()) {
1964
            return false;
1965
        }
1966
 
1967
        // Check all child categories (not only direct children).
1968
        $context = $this->get_context();
1969
        $sql = context_helper::get_preload_record_columns_sql('ctx');
1970
        $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
1971
            ' FROM {context} ctx '.
1972
            ' JOIN {course_categories} c ON c.id = ctx.instanceid'.
1973
            ' WHERE ctx.path like ? AND ctx.contextlevel = ?',
1974
                array($context->path. '/%', CONTEXT_COURSECAT));
1975
        foreach ($childcategories as $childcat) {
1976
            context_helper::preload_from_record($childcat);
1977
            $childcontext = context_coursecat::instance($childcat->id);
1978
            if ((!$childcat->visible && !has_capability('moodle/category:viewhiddencategories', $childcontext)) ||
1979
                    !has_capability('moodle/category:manage', $childcontext)) {
1980
                return false;
1981
            }
1982
        }
1983
 
1984
        // Check courses.
1985
        $sql = context_helper::get_preload_record_columns_sql('ctx');
1986
        $coursescontexts = $DB->get_records_sql('SELECT ctx.instanceid AS courseid, '.
1987
                    $sql. ' FROM {context} ctx '.
1988
                    'WHERE ctx.path like :pathmask and ctx.contextlevel = :courselevel',
1989
                array('pathmask' => $context->path. '/%',
1990
                    'courselevel' => CONTEXT_COURSE));
1991
        foreach ($coursescontexts as $ctxrecord) {
1992
            context_helper::preload_from_record($ctxrecord);
1993
            if (!can_delete_course($ctxrecord->courseid)) {
1994
                return false;
1995
            }
1996
        }
1997
 
1998
        // Check if plugins permit deletion of category content.
1999
        $pluginfunctions = $this->get_plugins_callback_function('can_course_category_delete');
2000
        foreach ($pluginfunctions as $pluginfunction) {
2001
            // If at least one plugin does not permit deletion, stop and return false.
2002
            if (!$pluginfunction($this)) {
2003
                return false;
2004
            }
2005
        }
2006
 
2007
        return true;
2008
    }
2009
 
2010
    /**
2011
     * Recursively delete category including all subcategories and courses
2012
     *
2013
     * Function {@link core_course_category::can_delete_full()} MUST be called prior
2014
     * to calling this function because there is no capability check
2015
     * inside this function
2016
     *
2017
     * @param boolean $showfeedback display some notices
2018
     * @return array return deleted courses
2019
     * @throws moodle_exception
2020
     */
2021
    public function delete_full($showfeedback = true) {
2022
        global $CFG, $DB;
2023
 
2024
        require_once($CFG->libdir.'/gradelib.php');
2025
        require_once($CFG->libdir.'/questionlib.php');
2026
        require_once($CFG->dirroot.'/cohort/lib.php');
2027
 
2028
        // Make sure we won't timeout when deleting a lot of courses.
2029
        $settimeout = core_php_time_limit::raise();
2030
 
2031
        // Allow plugins to use this category before we completely delete it.
2032
        $pluginfunctions = $this->get_plugins_callback_function('pre_course_category_delete');
2033
        foreach ($pluginfunctions as $pluginfunction) {
2034
            $pluginfunction($this->get_db_record());
2035
        }
2036
 
2037
        $deletedcourses = array();
2038
 
2039
        // Get children. Note, we don't want to use cache here because it would be rebuilt too often.
2040
        $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC');
2041
        foreach ($children as $record) {
2042
            $coursecat = new self($record);
2043
            $deletedcourses += $coursecat->delete_full($showfeedback);
2044
        }
2045
 
2046
        if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) {
2047
            foreach ($courses as $course) {
2048
                if (!delete_course($course, false)) {
2049
                    throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname);
2050
                }
2051
                $deletedcourses[] = $course;
2052
            }
2053
        }
2054
 
2055
        // Move or delete cohorts in this context.
2056
        cohort_delete_category($this);
2057
 
2058
        // Now delete anything that may depend on course category context.
2059
        grade_course_category_delete($this->id, 0, $showfeedback);
2060
        $cb = new \core_contentbank\contentbank();
2061
        if (!$cb->delete_contents($this->get_context())) {
2062
            throw new moodle_exception('errordeletingcontentfromcategory', 'contentbank', '', $this->get_formatted_name());
2063
        }
2064
 
2065
        // Delete all events in the category.
2066
        $DB->delete_records('event', array('categoryid' => $this->id));
2067
 
2068
        // Finally delete the category and it's context.
2069
        $categoryrecord = $this->get_db_record();
2070
        $DB->delete_records('course_categories', array('id' => $this->id));
2071
 
2072
        $coursecatcontext = context_coursecat::instance($this->id);
2073
        $coursecatcontext->delete();
2074
 
2075
        cache_helper::purge_by_event('changesincoursecat');
2076
 
2077
        // Trigger a course category deleted event.
2078
        /** @var \core\event\course_category_deleted $event */
2079
        $event = \core\event\course_category_deleted::create(array(
2080
            'objectid' => $this->id,
2081
            'context' => $coursecatcontext,
2082
            'other' => array('name' => $this->name)
2083
        ));
2084
        $event->add_record_snapshot($event->objecttable, $categoryrecord);
2085
        $event->set_coursecat($this);
2086
        $event->trigger();
2087
 
2088
        // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
2089
        if ($this->id == $CFG->defaultrequestcategory) {
2090
            set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
2091
        }
2092
        return $deletedcourses;
2093
    }
2094
 
2095
    /**
2096
     * Checks if user can delete this category and move content (courses, subcategories and questions)
2097
     * to another category. If yes returns the array of possible target categories names
2098
     *
2099
     * If user can not manage this category or it is completely empty - empty array will be returned
2100
     *
2101
     * @return array
2102
     */
2103
    public function move_content_targets_list() {
2104
        global $CFG;
2105
        require_once($CFG->libdir . '/questionlib.php');
2106
        $context = $this->get_context();
2107
        if (!$this->is_uservisible() ||
2108
                !has_capability('moodle/category:manage', $context)) {
2109
            // User is not able to manage current category, he is not able to delete it.
2110
            // No possible target categories.
2111
            return array();
2112
        }
2113
 
2114
        $testcaps = array();
2115
        // If this category has courses in it, user must have 'course:create' capability in target category.
2116
        if ($this->has_courses()) {
2117
            $testcaps[] = 'moodle/course:create';
2118
        }
2119
        // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
2120
        if ($this->has_children() || question_context_has_any_questions($context)) {
2121
            $testcaps[] = 'moodle/category:manage';
2122
        }
2123
        if (!empty($testcaps)) {
2124
            // Return list of categories excluding this one and it's children.
2125
            return self::make_categories_list($testcaps, $this->id);
2126
        }
2127
 
2128
        // Category is completely empty, no need in target for contents.
2129
        return array();
2130
    }
2131
 
2132
    /**
2133
     * Checks if user has capability to move all category content to the new parent before
2134
     * removing this category
2135
     *
2136
     * @param int $newcatid
2137
     * @return bool
2138
     */
2139
    public function can_move_content_to($newcatid) {
2140
        global $CFG;
2141
        require_once($CFG->libdir . '/questionlib.php');
2142
 
2143
        if (!$this->has_manage_capability()) {
2144
            return false;
2145
        }
2146
 
2147
        $testcaps = array();
2148
        // If this category has courses in it, user must have 'course:create' capability in target category.
2149
        if ($this->has_courses()) {
2150
            $testcaps[] = 'moodle/course:create';
2151
        }
2152
        // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
2153
        if ($this->has_children() || question_context_has_any_questions($this->get_context())) {
2154
            $testcaps[] = 'moodle/category:manage';
2155
        }
2156
        if (!empty($testcaps) && !has_all_capabilities($testcaps, context_coursecat::instance($newcatid))) {
2157
            // No sufficient capabilities to perform this task.
2158
            return false;
2159
        }
2160
 
2161
        // Check if plugins permit moving category content.
2162
        $pluginfunctions = $this->get_plugins_callback_function('can_course_category_delete_move');
2163
        $newparentcat = self::get($newcatid, MUST_EXIST, true);
2164
        foreach ($pluginfunctions as $pluginfunction) {
2165
            // If at least one plugin does not permit move on deletion, stop and return false.
2166
            if (!$pluginfunction($this, $newparentcat)) {
2167
                return false;
2168
            }
2169
        }
2170
 
2171
        return true;
2172
    }
2173
 
2174
    /**
2175
     * Deletes a category and moves all content (children, courses and questions) to the new parent
2176
     *
2177
     * Note that this function does not check capabilities, {@link core_course_category::can_move_content_to()}
2178
     * must be called prior
2179
     *
2180
     * @param int $newparentid
2181
     * @param bool $showfeedback
2182
     * @return bool
2183
     */
2184
    public function delete_move($newparentid, $showfeedback = false) {
2185
        global $CFG, $DB, $OUTPUT;
2186
 
2187
        require_once($CFG->libdir.'/gradelib.php');
2188
        require_once($CFG->libdir.'/questionlib.php');
2189
        require_once($CFG->dirroot.'/cohort/lib.php');
2190
 
2191
        // Get all objects and lists because later the caches will be reset so.
2192
        // We don't need to make extra queries.
2193
        $newparentcat = self::get($newparentid, MUST_EXIST, true);
2194
        $catname = $this->get_formatted_name();
2195
        $children = $this->get_children();
2196
        $params = array('category' => $this->id);
2197
        $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', $params);
2198
        $context = $this->get_context();
2199
 
2200
        // Allow plugins to make necessary changes before we move the category content.
2201
        $pluginfunctions = $this->get_plugins_callback_function('pre_course_category_delete_move');
2202
        foreach ($pluginfunctions as $pluginfunction) {
2203
            $pluginfunction($this, $newparentcat);
2204
        }
2205
 
2206
        if ($children) {
2207
            foreach ($children as $childcat) {
2208
                $childcat->change_parent_raw($newparentcat);
2209
                // Log action.
2210
                $event = \core\event\course_category_updated::create(array(
2211
                    'objectid' => $childcat->id,
2212
                    'context' => $childcat->get_context()
2213
                ));
2214
                $event->trigger();
2215
            }
2216
            fix_course_sortorder();
2217
        }
2218
 
2219
        if ($coursesids) {
2220
            require_once($CFG->dirroot.'/course/lib.php');
2221
            if (!move_courses($coursesids, $newparentid)) {
2222
                if ($showfeedback) {
2223
                    echo $OUTPUT->notification("Error moving courses");
2224
                }
2225
                return false;
2226
            }
2227
            if ($showfeedback) {
2228
                echo $OUTPUT->notification(get_string('coursesmovedout', '', $catname), 'notifysuccess');
2229
            }
2230
        }
2231
 
2232
        // Move or delete cohorts in this context.
2233
        cohort_delete_category($this);
2234
 
2235
        // Now delete anything that may depend on course category context.
2236
        grade_course_category_delete($this->id, $newparentid, $showfeedback);
2237
        $cb = new \core_contentbank\contentbank();
2238
        $newparentcontext = context_coursecat::instance($newparentid);
2239
        $result = $cb->move_contents($context, $newparentcontext);
2240
        if ($showfeedback) {
2241
            if ($result) {
2242
                echo $OUTPUT->notification(get_string('contentsmoved', 'contentbank', $catname), 'notifysuccess');
2243
            } else {
2244
                echo $OUTPUT->notification(
2245
                        get_string('errordeletingcontentbankfromcategory', 'contentbank', $catname),
2246
                        'notifysuccess'
2247
                );
2248
            }
2249
        }
2250
 
2251
        // Finally delete the category and it's context.
2252
        $categoryrecord = $this->get_db_record();
2253
        $DB->delete_records('course_categories', array('id' => $this->id));
2254
        $context->delete();
2255
 
2256
        // Trigger a course category deleted event.
2257
        /** @var \core\event\course_category_deleted $event */
2258
        $event = \core\event\course_category_deleted::create(array(
2259
            'objectid' => $this->id,
2260
            'context' => $context,
2261
            'other' => array('name' => $this->name, 'contentmovedcategoryid' => $newparentid)
2262
        ));
2263
        $event->add_record_snapshot($event->objecttable, $categoryrecord);
2264
        $event->set_coursecat($this);
2265
        $event->trigger();
2266
 
2267
        cache_helper::purge_by_event('changesincoursecat');
2268
 
2269
        if ($showfeedback) {
2270
            echo $OUTPUT->notification(get_string('coursecategorydeleted', '', $catname), 'notifysuccess');
2271
        }
2272
 
2273
        // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
2274
        if ($this->id == $CFG->defaultrequestcategory) {
2275
            set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
2276
        }
2277
        return true;
2278
    }
2279
 
2280
    /**
2281
     * Checks if user can move current category to the new parent
2282
     *
2283
     * This checks if new parent category exists, user has manage cap there
2284
     * and new parent is not a child of this category
2285
     *
2286
     * @param int|stdClass|core_course_category $newparentcat
2287
     * @return bool
2288
     */
2289
    public function can_change_parent($newparentcat) {
2290
        if (!has_capability('moodle/category:manage', $this->get_context())) {
2291
            return false;
2292
        }
2293
        if (is_object($newparentcat)) {
2294
            $newparentcat = self::get($newparentcat->id, IGNORE_MISSING);
2295
        } else {
2296
            $newparentcat = self::get((int)$newparentcat, IGNORE_MISSING);
2297
        }
2298
        if (!$newparentcat) {
2299
            return false;
2300
        }
2301
        if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
2302
            // Can not move to itself or it's own child.
2303
            return false;
2304
        }
2305
        if ($newparentcat->id) {
2306
            return has_capability('moodle/category:manage', context_coursecat::instance($newparentcat->id));
2307
        } else {
2308
            return has_capability('moodle/category:manage', context_system::instance());
2309
        }
2310
    }
2311
 
2312
    /**
2313
     * Moves the category under another parent category. All associated contexts are moved as well
2314
     *
2315
     * This is protected function, use change_parent() or update() from outside of this class
2316
     *
2317
     * @see core_course_category::change_parent()
2318
     * @see core_course_category::update()
2319
     *
2320
     * @param core_course_category $newparentcat
2321
     * @throws moodle_exception
2322
     */
2323
    protected function change_parent_raw(core_course_category $newparentcat) {
2324
        global $DB;
2325
 
2326
        $context = $this->get_context();
2327
 
2328
        $hidecat = false;
2329
        if (empty($newparentcat->id)) {
2330
            $DB->set_field('course_categories', 'parent', 0, array('id' => $this->id));
2331
            $newparent = context_system::instance();
2332
        } else {
2333
            if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
2334
                // Can not move to itself or it's own child.
2335
                throw new moodle_exception('cannotmovecategory');
2336
            }
2337
            $DB->set_field('course_categories', 'parent', $newparentcat->id, array('id' => $this->id));
2338
            $newparent = context_coursecat::instance($newparentcat->id);
2339
 
2340
            if (!$newparentcat->visible and $this->visible) {
2341
                // Better hide category when moving into hidden category, teachers may unhide afterwards and the hidden children
2342
                // will be restored properly.
2343
                $hidecat = true;
2344
            }
2345
        }
2346
        $this->parent = $newparentcat->id;
2347
 
2348
        $context->update_moved($newparent);
2349
 
2350
        // Now make it last in new category.
2351
        $DB->set_field('course_categories', 'sortorder',
2352
            get_max_courses_in_category() * MAX_COURSE_CATEGORIES, ['id' => $this->id]);
2353
 
2354
        if ($hidecat) {
2355
            fix_course_sortorder();
2356
            $this->restore();
2357
            // Hide object but store 1 in visibleold, because when parent category visibility changes this category must
2358
            // become visible again.
2359
            $this->hide_raw(1);
2360
        }
2361
    }
2362
 
2363
    /**
2364
     * Efficiently moves a category - NOTE that this can have
2365
     * a huge impact access-control-wise...
2366
     *
2367
     * Note that this function does not check capabilities.
2368
     *
2369
     * Example of usage:
2370
     * $coursecat = core_course_category::get($categoryid);
2371
     * if ($coursecat->can_change_parent($newparentcatid)) {
2372
     *     $coursecat->change_parent($newparentcatid);
2373
     * }
2374
     *
2375
     * This function does not update field course_categories.timemodified
2376
     * If you want to update timemodified, use
2377
     * $coursecat->update(array('parent' => $newparentcat));
2378
     *
2379
     * @param int|stdClass|core_course_category $newparentcat
2380
     */
2381
    public function change_parent($newparentcat) {
2382
        // Make sure parent category exists but do not check capabilities here that it is visible to current user.
2383
        if (is_object($newparentcat)) {
2384
            $newparentcat = self::get($newparentcat->id, MUST_EXIST, true);
2385
        } else {
2386
            $newparentcat = self::get((int)$newparentcat, MUST_EXIST, true);
2387
        }
2388
        if ($newparentcat->id != $this->parent) {
2389
            $this->change_parent_raw($newparentcat);
2390
            fix_course_sortorder();
2391
            cache_helper::purge_by_event('changesincoursecat');
2392
            $this->restore();
2393
 
2394
            $event = \core\event\course_category_updated::create(array(
2395
                'objectid' => $this->id,
2396
                'context' => $this->get_context()
2397
            ));
2398
            $event->trigger();
2399
        }
2400
    }
2401
 
2402
    /**
2403
     * Hide course category and child course and subcategories
2404
     *
2405
     * If this category has changed the parent and is moved under hidden
2406
     * category we will want to store it's current visibility state in
2407
     * the field 'visibleold'. If admin clicked 'hide' for this particular
2408
     * category, the field 'visibleold' should become 0.
2409
     *
2410
     * All subcategories and courses will have their current visibility in the field visibleold
2411
     *
2412
     * This is protected function, use hide() or update() from outside of this class
2413
     *
2414
     * @see core_course_category::hide()
2415
     * @see core_course_category::update()
2416
     *
2417
     * @param int $visibleold value to set in field $visibleold for this category
2418
     * @return bool whether changes have been made and caches need to be purged afterwards
2419
     */
2420
    protected function hide_raw($visibleold = 0) {
2421
        global $DB;
2422
        $changes = false;
2423
 
2424
        // Note that field 'visibleold' is not cached so we must retrieve it from DB if it is missing.
2425
        if ($this->id && $this->__get('visibleold') != $visibleold) {
2426
            $this->visibleold = $visibleold;
2427
            $DB->set_field('course_categories', 'visibleold', $visibleold, array('id' => $this->id));
2428
            $changes = true;
2429
        }
2430
        if (!$this->visible || !$this->id) {
2431
            // Already hidden or can not be hidden.
2432
            return $changes;
2433
        }
2434
 
2435
        $this->visible = 0;
2436
        $DB->set_field('course_categories', 'visible', 0, array('id' => $this->id));
2437
        // Store visible flag so that we can return to it if we immediately unhide.
2438
        $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($this->id));
2439
        $DB->set_field('course', 'visible', 0, array('category' => $this->id));
2440
        // Get all child categories and hide too.
2441
        if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visible')) {
2442
            foreach ($subcats as $cat) {
2443
                $DB->set_field('course_categories', 'visibleold', $cat->visible, array('id' => $cat->id));
2444
                $DB->set_field('course_categories', 'visible', 0, array('id' => $cat->id));
2445
                $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($cat->id));
2446
                $DB->set_field('course', 'visible', 0, array('category' => $cat->id));
2447
            }
2448
        }
2449
        return true;
2450
    }
2451
 
2452
    /**
2453
     * Hide course category and child course and subcategories
2454
     *
2455
     * Note that there is no capability check inside this function
2456
     *
2457
     * This function does not update field course_categories.timemodified
2458
     * If you want to update timemodified, use
2459
     * $coursecat->update(array('visible' => 0));
2460
     */
2461
    public function hide() {
2462
        if ($this->hide_raw(0)) {
2463
            cache_helper::purge_by_event('changesincoursecat');
2464
 
2465
            $event = \core\event\course_category_updated::create(array(
2466
                'objectid' => $this->id,
2467
                'context' => $this->get_context()
2468
            ));
2469
            $event->trigger();
2470
        }
2471
    }
2472
 
2473
    /**
2474
     * Show course category and restores visibility for child course and subcategories
2475
     *
2476
     * Note that there is no capability check inside this function
2477
     *
2478
     * This is protected function, use show() or update() from outside of this class
2479
     *
2480
     * @see core_course_category::show()
2481
     * @see core_course_category::update()
2482
     *
2483
     * @return bool whether changes have been made and caches need to be purged afterwards
2484
     */
2485
    protected function show_raw() {
2486
        global $DB;
2487
 
2488
        if ($this->visible) {
2489
            // Already visible.
2490
            return false;
2491
        }
2492
 
2493
        $this->visible = 1;
2494
        $this->visibleold = 1;
2495
        $DB->set_field('course_categories', 'visible', 1, array('id' => $this->id));
2496
        $DB->set_field('course_categories', 'visibleold', 1, array('id' => $this->id));
2497
        $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($this->id));
2498
        // Get all child categories and unhide too.
2499
        if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visibleold')) {
2500
            foreach ($subcats as $cat) {
2501
                if ($cat->visibleold) {
2502
                    $DB->set_field('course_categories', 'visible', 1, array('id' => $cat->id));
2503
                }
2504
                $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($cat->id));
2505
            }
2506
        }
2507
        return true;
2508
    }
2509
 
2510
    /**
2511
     * Show course category and restores visibility for child course and subcategories
2512
     *
2513
     * Note that there is no capability check inside this function
2514
     *
2515
     * This function does not update field course_categories.timemodified
2516
     * If you want to update timemodified, use
2517
     * $coursecat->update(array('visible' => 1));
2518
     */
2519
    public function show() {
2520
        if ($this->show_raw()) {
2521
            cache_helper::purge_by_event('changesincoursecat');
2522
 
2523
            $event = \core\event\course_category_updated::create(array(
2524
                'objectid' => $this->id,
2525
                'context' => $this->get_context()
2526
            ));
2527
            $event->trigger();
2528
        }
2529
    }
2530
 
2531
    /**
2532
     * Returns name of the category formatted as a string
2533
     *
2534
     * @param array $options formatting options other than context
2535
     * @return string
2536
     */
2537
    public function get_formatted_name($options = array()) {
2538
        if ($this->id) {
2539
            $context = $this->get_context();
2540
            return format_string($this->name, true, array('context' => $context) + $options);
2541
        } else {
2542
            return get_string('top');
2543
        }
2544
    }
2545
 
2546
    /**
2547
     * Get the nested name of this category, with all of it's parents.
2548
     *
2549
     * @param   bool    $includelinks Whether to wrap each name in the view link for that category.
2550
     * @param   string  $separator The string between each name.
2551
     * @param   array   $options Formatting options.
2552
     * @return  string
2553
     */
2554
    public function get_nested_name($includelinks = true, $separator = ' / ', $options = []) {
2555
        // Get the name of hierarchical name of this category.
2556
        $parents = $this->get_parents();
2557
        $categories = static::get_many($parents);
2558
        $categories[] = $this;
2559
 
2560
        $names = array_map(function($category) use ($options, $includelinks) {
2561
            if ($includelinks) {
2562
                return html_writer::link($category->get_view_link(), $category->get_formatted_name($options));
2563
            } else {
2564
                return $category->get_formatted_name($options);
2565
            }
2566
 
2567
        }, $categories);
2568
 
2569
        return implode($separator, $names);
2570
    }
2571
 
2572
    /**
2573
     * Returns ids of all parents of the category. Last element in the return array is the direct parent
2574
     *
2575
     * For example, if you have a tree of categories like:
2576
     *   Category (id = 1)
2577
     *      Subcategory (id = 2)
2578
     *         Sub-subcategory (id = 4)
2579
     *   Other category (id = 3)
2580
     *
2581
     * core_course_category::get(1)->get_parents() == array()
2582
     * core_course_category::get(2)->get_parents() == array(1)
2583
     * core_course_category::get(4)->get_parents() == array(1, 2);
2584
     *
2585
     * Note that this method does not check if all parents are accessible by current user
2586
     *
2587
     * @return array of category ids
2588
     */
2589
    public function get_parents() {
2590
        $parents = preg_split('|/|', $this->path, 0, PREG_SPLIT_NO_EMPTY);
2591
        array_pop($parents);
2592
        return $parents;
2593
    }
2594
 
2595
    /**
2596
     * This function returns a nice list representing category tree
2597
     * for display or to use in a form <select> element
2598
     *
2599
     * List is cached for 10 minutes
2600
     *
2601
     * For example, if you have a tree of categories like:
2602
     *   Category (id = 1)
2603
     *      Subcategory (id = 2)
2604
     *         Sub-subcategory (id = 4)
2605
     *   Other category (id = 3)
2606
     * Then after calling this function you will have
2607
     * array(1 => 'Category',
2608
     *       2 => 'Category / Subcategory',
2609
     *       4 => 'Category / Subcategory / Sub-subcategory',
2610
     *       3 => 'Other category');
2611
     *
2612
     * If you specify $requiredcapability, then only categories where the current
2613
     * user has that capability will be added to $list.
2614
     * If you only have $requiredcapability in a child category, not the parent,
2615
     * then the child catgegory will still be included.
2616
     *
2617
     * If you specify the option $excludeid, then that category, and all its children,
2618
     * are omitted from the tree. This is useful when you are doing something like
2619
     * moving categories, where you do not want to allow people to move a category
2620
     * to be the child of itself.
2621
     *
2622
     * @param string/array $requiredcapability if given, only categories where the current
2623
     *      user has this capability will be returned. Can also be an array of capabilities,
2624
     *      in which case they are all required.
2625
     * @param integer $excludeid Exclude this category and its children from the lists built.
2626
     * @param string $separator string to use as a separator between parent and child category. Default ' / '
2627
     * @return array of strings
2628
     */
2629
    public static function make_categories_list($requiredcapability = '', $excludeid = 0, $separator = ' / ') {
2630
        global $DB;
2631
        $coursecatcache = cache::make('core', 'coursecat');
2632
 
2633
        // Check if we cached the complete list of user-accessible category names ($baselist) or list of ids
2634
        // with requried cap ($thislist).
2635
        $currentlang = current_language();
2636
        $basecachekey = $currentlang . '_catlist';
2637
        $baselist = $coursecatcache->get($basecachekey);
2638
        $thislist = false;
2639
        $thiscachekey = null;
2640
        if (!empty($requiredcapability)) {
2641
            $requiredcapability = (array)$requiredcapability;
2642
            $thiscachekey = 'catlist:'. serialize($requiredcapability);
2643
            if ($baselist !== false && ($thislist = $coursecatcache->get($thiscachekey)) !== false) {
2644
                $thislist = preg_split('|,|', $thislist, -1, PREG_SPLIT_NO_EMPTY);
2645
            }
2646
        } else if ($baselist !== false) {
2647
            $thislist = array_keys(array_filter($baselist, function($el) {
2648
                return $el['name'] !== false;
2649
            }));
2650
        }
2651
 
2652
        if ($baselist === false) {
2653
            // We don't have $baselist cached, retrieve it. Retrieve $thislist again in any case.
2654
            $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
2655
            $sql = "SELECT cc.id, cc.sortorder, cc.name, cc.visible, cc.parent, cc.path, $ctxselect
2656
                    FROM {course_categories} cc
2657
                    JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
2658
                    ORDER BY cc.sortorder";
2659
            $rs = $DB->get_recordset_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
2660
            $baselist = array();
2661
            $thislist = array();
2662
            foreach ($rs as $record) {
2663
                context_helper::preload_from_record($record);
2664
                $canview = self::can_view_category($record);
2665
                $context = context_coursecat::instance($record->id);
2666
                $filtercontext = \context_helper::get_navigation_filter_context($context);
2667
                $baselist[$record->id] = array(
2668
                    'name' => $canview ? format_string($record->name, true, array('context' => $filtercontext)) : false,
2669
                    'path' => $record->path
2670
                );
2671
                if (!$canview || (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context))) {
2672
                    // No required capability, added to $baselist but not to $thislist.
2673
                    continue;
2674
                }
2675
                $thislist[] = $record->id;
2676
            }
2677
            $rs->close();
2678
            $coursecatcache->set($basecachekey, $baselist);
2679
            if (!empty($requiredcapability)) {
2680
                $coursecatcache->set($thiscachekey, join(',', $thislist));
2681
            }
2682
        } else if ($thislist === false) {
2683
            // We have $baselist cached but not $thislist. Simplier query is used to retrieve.
2684
            $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
2685
            $sql = "SELECT ctx.instanceid AS id, $ctxselect
2686
                    FROM {context} ctx WHERE ctx.contextlevel = :contextcoursecat";
2687
            $contexts = $DB->get_records_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
2688
            $thislist = array();
2689
            foreach (array_keys($baselist) as $id) {
2690
                if ($baselist[$id]['name'] !== false) {
2691
                    context_helper::preload_from_record($contexts[$id]);
2692
                    if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
2693
                        $thislist[] = $id;
2694
                    }
2695
                }
2696
            }
2697
            $coursecatcache->set($thiscachekey, join(',', $thislist));
2698
        }
2699
 
2700
        // Now build the array of strings to return, mind $separator and $excludeid.
2701
        $names = array();
2702
        foreach ($thislist as $id) {
2703
            $path = preg_split('|/|', $baselist[$id]['path'], -1, PREG_SPLIT_NO_EMPTY);
2704
            if (!$excludeid || !in_array($excludeid, $path)) {
2705
                $namechunks = array();
2706
                foreach ($path as $parentid) {
2707
                    if (array_key_exists($parentid, $baselist) && $baselist[$parentid]['name'] !== false) {
2708
                        $namechunks[] = $baselist[$parentid]['name'];
2709
                    }
2710
                }
2711
                $names[$id] = join($separator, $namechunks);
2712
            }
2713
        }
2714
        return $names;
2715
    }
2716
 
2717
    /**
2718
     * Prepares the object for caching. Works like the __sleep method.
2719
     *
2720
     * implementing method from interface cacheable_object
2721
     *
2722
     * @return array ready to be cached
2723
     */
2724
    public function prepare_to_cache() {
2725
        $a = array();
2726
        foreach (self::$coursecatfields as $property => $cachedirectives) {
2727
            if ($cachedirectives !== null) {
2728
                list($shortname, $defaultvalue) = $cachedirectives;
2729
                if ($this->$property !== $defaultvalue) {
2730
                    $a[$shortname] = $this->$property;
2731
                }
2732
            }
2733
        }
2734
        $context = $this->get_context();
2735
        $a['xi'] = $context->id;
2736
        $a['xp'] = $context->path;
2737
        $a['xl'] = $context->locked;
2738
        return $a;
2739
    }
2740
 
2741
    /**
2742
     * Takes the data provided by prepare_to_cache and reinitialises an instance of the associated from it.
2743
     *
2744
     * implementing method from interface cacheable_object
2745
     *
2746
     * @param array $a
2747
     * @return core_course_category
2748
     */
2749
    public static function wake_from_cache($a) {
2750
        $record = new stdClass;
2751
        foreach (self::$coursecatfields as $property => $cachedirectives) {
2752
            if ($cachedirectives !== null) {
2753
                list($shortname, $defaultvalue) = $cachedirectives;
2754
                if (array_key_exists($shortname, $a)) {
2755
                    $record->$property = $a[$shortname];
2756
                } else {
2757
                    $record->$property = $defaultvalue;
2758
                }
2759
            }
2760
        }
2761
        $record->ctxid = $a['xi'];
2762
        $record->ctxpath = $a['xp'];
2763
        $record->ctxdepth = $record->depth + 1;
2764
        $record->ctxlevel = CONTEXT_COURSECAT;
2765
        $record->ctxinstance = $record->id;
2766
        $record->ctxlocked = $a['xl'];
2767
        return new self($record, true);
2768
    }
2769
 
2770
    /**
2771
     * Returns true if the user is able to create a top level category.
2772
     * @return bool
2773
     */
2774
    public static function can_create_top_level_category() {
2775
        return self::top()->has_manage_capability();
2776
    }
2777
 
2778
    /**
2779
     * Returns the category context.
2780
     * @return context_coursecat
2781
     */
2782
    public function get_context() {
2783
        if ($this->id === 0) {
2784
            // This is the special top level category object.
2785
            return context_system::instance();
2786
        } else {
2787
            return context_coursecat::instance($this->id);
2788
        }
2789
    }
2790
 
2791
    /**
2792
     * Returns true if the user is able to manage this category.
2793
     * @return bool
2794
     */
2795
    public function has_manage_capability() {
2796
        if (!$this->is_uservisible()) {
2797
            return false;
2798
        }
2799
        return has_capability('moodle/category:manage', $this->get_context());
2800
    }
2801
 
2802
    /**
2803
     * Checks whether the category has access to content bank
2804
     *
2805
     * @return bool
2806
     */
2807
    public function has_contentbank() {
2808
        $cb = new \core_contentbank\contentbank();
2809
        return ($cb->is_context_allowed($this->get_context()) &&
2810
            has_capability('moodle/contentbank:access', $this->get_context()));
2811
    }
2812
 
2813
    /**
2814
     * Returns true if the user has the manage capability on the parent category.
2815
     * @return bool
2816
     */
2817
    public function parent_has_manage_capability() {
2818
        return ($parent = $this->get_parent_coursecat()) && $parent->has_manage_capability();
2819
    }
2820
 
2821
    /**
2822
     * Returns true if the current user can create subcategories of this category.
2823
     * @return bool
2824
     */
2825
    public function can_create_subcategory() {
2826
        return $this->has_manage_capability();
2827
    }
2828
 
2829
    /**
2830
     * Returns true if the user can resort this categories sub categories and courses.
2831
     * Must have manage capability and be able to see all subcategories.
2832
     * @return bool
2833
     */
2834
    public function can_resort_subcategories() {
2835
        return $this->has_manage_capability() && !$this->get_not_visible_children_ids();
2836
    }
2837
 
2838
    /**
2839
     * Returns true if the user can resort the courses within this category.
2840
     * Must have manage capability and be able to see all courses.
2841
     * @return bool
2842
     */
2843
    public function can_resort_courses() {
2844
        return $this->has_manage_capability() && $this->coursecount == $this->get_courses_count();
2845
    }
2846
 
2847
    /**
2848
     * Returns true of the user can change the sortorder of this category (resort in the parent category)
2849
     * @return bool
2850
     */
2851
    public function can_change_sortorder() {
2852
        return ($parent = $this->get_parent_coursecat()) && $parent->can_resort_subcategories();
2853
    }
2854
 
2855
    /**
2856
     * Returns true if the current user can create a course within this category.
2857
     * @return bool
2858
     */
2859
    public function can_create_course() {
2860
        return $this->is_uservisible() && has_capability('moodle/course:create', $this->get_context());
2861
    }
2862
 
2863
    /**
2864
     * Returns true if the current user can edit this categories settings.
2865
     * @return bool
2866
     */
2867
    public function can_edit() {
2868
        return $this->has_manage_capability();
2869
    }
2870
 
2871
    /**
2872
     * Returns true if the current user can review role assignments for this category.
2873
     * @return bool
2874
     */
2875
    public function can_review_roles() {
2876
        return $this->is_uservisible() && has_capability('moodle/role:assign', $this->get_context());
2877
    }
2878
 
2879
    /**
2880
     * Returns true if the current user can review permissions for this category.
2881
     * @return bool
2882
     */
2883
    public function can_review_permissions() {
2884
        return $this->is_uservisible() &&
2885
        has_any_capability(array(
2886
            'moodle/role:assign',
2887
            'moodle/role:safeoverride',
2888
            'moodle/role:override',
2889
            'moodle/role:assign'
2890
        ), $this->get_context());
2891
    }
2892
 
2893
    /**
2894
     * Returns true if the current user can review cohorts for this category.
2895
     * @return bool
2896
     */
2897
    public function can_review_cohorts() {
2898
        return $this->is_uservisible() &&
2899
            has_any_capability(array('moodle/cohort:view', 'moodle/cohort:manage'), $this->get_context());
2900
    }
2901
 
2902
    /**
2903
     * Returns true if the current user can review filter settings for this category.
2904
     * @return bool
2905
     */
2906
    public function can_review_filters() {
2907
        return $this->is_uservisible() &&
2908
                has_capability('moodle/filter:manage', $this->get_context()) &&
2909
                count(filter_get_available_in_context($this->get_context())) > 0;
2910
    }
2911
 
2912
    /**
2913
     * Returns true if the current user is able to change the visbility of this category.
2914
     * @return bool
2915
     */
2916
    public function can_change_visibility() {
2917
        return $this->parent_has_manage_capability();
2918
    }
2919
 
2920
    /**
2921
     * Returns true if the user can move courses out of this category.
2922
     * @return bool
2923
     */
2924
    public function can_move_courses_out_of() {
2925
        return $this->has_manage_capability();
2926
    }
2927
 
2928
    /**
2929
     * Returns true if the user can move courses into this category.
2930
     * @return bool
2931
     */
2932
    public function can_move_courses_into() {
2933
        return $this->has_manage_capability();
2934
    }
2935
 
2936
    /**
2937
     * Returns true if the user is able to restore a course into this category as a new course.
2938
     * @return bool
2939
     */
2940
    public function can_restore_courses_into() {
2941
        return $this->is_uservisible() && has_capability('moodle/restore:restorecourse', $this->get_context());
2942
    }
2943
 
2944
    /**
2945
     * Resorts the sub categories of this category by the given field.
2946
     *
2947
     * @param string $field One of name, idnumber or descending values of each (appended desc)
2948
     * @param bool $cleanup If true cleanup will be done, if false you will need to do it manually later.
2949
     * @return bool True on success.
2950
     * @throws coding_exception
2951
     */
2952
    public function resort_subcategories($field, $cleanup = true) {
2953
        global $DB;
2954
        $desc = false;
2955
        if (substr($field, -4) === "desc") {
2956
            $desc = true;
2957
            $field = substr($field, 0, -4);  // Remove "desc" from field name.
2958
        }
2959
        if ($field !== 'name' && $field !== 'idnumber') {
2960
            throw new coding_exception('Invalid field requested');
2961
        }
2962
        $children = $this->get_children();
2963
        core_collator::asort_objects_by_property($children, $field, core_collator::SORT_NATURAL);
2964
        if (!empty($desc)) {
2965
            $children = array_reverse($children);
2966
        }
2967
        $i = 1;
2968
        foreach ($children as $cat) {
2969
            $i++;
2970
            $DB->set_field('course_categories', 'sortorder', $i, array('id' => $cat->id));
2971
            $i += $cat->coursecount;
2972
        }
2973
        if ($cleanup) {
2974
            self::resort_categories_cleanup();
2975
        }
2976
        return true;
2977
    }
2978
 
2979
    /**
2980
     * Cleans things up after categories have been resorted.
2981
     * @param bool $includecourses If set to true we know courses have been resorted as well.
2982
     */
2983
    public static function resort_categories_cleanup($includecourses = false) {
2984
        // This should not be needed but we do it just to be safe.
2985
        fix_course_sortorder();
2986
        cache_helper::purge_by_event('changesincoursecat');
2987
        if ($includecourses) {
2988
            cache_helper::purge_by_event('changesincourse');
2989
        }
2990
    }
2991
 
2992
    /**
2993
     * Resort the courses within this category by the given field.
2994
     *
2995
     * @param string $field One of fullname, shortname, idnumber or descending values of each (appended desc)
2996
     * @param bool $cleanup
2997
     * @return bool True for success.
2998
     * @throws coding_exception
2999
     */
3000
    public function resort_courses($field, $cleanup = true) {
3001
        global $DB;
3002
        $desc = false;
3003
        if (substr($field, -4) === "desc") {
3004
            $desc = true;
3005
            $field = substr($field, 0, -4);  // Remove "desc" from field name.
3006
        }
3007
        if ($field !== 'fullname' && $field !== 'shortname' && $field !== 'idnumber' && $field !== 'timecreated') {
3008
            // This is ultra important as we use $field in an SQL statement below this.
3009
            throw new coding_exception('Invalid field requested');
3010
        }
3011
        $ctxfields = context_helper::get_preload_record_columns_sql('ctx');
3012
        $sql = "SELECT c.id, c.sortorder, c.{$field}, $ctxfields
3013
                  FROM {course} c
3014
             LEFT JOIN {context} ctx ON ctx.instanceid = c.id
3015
                 WHERE ctx.contextlevel = :ctxlevel AND
3016
                       c.category = :categoryid";
3017
        $params = array(
3018
            'ctxlevel' => CONTEXT_COURSE,
3019
            'categoryid' => $this->id
3020
        );
3021
        $courses = $DB->get_records_sql($sql, $params);
3022
        if (count($courses) > 0) {
3023
            foreach ($courses as $courseid => $course) {
3024
                context_helper::preload_from_record($course);
3025
                if ($field === 'idnumber') {
3026
                    $course->sortby = $course->idnumber;
3027
                } else {
3028
                    // It'll require formatting.
3029
                    $options = array(
3030
                        'context' => context_course::instance($course->id)
3031
                    );
3032
                    // We format the string first so that it appears as the user would see it.
3033
                    // This ensures the sorting makes sense to them. However it won't necessarily make
3034
                    // sense to everyone if things like multilang filters are enabled.
3035
                    // We then strip any tags as we don't want things such as image tags skewing the
3036
                    // sort results.
3037
                    $course->sortby = strip_tags(format_string($course->$field, true, $options));
3038
                }
3039
                // We set it back here rather than using references as there is a bug with using
3040
                // references in a foreach before passing as an arg by reference.
3041
                $courses[$courseid] = $course;
3042
            }
3043
            // Sort the courses.
3044
            core_collator::asort_objects_by_property($courses, 'sortby', core_collator::SORT_NATURAL);
3045
            if (!empty($desc)) {
3046
                $courses = array_reverse($courses);
3047
            }
3048
            $i = 1;
3049
            foreach ($courses as $course) {
3050
                $DB->set_field('course', 'sortorder', $this->sortorder + $i, array('id' => $course->id));
3051
                $i++;
3052
            }
3053
            if ($cleanup) {
3054
                // This should not be needed but we do it just to be safe.
3055
                fix_course_sortorder();
3056
                cache_helper::purge_by_event('changesincourse');
3057
            }
3058
        }
3059
        return true;
3060
    }
3061
 
3062
    /**
3063
     * Changes the sort order of this categories parent shifting this category up or down one.
3064
     *
3065
     * @param bool $up If set to true the category is shifted up one spot, else its moved down.
3066
     * @return bool True on success, false otherwise.
3067
     */
3068
    public function change_sortorder_by_one($up) {
3069
        global $DB;
3070
        $params = array($this->sortorder, $this->parent);
3071
        if ($up) {
3072
            $select = 'sortorder < ? AND parent = ?';
3073
            $sort = 'sortorder DESC';
3074
        } else {
3075
            $select = 'sortorder > ? AND parent = ?';
3076
            $sort = 'sortorder ASC';
3077
        }
3078
        fix_course_sortorder();
3079
        $swapcategory = $DB->get_records_select('course_categories', $select, $params, $sort, '*', 0, 1);
3080
        $swapcategory = reset($swapcategory);
3081
        if ($swapcategory) {
3082
            $DB->set_field('course_categories', 'sortorder', $swapcategory->sortorder, array('id' => $this->id));
3083
            $DB->set_field('course_categories', 'sortorder', $this->sortorder, array('id' => $swapcategory->id));
3084
            $this->sortorder = $swapcategory->sortorder;
3085
 
3086
            $event = \core\event\course_category_updated::create(array(
3087
                'objectid' => $this->id,
3088
                'context' => $this->get_context()
3089
            ));
3090
            $event->trigger();
3091
 
3092
            // Finally reorder courses.
3093
            fix_course_sortorder();
3094
            cache_helper::purge_by_event('changesincoursecat');
3095
            return true;
3096
        }
3097
        return false;
3098
    }
3099
 
3100
    /**
3101
     * Returns the parent core_course_category object for this category.
3102
     *
3103
     * Only returns parent if it exists and is visible to the current user
3104
     *
3105
     * @return core_course_category|null
3106
     */
3107
    public function get_parent_coursecat() {
3108
        if (!$this->id) {
3109
            return null;
3110
        }
3111
        return self::get($this->parent, IGNORE_MISSING);
3112
    }
3113
 
3114
 
3115
    /**
3116
     * Returns true if the user is able to request a new course be created.
3117
     * @return bool
3118
     */
3119
    public function can_request_course() {
3120
        global $CFG;
3121
        require_once($CFG->dirroot . '/course/lib.php');
3122
 
3123
        return course_request::can_request($this->get_context());
3124
    }
3125
 
3126
    /**
3127
     * Returns true if the user has all the given permissions.
3128
     *
3129
     * @param array $permissionstocheck The value can be create, manage or any specific capability.
3130
     * @return bool
3131
     */
3132
    private function has_capabilities(array $permissionstocheck): bool {
3133
        if (empty($permissionstocheck)) {
3134
            throw new coding_exception('Invalid permissionstocheck parameter');
3135
        }
3136
        foreach ($permissionstocheck as $permission) {
3137
            if ($permission == 'create') {
3138
                if (!$this->can_create_course()) {
3139
                    return false;
3140
                }
3141
            } else if ($permission == 'manage') {
3142
                if (!$this->has_manage_capability()) {
3143
                    return false;
3144
                }
3145
            } else {
3146
                // Specific capability.
3147
                if (!$this->is_uservisible() || !has_capability($permission, $this->get_context())) {
3148
                    return false;
3149
                }
3150
            }
3151
        }
3152
 
3153
        return true;
3154
    }
3155
 
3156
    /**
3157
     * Returns true if the user can approve course requests.
3158
     * @return bool
3159
     */
3160
    public static function can_approve_course_requests() {
3161
        global $CFG, $DB;
3162
        if (empty($CFG->enablecourserequests)) {
3163
            return false;
3164
        }
3165
        $context = context_system::instance();
3166
        if (!has_capability('moodle/site:approvecourse', $context)) {
3167
            return false;
3168
        }
3169
        if (!$DB->record_exists('course_request', array())) {
3170
            return false;
3171
        }
3172
        return true;
3173
    }
3174
 
3175
    /**
3176
     * General page setup for the course category pages.
3177
     *
3178
     * This method sets up things which are common for the course category pages such as page heading,
3179
     * the active nodes in the page navigation block, the active item in the primary navigation (when applicable).
3180
     *
3181
     * @return void
3182
     */
3183
    public static function page_setup() {
3184
        global $PAGE;
3185
 
3186
        if ($PAGE->context->contextlevel != CONTEXT_COURSECAT) {
3187
            return;
3188
        }
3189
        $categoryid = $PAGE->context->instanceid;
3190
        // Highlight the 'Home' primary navigation item (when applicable).
3191
        $PAGE->set_primary_active_tab('home');
3192
        // Set the page heading to display the category name.
3193
        $coursecategory = self::get($categoryid, MUST_EXIST, true);
3194
        $PAGE->set_heading($coursecategory->get_formatted_name());
3195
        // Set the category node active in the navigation block.
3196
        if ($coursesnode = $PAGE->navigation->find('courses', navigation_node::COURSE_OTHER)) {
3197
            if ($categorynode = $coursesnode->find($categoryid, navigation_node::TYPE_CATEGORY)) {
3198
                $categorynode->make_active();
3199
            }
3200
        }
3201
    }
3202
 
3203
    /**
3204
     * Returns the core_course_category object for the first category that the current user have the permission for the course.
3205
     *
3206
     * Only returns if it exists and is creatable/manageable to the current user
3207
     *
3208
     * @param core_course_category $parentcat Parent category to check.
3209
     * @param array $permissionstocheck The value can be create, manage or any specific capability.
3210
     * @return core_course_category|null
3211
     */
3212
    public static function get_nearest_editable_subcategory(core_course_category $parentcat,
3213
        array $permissionstocheck): ?core_course_category {
3214
        global $USER, $DB;
3215
 
3216
        // First, check the parent category.
3217
        if ($parentcat->has_capabilities($permissionstocheck)) {
3218
            return $parentcat;
3219
        }
3220
 
3221
        // Get all course category contexts that are children of the parent category's context where
3222
        // a) there is a role assignment for the current user or
3223
        // b) there are role capability overrides for a role that the user has in this context.
3224
        // We never need to return the system context because it cannot be a child of another context.
3225
        $fields = array_keys(array_filter(self::$coursecatfields));
3226
        $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
3227
        $rs = $DB->get_recordset_sql("
3228
                SELECT cc.". join(',cc.', $fields). ", $ctxselect
3229
                  FROM {course_categories} cc
3230
                  JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat1
3231
                  JOIN {role_assignments} ra ON ra.contextid = ctx.id
3232
                 WHERE ctx.path LIKE :parentpath1
3233
                       AND ra.userid = :userid1
3234
            UNION
3235
                SELECT cc.". join(',cc.', $fields). ", $ctxselect
3236
                  FROM {course_categories} cc
3237
                  JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat2
3238
                  JOIN {role_capabilities} rc ON rc.contextid = ctx.id
3239
                  JOIN {role_assignments} rc_ra ON rc_ra.roleid = rc.roleid
3240
                  JOIN {context} rc_ra_ctx ON rc_ra_ctx.id = rc_ra.contextid
3241
                 WHERE ctx.path LIKE :parentpath2
3242
                       AND rc_ra.userid = :userid2
3243
                       AND (ctx.path = rc_ra_ctx.path OR ctx.path LIKE " . $DB->sql_concat("rc_ra_ctx.path", "'/%'") . ")
3244
        ", [
3245
            'contextcoursecat1' => CONTEXT_COURSECAT,
3246
            'contextcoursecat2' => CONTEXT_COURSECAT,
3247
            'parentpath1' => $parentcat->get_context()->path . '/%',
3248
            'parentpath2' => $parentcat->get_context()->path . '/%',
3249
            'userid1' => $USER->id,
3250
            'userid2' => $USER->id
3251
        ]);
3252
 
3253
        // Check if user has required capabilities in any of the contexts.
3254
        $tocache = [];
3255
        $result = null;
3256
        foreach ($rs as $record) {
3257
            $subcategory = new self($record);
3258
            $tocache[$subcategory->id] = $subcategory;
3259
            if ($subcategory->has_capabilities($permissionstocheck)) {
3260
                $result = $subcategory;
3261
                break;
3262
            }
3263
        }
3264
        $rs->close();
3265
 
3266
        $coursecatrecordcache = cache::make('core', 'coursecatrecords');
3267
        $coursecatrecordcache->set_many($tocache);
3268
 
3269
        return $result;
3270
    }
3271
}