coursecatlib.php 56.5 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Contains class coursecat reponsible for course category operations
 *
 * @package    core
 * @subpackage course
 * @copyright  2013 Marina Glancy
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

/**
 * Class to store, cache, render and manage course category
 *
 * @package    core
 * @subpackage course
 * @copyright  2013 Marina Glancy
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class coursecat implements renderable, cacheable_object, IteratorAggregate {
    /** @var coursecat stores pseudo category with id=0. Use coursecat::get(0) to retrieve */
    protected static $coursecat0;

    /** @var array list of all fields and their short name and default value for caching */
    protected static $coursecatfields = array(
        'id' => array('id', 0),
        'name' => array('na', ''),
        'idnumber' => array('in', null),
45
46
        'description' => null, // not cached
        'descriptionformat' => null, // not cached
47
48
        'parent' => array('pa', 0),
        'sortorder' => array('so', 0),
49
        'coursecount' => null, // not cached
50
        'visible' => array('vi', 1),
51
        'visibleold' => null, // not cached
52
53
54
        'timemodified' => null, // not cached
        'depth' => array('dh', 1),
        'path' => array('ph', null),
55
        'theme' => null, // not cached
56
57
58
59
60
61
62
63
64
65
66
67
    );

    /** @var int */
    protected $id;

    /** @var string */
    protected $name = '';

    /** @var string */
    protected $idnumber = null;

    /** @var string */
68
    protected $description = false;
69
70

    /** @var int */
71
    protected $descriptionformat = false;
72
73
74
75
76
77
78
79

    /** @var int */
    protected $parent = 0;

    /** @var int */
    protected $sortorder = 0;

    /** @var int */
80
    protected $coursecount = false;
81
82
83
84
85

    /** @var int */
    protected $visible = 1;

    /** @var int */
86
    protected $visibleold = false;
87
88

    /** @var int */
89
    protected $timemodified = false;
90
91
92
93
94
95
96
97

    /** @var int */
    protected $depth = 0;

    /** @var string */
    protected $path = '';

    /** @var string */
98
    protected $theme = false;
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114

    /** @var bool */
    protected $fromcache;

    // ====== magic methods =======

    /**
     * Magic setter method, we do not want anybody to modify properties from the outside
     * @param string $name
     * @param mixed $value
     */
    public function __set($name, $value) {
        debugging('Can not change coursecat instance properties!', DEBUG_DEVELOPER);
    }

    /**
115
     * Magic method getter, redirects to read only values. Queries from DB the fields that were not cached
116
117
118
119
     * @param string $name
     * @return mixed
     */
    public function __get($name) {
120
        global $DB;
121
        if (array_key_exists($name, self::$coursecatfields)) {
122
123
124
125
126
127
128
129
130
            if ($this->$name === false) {
                // property was not retrieved from DB, retrieve all not retrieved fields
                $notretrievedfields = array_diff(self::$coursecatfields, array_filter(self::$coursecatfields));
                $record = $DB->get_record('course_categories', array('id' => $this->id),
                        join(',', array_keys($notretrievedfields)), MUST_EXIST);
                foreach ($record as $key => $value) {
                    $this->$key = $value;
                }
            }
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
            return $this->$name;
        }
        debugging('Invalid coursecat property accessed! '.$name, DEBUG_DEVELOPER);
        return null;
    }

    /**
     * Full support for isset on our magic read only properties.
     * @param string $name
     * @return bool
     */
    public function __isset($name) {
        if (array_key_exists($name, self::$coursecatfields)) {
            return isset($this->$name);
        }
        return false;
    }

    /**
     * ALl properties are read only, sorry.
     * @param string $name
     */
    public function __unset($name) {
        debugging('Can not unset coursecat instance properties!', DEBUG_DEVELOPER);
    }

    // ====== implementing method from interface IteratorAggregate ======

    /**
     * Create an iterator because magic vars can't be seen by 'foreach'.
     */
    public function getIterator() {
        $ret = array();
        foreach (self::$coursecatfields as $property => $unused) {
165
166
167
            if ($this->$property !== false) {
                $ret[$property] = $this->$property;
            }
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
        }
        return new ArrayIterator($ret);
    }

    // ====== general coursecat methods ======

    /**
     * Constructor
     *
     * Constructor is protected, use coursecat::get($id) to retrieve category
     *
     * @param stdClass $record
     */
    protected function __construct(stdClass $record, $fromcache = false) {
        context_instance_preload($record);
        foreach ($record as $key => $val) {
            if (array_key_exists($key, self::$coursecatfields)) {
                $this->$key = $val;
            }
        }
        $this->fromcache = $fromcache;
    }

    /**
     * Returns coursecat object for requested category
     *
     * If category is not visible to user it is treated as non existing
195
     * unless $alwaysreturnhidden is set to true
196
197
198
199
200
201
202
203
     *
     * If id is 0, the pseudo object for root category is returned (convenient
     * for calling other functions such as get_children())
     *
     * @param int $id category id
     * @param int $strictness whether to throw an exception (MUST_EXIST) or
     *     return null (IGNORE_MISSING) in case the category is not found or
     *     not visible to current user
204
     * @param bool $alwaysreturnhidden set to true if you want an object to be
205
206
207
     *     returned even if this category is not visible to the current user
     *     (category is hidden and user does not have
     *     'moodle/category:viewhiddencategories' capability). Use with care!
208
     * @return null|coursecat
209
     */
210
    public static function get($id, $strictness = MUST_EXIST, $alwaysreturnhidden = false) {
211
212
        if (!$id) {
            if (!isset(self::$coursecat0)) {
213
214
215
216
217
218
                $record = new stdClass();
                $record->id = 0;
                $record->visible = 1;
                $record->depth = 0;
                $record->path = '';
                self::$coursecat0 = new coursecat($record);
219
220
221
            }
            return self::$coursecat0;
        }
222
223
        $coursecatrecordcache = cache::make('core', 'coursecatrecords');
        $coursecat = $coursecatrecordcache->get($id);
224
        if ($coursecat === false) {
225
226
227
228
229
            if ($records = self::get_records('cc.id = :id', array('id' => $id))) {
                $record = reset($records);
                $coursecat = new coursecat($record);
                // Store in cache
                $coursecatrecordcache->set($id, $coursecat);
230
231
            }
        }
232
        if ($coursecat && ($alwaysreturnhidden || $coursecat->is_uservisible())) {
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
            return $coursecat;
        } else {
            if ($strictness == MUST_EXIST) {
                throw new moodle_exception('unknowcategory');
            }
        }
        return null;
    }

    /**
     * Returns the first found category
     *
     * Note that if there are no categories visible to the current user on the first level,
     * the invisible category may be returned
     *
     * @return coursecat
     */
    public static function get_default() {
        if ($visiblechildren = self::get(0)->get_children()) {
            $defcategory = reset($visiblechildren);
        } else {
254
255
            $toplevelcategories = self::get_tree(0);
            $defcategoryid = $toplevelcategories[0];
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
            $defcategory = self::get($defcategoryid, MUST_EXIST, true);
        }
        return $defcategory;
    }

    /**
     * Restores the object after it has been externally modified in DB for example
     * during {@link fix_course_sortorder()}
     */
    protected function restore() {
        // update all fields in the current object
        $newrecord = self::get($this->id, MUST_EXIST, true);
        foreach (self::$coursecatfields as $key => $unused) {
            $this->$key = $newrecord->$key;
        }
    }

    /**
     * Creates a new category either from form data or from raw data
     *
     * Please note that this function does not verify access control.
     *
     * Exception is thrown if name is missing or idnumber is duplicating another one in the system.
     *
     * Category visibility is inherited from parent unless $data->visible = 0 is specified
     *
     * @param array|stdClass $data
     * @param array $editoroptions if specified, the data is considered to be
     *    form data and file_postupdate_standard_editor() is being called to
     *    process images in description.
     * @return coursecat
     * @throws moodle_exception
     */
    public static function create($data, $editoroptions = null) {
        global $DB, $CFG;
        $data = (object)$data;
        $newcategory = new stdClass();

        $newcategory->descriptionformat = FORMAT_MOODLE;
        $newcategory->description = '';
        // copy all description* fields regardless of whether this is form data or direct field update
        foreach ($data as $key => $value) {
            if (preg_match("/^description/", $key)) {
                $newcategory->$key = $value;
            }
        }

        if (empty($data->name)) {
            throw new moodle_exception('categorynamerequired');
        }
        if (textlib::strlen($data->name) > 255) {
            throw new moodle_exception('categorytoolong');
        }
        $newcategory->name = $data->name;

        // validate and set idnumber
        if (!empty($data->idnumber)) {
            if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) {
                throw new moodle_exception('categoryidnumbertaken');
            }
            if (textlib::strlen($data->idnumber) > 100) {
                throw new moodle_exception('idnumbertoolong');
            }
        }
        if (isset($data->idnumber)) {
            $newcategory->idnumber = $data->idnumber;
        }

        if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
            $newcategory->theme = $data->theme;
        }

        if (empty($data->parent)) {
            $parent = self::get(0);
        } else {
            $parent = self::get($data->parent, MUST_EXIST, true);
        }
        $newcategory->parent = $parent->id;
        $newcategory->depth = $parent->depth + 1;

        // By default category is visible, unless visible = 0 is specified or parent category is hidden
        if (isset($data->visible) && !$data->visible) {
            // create a hidden category
            $newcategory->visible = $newcategory->visibleold = 0;
        } else {
            // create a category that inherits visibility from parent
            $newcategory->visible = $parent->visible;
            // in case parent is hidden, when it changes visibility this new subcategory will automatically become visible too
            $newcategory->visibleold = 1;
        }

        $newcategory->sortorder = 0;
        $newcategory->timemodified = time();

        $newcategory->id = $DB->insert_record('course_categories', $newcategory);

        // update path (only possible after we know the category id
        $path = $parent->path . '/' . $newcategory->id;
        $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));

        // We should mark the context as dirty
        context_coursecat::instance($newcategory->id)->mark_dirty();

        fix_course_sortorder();

        // if this is data from form results, save embedded files and update description
        $categorycontext = context_coursecat::instance($newcategory->id);
        if ($editoroptions) {
            $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);

            // update only fields description and descriptionformat
            $updatedata = array_intersect_key((array)$newcategory, array('id' => 1, 'description' => 1, 'descriptionformat' => 1));
            $DB->update_record('course_categories', $updatedata);
        }

        add_to_log(SITEID, "category", 'add', "editcategory.php?id=$newcategory->id", $newcategory->id);
372
        cache_helper::purge_by_event('changesincoursecat');
373
374
375
376
377
378
379
380
381

        return self::get($newcategory->id, MUST_EXIST, true);
    }

    /**
     * Updates the record with either form data or raw data
     *
     * Please note that this function does not verify access control.
     *
382
383
     * This function calls coursecat::change_parent_raw if field 'parent' is updated.
     * It also calls coursecat::hide_raw or coursecat::show_raw if 'visible' is updated.
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
     * Visibility is changed first and then parent is changed. This means that
     * if parent category is hidden, the current category will become hidden
     * too and it may overwrite whatever was set in field 'visible'.
     *
     * Note that fields 'path' and 'depth' can not be updated manually
     * Also coursecat::update() can not directly update the field 'sortoder'
     *
     * @param array|stdClass $data
     * @param array $editoroptions if specified, the data is considered to be
     *    form data and file_postupdate_standard_editor() is being called to
     *    process images in description.
     * @throws moodle_exception
     */
    public function update($data, $editoroptions = null) {
        global $DB, $CFG;
        if (!$this->id) {
            // there is no actual DB record associated with root category
            return;
        }

        $data = (object)$data;
        $newcategory = new stdClass();
        $newcategory->id = $this->id;

        // copy all description* fields regardless of whether this is form data or direct field update
        foreach ($data as $key => $value) {
            if (preg_match("/^description/", $key)) {
                $newcategory->$key = $value;
            }
        }

        if (isset($data->name) && empty($data->name)) {
            throw new moodle_exception('categorynamerequired');
        }

        if (!empty($data->name) && $data->name !== $this->name) {
            if (textlib::strlen($data->name) > 255) {
                throw new moodle_exception('categorytoolong');
            }
            $newcategory->name = $data->name;
        }

        if (isset($data->idnumber) && $data->idnumber != $this->idnumber) {
            if (textlib::strlen($data->idnumber) > 100) {
                throw new moodle_exception('idnumbertoolong');
            }
            if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) {
                throw new moodle_exception('categoryidnumbertaken');
            }
            $newcategory->idnumber = $data->idnumber;
        }

        if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
            $newcategory->theme = $data->theme;
        }

        $changes = false;
        if (isset($data->visible)) {
            if ($data->visible) {
443
                $changes = $this->show_raw();
444
            } else {
445
                $changes = $this->hide_raw(0);
446
447
448
449
450
            }
        }

        if (isset($data->parent) && $data->parent != $this->parent) {
            if ($changes) {
451
                cache_helper::purge_by_event('changesincoursecat');
452
453
            }
            $parentcat = self::get($data->parent, MUST_EXIST, true);
454
            $this->change_parent_raw($parentcat);
455
456
457
458
459
460
461
462
463
464
465
466
            fix_course_sortorder();
        }

        $newcategory->timemodified = time();

        if ($editoroptions) {
            $categorycontext = context_coursecat::instance($this->id);
            $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
        }
        $DB->update_record('course_categories', $newcategory);
        add_to_log(SITEID, "category", 'update', "editcategory.php?id=$this->id", $this->id);
        fix_course_sortorder();
467
468
        // purge cache even if fix_course_sortorder() did not do it
        cache_helper::purge_by_event('changesincoursecat');
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528

        // update all fields in the current object
        $this->restore();
    }

    /**
     * Checks if this course category is visible to current user
     *
     * Please note that methods coursecat::get (without 3rd argumet),
     * coursecat::get_children(), etc. return only visible categories so it is
     * usually not needed to call this function outside of this class
     *
     * @return bool
     */
    public function is_uservisible() {
        return !$this->id || $this->visible ||
                has_capability('moodle/category:viewhiddencategories',
                        context_coursecat::instance($this->id));
    }

    /**
     * Returns all categories visible to the current user
     *
     * This is a generic function that returns an array of
     * (category id => coursecat object) sorted by sortorder
     *
     * @see coursecat::get_children()
     * @see coursecat::get_all_parents()
     *
     * @return cacheable_object_array array of coursecat objects
     */
    public static function get_all_visible() {
        global $USER;
        $coursecatcache = cache::make('core', 'coursecat');
        $ids = $coursecatcache->get('user'. $USER->id);
        if ($ids === false) {
            $all = self::get_all_ids();
            $parentvisible = $all[0];
            $rv = array();
            foreach ($all as $id => $children) {
                if ($id && in_array($id, $parentvisible) &&
                        ($coursecat = self::get($id, IGNORE_MISSING)) &&
                        (!$coursecat->parent || isset($rv[$coursecat->parent]))) {
                    $rv[$id] = $coursecat;
                    $parentvisible += $children;
                }
            }
            $coursecatcache->set('user'. $USER->id, array_keys($rv));
        } else {
            $rv = array();
            foreach ($ids as $id) {
                if ($coursecat = self::get($id, IGNORE_MISSING)) {
                    $rv[$id] = $coursecat;
                }
            }
        }
        return $rv;
    }

    /**
529
     * Returns the entry from categories tree and makes sure the application-level tree cache is built
530
     *
531
     * The following keys can be requested:
532
     *
533
534
535
536
537
538
539
540
541
     * 'countall' - total number of categories in the system (always present)
     * 0 - array of ids of top-level categories (always present)
     * '0i' - array of ids of top-level categories that have visible=0 (always present but may be empty array)
     * $id (int) - array of ids of categories that are direct children of category with id $id. If
     *   category with id $id does not exist returns false. If category has no children returns empty array
     * $id.'i' - array of ids of children categories that have visible=0
     *
     * @param int|string $id
     * @return mixed
542
     */
543
    protected static function get_tree($id) {
544
        global $DB;
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
        $coursecattreecache = cache::make('core', 'coursecattree');
        $rv = $coursecattreecache->get($id);
        if ($rv !== false) {
            return $rv;
        }
        // We did not find the entry in cache but it also can mean that tree is not built.
        // The keys 0 and 'countall' must always be present if tree is built.
        if ($id !== 0 && $id !== 'countall' && $coursecattreecache->has('countall')) {
            // Tree was built, it means the non-existing $id was requested.
            return false;
        }
        // Re-build the tree.
        $sql = "SELECT cc.id, cc.parent, cc.visible
                FROM {course_categories} cc
                ORDER BY cc.sortorder";
        $rs = $DB->get_recordset_sql($sql, array());
        $all = array(0 => array(), '0i' => array());
        $count = 0;
        foreach ($rs as $record) {
            $all[$record->id] = array();
            $all[$record->id. 'i'] = array();
            if (array_key_exists($record->parent, $all)) {
567
                $all[$record->parent][] = $record->id;
568
569
570
571
572
573
                if (!$record->visible) {
                    $all[$record->parent. 'i'][] = $record->id;
                }
            } else {
                // parent not found. This is data consistency error but next fix_course_sortorder() should fix it
                $all[0][] = $record->id;
574
            }
575
576
577
578
579
580
581
582
583
584
585
            $count++;
        }
        $rs->close();
        if (!$count) {
            // No categories found.
            // This may happen after upgrade from very old moodle version. In new versions the default category is created on install.
            $defcoursecat = self::create(array('name' => get_string('miscellaneous')));
            set_config('defaultrequestcategory', $defcoursecat->id);
            $all[0] = array($defcoursecat->id);
            $all[$defcoursecat->id] = array();
            $count++;
586
        }
587
588
589
590
591
592
593
594
        foreach ($all as $key => $children) {
            $coursecattreecache->set($key, $children);
        }
        $coursecattreecache->set('countall', $count);
        if (array_key_exists($id, $all)) {
            return $all[$id];
        }
        return false;
595
596
597
598
599
600
601
602
    }

    /**
     * Returns number of ALL categories in the system regardless if
     * they are visible to current user or not
     *
     * @return int
     */
603
    public static function count_all() {
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
        return self::get_tree('countall');
    }

    /**
     * Retrieves number of records from course_categories table
     *
     * Only cached fields are retrieved. Records are ready for preloading context
     *
     * @param string $whereclause
     * @param array $params
     * @return array array of stdClass objects
     */
    protected static function get_records($whereclause, $params) {
        global $DB;
        // Retrieve from DB only the fields that need to be stored in cache
        $fields = array_filter(array_keys(self::$coursecatfields), function ($element)
            { return (self::$coursecatfields[$element] !== null); } );
        $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
        $sql = "SELECT cc.". join(',cc.', $fields). ", $ctxselect
                FROM {course_categories} cc
                JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
                WHERE ". $whereclause." ORDER BY cc.sortorder";
        return $DB->get_records_sql($sql,
                array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
    }

    /**
     * Returns array of ids of children categories that current user can not see
     *
     * This data is cached in user session cache
     *
     * @return array
     */
    protected function get_not_visible_children_ids() {
        global $DB;
        $coursecatcache = cache::make('core', 'coursecat');
        if (($invisibleids = $coursecatcache->get('ic'. $this->id)) === false) {
            // we never checked visible children before
            $hidden = self::get_tree($this->id.'i');
            $invisibleids = array();
            if ($hidden) {
                // preload categories contexts
                list($sql, $params) = $DB->get_in_or_equal($hidden, SQL_PARAMS_NAMED, 'id');
                $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
                $contexts = $DB->get_records_sql("SELECT $ctxselect FROM {context} ctx
                    WHERE ctx.contextlevel = :contextcoursecat AND ctx.instanceid ".$sql,
                        array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
                foreach ($contexts as $record) {
                    context_helper::preload_from_record($record);
                }
                // check that user has 'viewhiddencategories' capability for each hidden category
                foreach ($hidden as $id) {
                    if (!has_capability('moodle/category:viewhiddencategories', context_coursecat::instance($id))) {
                        $invisibleids[] = $id;
                    }
                }
            }
            $coursecatcache->set('ic'. $this->id, $invisibleids);
        }
        return $invisibleids;
    }

    /**
     * Compares two records. For use in uasort()
     *
     * @param stdClass $a
     * @param stdClass $b
     * @param array $sortfields assoc array where key is the field to sort and value is 1 for asc or -1 for desc
     * @return int
     */
    protected static function compare_records($a, $b, $sortfields) {
        foreach ($sortfields as $field => $mult) {
            if ($field === 'name' || $field === 'idnumber' || $field === 'path') {
                // string fields
                if ($cmp = strcmp($a->$field, $b->$field)) {
                    // TODO textlib?
                    return $mult * $cmp;
                }
            } else {
                // int fields
                if ($a->$field > $b->$field) {
                    return $mult;
                }
                if ($a->$field < $b->$field) {
                    return -$mult;
                }
            }
        }
        return 0;
693
694
695
696
697
    }

    /**
     * Returns array of children categories visible to the current user
     *
698
699
700
701
702
703
704
705
     * @param array $options options for retrieving children
     *    - sort - list of fields to sort. Example
     *             array('idnumber' => 1, 'name' => 1, 'id' => -1)
     *             will sort by idnumber asc, name asc and id desc.
     *             Default: array('sortorder' => 1)
     *             Only cached fields may be used for sorting!
     *    - offset
     *    - limit - maximum number of children to return, 0 or null for no limit
706
707
     * @return array of coursecat objects indexed by category id
     */
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
    public function get_children($options = array()) {
        global $DB;
        $coursecatcache = cache::make('core', 'coursecat');

        // get default values for options
        if (!empty($options['sort']) && is_array($options['sort'])) {
            $sortfields = $options['sort'];
        } else {
            $sortfields = array('sortorder' => 1);
        }
        $limit = null;
        if (!empty($options['limit']) && (int)$options['limit']) {
            $limit = (int)$options['limit'];
        }
        $offset = 0;
        if (!empty($options['offset']) && (int)$options['offset']) {
            $offset = (int)$options['offset'];
        }

        // first retrieve list of user-visible and sorted children ids from cache
        $sortedids = $coursecatcache->get('c'. $this->id. ':'.  serialize($sortfields));
        if ($sortedids === false) {
            $sortfieldskeys = array_keys($sortfields);
            if ($sortfieldskeys[0] === 'sortorder') {
                // no DB requests required to build the list of ids sorted by sortorder.
                // We can easily ignore other sort fields because sortorder is always different
                $sortedids = self::get_tree($this->id);
                if ($sortedids && ($invisibleids = $this->get_not_visible_children_ids())) {
                    $sortedids = array_diff($sortedids, $invisibleids);
                    if ($sortfields['sortorder'] == -1) {
                        $sortedids = array_reverse($sortedids, true);
                    }
740
                }
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
            } else {
                // we need to retrieve and sort all children. Good thing that it is done only on first request
                if ($invisibleids = $this->get_not_visible_children_ids()) {
                    list($sql, $params) = $DB->get_in_or_equal($invisibleids, SQL_PARAMS_NAMED, 'id', false);
                    $records = self::get_records('cc.parent = :parent AND cc.id '. $sql,
                            array('parent' => $this->id) + $params);
                } else {
                    $records = self::get_records('cc.parent = :parent', array('parent' => $this->id));
                }
                uasort($records, function ($a, $b) use ($sortfields) { return self::compare_records($a, $b, $sortfields); });
                $sortedids = array_keys($records);
            }
            $coursecatcache->set('c'. $this->id. ':'.serialize($sortfields), $sortedids);
        }

        if (empty($sortedids)) {
            return array();
        }

        // now retrieive and return categories
        if ($offset || $limit) {
            $sortedids = array_slice($sortedids, $offset, $limit);
        }
        if (isset($records)) {
            // easy, we have already retrieved records
            if ($offset || $limit) {
                $records = array_slice($records, $offset, $limit, true);
            }
        } else {
            list($sql, $params) = $DB->get_in_or_equal($sortedids, SQL_PARAMS_NAMED, 'id');
            $records = self::get_records('cc.id '. $sql,
                    array('parent' => $this->id) + $params);
        }

        $rv = array();
        foreach ($sortedids as $id) {
            if (isset($records[$id])) {
                $rv[$id] = new coursecat($records[$id]);
779
780
781
782
783
            }
        }
        return $rv;
    }

784
785
786
787
788
789
790
791
792
793
794
    /**
     * Returns number of subcategories visible to the current user
     *
     * @return int
     */
    public function get_children_count() {
        $sortedids = self::get_tree($this->id);
        $invisibleids = $this->get_not_visible_children_ids();
        return count($sortedids) - count($invisibleids);
    }

795
796
797
798
799
800
    /**
     * Returns true if the category has ANY children, including those not visible to the user
     *
     * @return boolean
     */
    public function has_children() {
801
802
        $allchildren = self::get_tree($this->id);
        return !empty($allchildren);
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
    }

    /**
     * Returns true if the category has courses in it (count does not include courses
     * in child categories)
     *
     * @return bool
     */
    public function has_courses() {
        global $DB;
        return $DB->record_exists_sql("select 1 from {course} where category = ?",
                array($this->id));
    }

    /**
     * Returns true if user can delete current category and all its contents
     *
     * To be able to delete course category the user must have permission
     * 'moodle/category:manage' in ALL child course categories AND
     * be able to delete all courses
     *
     * @return bool
     */
    public function can_delete_full() {
        global $DB;
        if (!$this->id) {
            // fool-proof
            return false;
        }

        $context = context_coursecat::instance($this->id);
        if (!$this->is_uservisible() ||
                !has_capability('moodle/category:manage', $context)) {
            return false;
        }

839
840
841
842
843
844
845
846
847
848
849
850
851
        // Check all child categories (not only direct children)
        $sql = context_helper::get_preload_record_columns_sql('ctx');
        $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
            ' FROM {context} ctx '.
            ' JOIN {course_categories} c ON c.id = ctx.instanceid'.
            ' WHERE ctx.path like ? AND ctx.contextlevel = ?',
                array($context->path. '/%', CONTEXT_COURSECAT));
        foreach ($childcategories as $childcat) {
            context_helper::preload_from_record($childcat);
            $childcontext = context_coursecat::instance($childcat->id);
            if ((!$childcat->visible && !has_capability('moodle/category:viewhiddencategories', $childcontext)) ||
                    !has_capability('moodle/category:manage', $childcontext)) {
                return false;
852
853
854
855
            }
        }

        // Check courses
856
857
858
859
        $sql = context_helper::get_preload_record_columns_sql('ctx');
        $coursescontexts = $DB->get_records_sql('SELECT ctx.instanceid AS courseid, '.
                    $sql. ' FROM {context} ctx '.
                    'WHERE ctx.path like :pathmask and ctx.contextlevel = :courselevel',
860
861
                array('pathmask' => $context->path. '/%',
                    'courselevel' => CONTEXT_COURSE));
862
863
864
        foreach ($coursescontexts as $ctxrecord) {
            context_helper::preload_from_record($ctxrecord);
            if (!can_delete_course($ctxrecord->courseid)) {
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
                return false;
            }
        }

        return true;
    }

    /**
     * Recursively delete category including all subcategories and courses
     *
     * Function {@link coursecat::can_delete_full()} MUST be called prior
     * to calling this function because there is no capability check
     * inside this function
     *
     * @param boolean $showfeedback display some notices
     * @return array return deleted courses
     */
882
    public function delete_full($showfeedback = true) {
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
        global $CFG, $DB;
        require_once($CFG->libdir.'/gradelib.php');
        require_once($CFG->libdir.'/questionlib.php');
        require_once($CFG->dirroot.'/cohort/lib.php');

        $deletedcourses = array();

        // Get children. Note, we don't want to use cache here because
        // it would be rebuilt too often
        $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC');
        foreach ($children as $record) {
            $coursecat = new coursecat($record);
            $deletedcourses += $coursecat->delete_full($showfeedback);
        }

        if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) {
            foreach ($courses as $course) {
                if (!delete_course($course, false)) {
                    throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname);
                }
                $deletedcourses[] = $course;
            }
        }

        // move or delete cohorts in this context
        cohort_delete_category($this);

        // now delete anything that may depend on course category context
        grade_course_category_delete($this->id, 0, $showfeedback);
        if (!question_delete_course_category($this, 0, $showfeedback)) {
            throw new moodle_exception('cannotdeletecategoryquestions', '', '', $this->get_formatted_name());
        }

        // finally delete the category and it's context
        $DB->delete_records('course_categories', array('id' => $this->id));
        delete_context(CONTEXT_COURSECAT, $this->id);
        add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");

921
        cache_helper::purge_by_event('changesincoursecat');
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000

        events_trigger('course_category_deleted', $this);

        // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
        if ($this->id == $CFG->defaultrequestcategory) {
            set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
        }
        return $deletedcourses;
    }

    /**
     * Checks if user can delete this category and move content (courses, subcategories and questions)
     * to another category. If yes returns the array of possible target categories names
     *
     * If user can not manage this category or it is completely empty - empty array will be returned
     *
     * @return array
     */
    public function move_content_targets_list() {
        global $CFG;
        require_once($CFG->libdir . '/questionlib.php');
        $context = context_coursecat::instance($this->id);
        if (!$this->is_uservisible() ||
                !has_capability('moodle/category:manage', $context)) {
            // User is not able to manage current category, he is not able to delete it.
            // No possible target categories.
            return array();
        }

        $testcaps = array();
        // If this category has courses in it, user must have 'course:create' capability in target category.
        if ($this->has_courses()) {
            $testcaps[] = 'moodle/course:create';
        }
        // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
        if ($this->has_children() || question_context_has_any_questions($context)) {
            $testcaps[] = 'moodle/category:manage';
        }
        if (!empty($testcaps)) {
            // return list of categories excluding this one and it's children
            return self::make_categories_list($testcaps, $this->id);
        }

        // Category is completely empty, no need in target for contents.
        return array();
    }

    /**
     * Checks if user has capability to move all category content to the new parent before
     * removing this category
     *
     * @param int $newcatid
     * @return bool
     */
    public function can_move_content_to($newcatid) {
        global $CFG;
        require_once($CFG->libdir . '/questionlib.php');
        $context = context_coursecat::instance($this->id);
        if (!$this->is_uservisible() ||
                !has_capability('moodle/category:manage', $context)) {
            return false;
        }
        $testcaps = array();
        // If this category has courses in it, user must have 'course:create' capability in target category.
        if ($this->has_courses()) {
            $testcaps[] = 'moodle/course:create';
        }
        // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
        if ($this->has_children() || question_context_has_any_questions($context)) {
            $testcaps[] = 'moodle/category:manage';
        }
        if (!empty($testcaps)) {
            return has_all_capabilities($testcaps, context_coursecat::instance($newcatid));
        }

        // there is no content but still return true
        return true;
    }

For faster browsing, not all history is shown. View entire blame