Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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

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