Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

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