Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]

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