MDL-38596 Optimise SQL in preloading course contacts for number of courses
[moodle.git] / lib / coursecatlib.php
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/>.
17 /**
18  * Contains class coursecat reponsible 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  */
26 defined('MOODLE_INTERNAL') || die();
28 /**
29  * Class to store, cache, render and manage course category
30  *
31  * @package    core
32  * @subpackage course
33  * @copyright  2013 Marina Glancy
34  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class coursecat implements renderable, cacheable_object, IteratorAggregate {
37     /** @var coursecat stores pseudo category with id=0. Use coursecat::get(0) to retrieve */
38     protected static $coursecat0;
40     /** @var array list of all fields and their short name and default value for caching */
41     protected static $coursecatfields = array(
42         'id' => array('id', 0),
43         'name' => array('na', ''),
44         'idnumber' => array('in', null),
45         'description' => null, // not cached
46         'descriptionformat' => null, // not cached
47         'parent' => array('pa', 0),
48         'sortorder' => array('so', 0),
49         'coursecount' => null, // not cached
50         'visible' => array('vi', 1),
51         'visibleold' => null, // not cached
52         'timemodified' => null, // not cached
53         'depth' => array('dh', 1),
54         'path' => array('ph', null),
55         'theme' => null, // not cached
56     );
58     /** @var int */
59     protected $id;
61     /** @var string */
62     protected $name = '';
64     /** @var string */
65     protected $idnumber = null;
67     /** @var string */
68     protected $description = false;
70     /** @var int */
71     protected $descriptionformat = false;
73     /** @var int */
74     protected $parent = 0;
76     /** @var int */
77     protected $sortorder = 0;
79     /** @var int */
80     protected $coursecount = false;
82     /** @var int */
83     protected $visible = 1;
85     /** @var int */
86     protected $visibleold = false;
88     /** @var int */
89     protected $timemodified = false;
91     /** @var int */
92     protected $depth = 0;
94     /** @var string */
95     protected $path = '';
97     /** @var string */
98     protected $theme = false;
100     /** @var bool */
101     protected $fromcache;
103     // ====== magic methods =======
105     /**
106      * Magic setter method, we do not want anybody to modify properties from the outside
107      *
108      * @param string $name
109      * @param mixed $value
110      */
111     public function __set($name, $value) {
112         debugging('Can not change coursecat instance properties!', DEBUG_DEVELOPER);
113     }
115     /**
116      * Magic method getter, redirects to read only values. Queries from DB the fields that were not cached
117      *
118      * @param string $name
119      * @return mixed
120      */
121     public function __get($name) {
122         global $DB;
123         if (array_key_exists($name, self::$coursecatfields)) {
124             if ($this->$name === false) {
125                 // property was not retrieved from DB, retrieve all not retrieved fields
126                 $notretrievedfields = array_diff_key(self::$coursecatfields, array_filter(self::$coursecatfields));
127                 $record = $DB->get_record('course_categories', array('id' => $this->id),
128                         join(',', array_keys($notretrievedfields)), MUST_EXIST);
129                 foreach ($record as $key => $value) {
130                     $this->$key = $value;
131                 }
132             }
133             return $this->$name;
134         }
135         debugging('Invalid coursecat property accessed! '.$name, DEBUG_DEVELOPER);
136         return null;
137     }
139     /**
140      * Full support for isset on our magic read only properties.
141      *
142      * @param string $name
143      * @return bool
144      */
145     public function __isset($name) {
146         if (array_key_exists($name, self::$coursecatfields)) {
147             return isset($this->$name);
148         }
149         return false;
150     }
152     /**
153      * All properties are read only, sorry.
154      *
155      * @param string $name
156      */
157     public function __unset($name) {
158         debugging('Can not unset coursecat instance properties!', DEBUG_DEVELOPER);
159     }
161     /**
162      * Create an iterator because magic vars can't be seen by 'foreach'.
163      *
164      * implementing method from interface IteratorAggregate
165      *
166      * @return ArrayIterator
167      */
168     public function getIterator() {
169         $ret = array();
170         foreach (self::$coursecatfields as $property => $unused) {
171             if ($this->$property !== false) {
172                 $ret[$property] = $this->$property;
173             }
174         }
175         return new ArrayIterator($ret);
176     }
178     /**
179      * Constructor
180      *
181      * Constructor is protected, use coursecat::get($id) to retrieve category
182      *
183      * @param stdClass $record record from DB (may not contain all fields)
184      * @param bool $fromcache whether it is being restored from cache
185      */
186     protected function __construct(stdClass $record, $fromcache = false) {
187         context_helper::preload_from_record($record);
188         foreach ($record as $key => $val) {
189             if (array_key_exists($key, self::$coursecatfields)) {
190                 $this->$key = $val;
191             }
192         }
193         $this->fromcache = $fromcache;
194     }
196     /**
197      * Returns coursecat object for requested category
198      *
199      * If category is not visible to user it is treated as non existing
200      * unless $alwaysreturnhidden is set to true
201      *
202      * If id is 0, the pseudo object for root category is returned (convenient
203      * for calling other functions such as get_children())
204      *
205      * @param int $id category id
206      * @param int $strictness whether to throw an exception (MUST_EXIST) or
207      *     return null (IGNORE_MISSING) in case the category is not found or
208      *     not visible to current user
209      * @param bool $alwaysreturnhidden set to true if you want an object to be
210      *     returned even if this category is not visible to the current user
211      *     (category is hidden and user does not have
212      *     'moodle/category:viewhiddencategories' capability). Use with care!
213      * @return null|coursecat
214      */
215     public static function get($id, $strictness = MUST_EXIST, $alwaysreturnhidden = false) {
216         if (!$id) {
217             if (!isset(self::$coursecat0)) {
218                 $record = new stdClass();
219                 $record->id = 0;
220                 $record->visible = 1;
221                 $record->depth = 0;
222                 $record->path = '';
223                 self::$coursecat0 = new coursecat($record);
224             }
225             return self::$coursecat0;
226         }
227         $coursecatrecordcache = cache::make('core', 'coursecatrecords');
228         $coursecat = $coursecatrecordcache->get($id);
229         if ($coursecat === false) {
230             if ($records = self::get_records('cc.id = :id', array('id' => $id))) {
231                 $record = reset($records);
232                 $coursecat = new coursecat($record);
233                 // Store in cache
234                 $coursecatrecordcache->set($id, $coursecat);
235             }
236         }
237         if ($coursecat && ($alwaysreturnhidden || $coursecat->is_uservisible())) {
238             return $coursecat;
239         } else {
240             if ($strictness == MUST_EXIST) {
241                 throw new moodle_exception('unknowcategory');
242             }
243         }
244         return null;
245     }
247     /**
248      * Returns the first found category
249      *
250      * Note that if there are no categories visible to the current user on the first level,
251      * the invisible category may be returned
252      *
253      * @return coursecat
254      */
255     public static function get_default() {
256         if ($visiblechildren = self::get(0)->get_children()) {
257             $defcategory = reset($visiblechildren);
258         } else {
259             $toplevelcategories = self::get_tree(0);
260             $defcategoryid = $toplevelcategories[0];
261             $defcategory = self::get($defcategoryid, MUST_EXIST, true);
262         }
263         return $defcategory;
264     }
266     /**
267      * Restores the object after it has been externally modified in DB for example
268      * during {@link fix_course_sortorder()}
269      */
270     protected function restore() {
271         // update all fields in the current object
272         $newrecord = self::get($this->id, MUST_EXIST, true);
273         foreach (self::$coursecatfields as $key => $unused) {
274             $this->$key = $newrecord->$key;
275         }
276     }
278     /**
279      * Creates a new category either from form data or from raw data
280      *
281      * Please note that this function does not verify access control.
282      *
283      * Exception is thrown if name is missing or idnumber is duplicating another one in the system.
284      *
285      * Category visibility is inherited from parent unless $data->visible = 0 is specified
286      *
287      * @param array|stdClass $data
288      * @param array $editoroptions if specified, the data is considered to be
289      *    form data and file_postupdate_standard_editor() is being called to
290      *    process images in description.
291      * @return coursecat
292      * @throws moodle_exception
293      */
294     public static function create($data, $editoroptions = null) {
295         global $DB, $CFG;
296         $data = (object)$data;
297         $newcategory = new stdClass();
299         $newcategory->descriptionformat = FORMAT_MOODLE;
300         $newcategory->description = '';
301         // copy all description* fields regardless of whether this is form data or direct field update
302         foreach ($data as $key => $value) {
303             if (preg_match("/^description/", $key)) {
304                 $newcategory->$key = $value;
305             }
306         }
308         if (empty($data->name)) {
309             throw new moodle_exception('categorynamerequired');
310         }
311         if (textlib::strlen($data->name) > 255) {
312             throw new moodle_exception('categorytoolong');
313         }
314         $newcategory->name = $data->name;
316         // validate and set idnumber
317         if (!empty($data->idnumber)) {
318             if (textlib::strlen($data->idnumber) > 100) {
319                 throw new moodle_exception('idnumbertoolong');
320             }
321             if ($DB->record_exists('course_categories', array('idnumber' => $data->idnumber))) {
322                 throw new moodle_exception('categoryidnumbertaken');
323             }
324         }
325         if (isset($data->idnumber)) {
326             $newcategory->idnumber = $data->idnumber;
327         }
329         if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
330             $newcategory->theme = $data->theme;
331         }
333         if (empty($data->parent)) {
334             $parent = self::get(0);
335         } else {
336             $parent = self::get($data->parent, MUST_EXIST, true);
337         }
338         $newcategory->parent = $parent->id;
339         $newcategory->depth = $parent->depth + 1;
341         // By default category is visible, unless visible = 0 is specified or parent category is hidden
342         if (isset($data->visible) && !$data->visible) {
343             // create a hidden category
344             $newcategory->visible = $newcategory->visibleold = 0;
345         } else {
346             // create a category that inherits visibility from parent
347             $newcategory->visible = $parent->visible;
348             // in case parent is hidden, when it changes visibility this new subcategory will automatically become visible too
349             $newcategory->visibleold = 1;
350         }
352         $newcategory->sortorder = 0;
353         $newcategory->timemodified = time();
355         $newcategory->id = $DB->insert_record('course_categories', $newcategory);
357         // update path (only possible after we know the category id
358         $path = $parent->path . '/' . $newcategory->id;
359         $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));
361         // We should mark the context as dirty
362         context_coursecat::instance($newcategory->id)->mark_dirty();
364         fix_course_sortorder();
366         // if this is data from form results, save embedded files and update description
367         $categorycontext = context_coursecat::instance($newcategory->id);
368         if ($editoroptions) {
369             $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
371             // update only fields description and descriptionformat
372             $updatedata = new stdClass();
373             $updatedata->id = $newcategory->id;
374             $updatedata->description = $newcategory->description;
375             $updatedata->descriptionformat = $newcategory->descriptionformat;
376             $DB->update_record('course_categories', $updatedata);
377         }
379         add_to_log(SITEID, "category", 'add', "editcategory.php?id=$newcategory->id", $newcategory->id);
380         cache_helper::purge_by_event('changesincoursecat');
382         return self::get($newcategory->id, MUST_EXIST, true);
383     }
385     /**
386      * Updates the record with either form data or raw data
387      *
388      * Please note that this function does not verify access control.
389      *
390      * This function calls coursecat::change_parent_raw if field 'parent' is updated.
391      * It also calls coursecat::hide_raw or coursecat::show_raw if 'visible' is updated.
392      * Visibility is changed first and then parent is changed. This means that
393      * if parent category is hidden, the current category will become hidden
394      * too and it may overwrite whatever was set in field 'visible'.
395      *
396      * Note that fields 'path' and 'depth' can not be updated manually
397      * Also coursecat::update() can not directly update the field 'sortoder'
398      *
399      * @param array|stdClass $data
400      * @param array $editoroptions if specified, the data is considered to be
401      *    form data and file_postupdate_standard_editor() is being called to
402      *    process images in description.
403      * @throws moodle_exception
404      */
405     public function update($data, $editoroptions = null) {
406         global $DB, $CFG;
407         if (!$this->id) {
408             // there is no actual DB record associated with root category
409             return;
410         }
412         $data = (object)$data;
413         $newcategory = new stdClass();
414         $newcategory->id = $this->id;
416         // copy all description* fields regardless of whether this is form data or direct field update
417         foreach ($data as $key => $value) {
418             if (preg_match("/^description/", $key)) {
419                 $newcategory->$key = $value;
420             }
421         }
423         if (isset($data->name) && empty($data->name)) {
424             throw new moodle_exception('categorynamerequired');
425         }
427         if (!empty($data->name) && $data->name !== $this->name) {
428             if (textlib::strlen($data->name) > 255) {
429                 throw new moodle_exception('categorytoolong');
430             }
431             $newcategory->name = $data->name;
432         }
434         if (isset($data->idnumber) && $data->idnumber != $this->idnumber) {
435             if (textlib::strlen($data->idnumber) > 100) {
436                 throw new moodle_exception('idnumbertoolong');
437             }
438             if ($DB->record_exists('course_categories', array('idnumber' => $data->idnumber))) {
439                 throw new moodle_exception('categoryidnumbertaken');
440             }
441             $newcategory->idnumber = $data->idnumber;
442         }
444         if (isset($data->theme) && !empty($CFG->allowcategorythemes)) {
445             $newcategory->theme = $data->theme;
446         }
448         $changes = false;
449         if (isset($data->visible)) {
450             if ($data->visible) {
451                 $changes = $this->show_raw();
452             } else {
453                 $changes = $this->hide_raw(0);
454             }
455         }
457         if (isset($data->parent) && $data->parent != $this->parent) {
458             if ($changes) {
459                 cache_helper::purge_by_event('changesincoursecat');
460             }
461             $parentcat = self::get($data->parent, MUST_EXIST, true);
462             $this->change_parent_raw($parentcat);
463             fix_course_sortorder();
464         }
466         $newcategory->timemodified = time();
468         if ($editoroptions) {
469             $categorycontext = context_coursecat::instance($this->id);
470             $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0);
471         }
472         $DB->update_record('course_categories', $newcategory);
473         add_to_log(SITEID, "category", 'update', "editcategory.php?id=$this->id", $this->id);
474         fix_course_sortorder();
475         // purge cache even if fix_course_sortorder() did not do it
476         cache_helper::purge_by_event('changesincoursecat');
478         // update all fields in the current object
479         $this->restore();
480     }
482     /**
483      * Checks if this course category is visible to current user
484      *
485      * Please note that methods coursecat::get (without 3rd argumet),
486      * coursecat::get_children(), etc. return only visible categories so it is
487      * usually not needed to call this function outside of this class
488      *
489      * @return bool
490      */
491     public function is_uservisible() {
492         return !$this->id || $this->visible ||
493                 has_capability('moodle/category:viewhiddencategories',
494                         context_coursecat::instance($this->id));
495     }
497     /**
498      * Returns all categories visible to the current user
499      *
500      * This is a generic function that returns an array of
501      * (category id => coursecat object) sorted by sortorder
502      *
503      * @see coursecat::get_children()
504      * @see coursecat::get_all_parents()
505      *
506      * @return cacheable_object_array array of coursecat objects
507      */
508     public static function get_all_visible() {
509         global $USER;
510         $coursecatcache = cache::make('core', 'coursecat');
511         $ids = $coursecatcache->get('user'. $USER->id);
512         if ($ids === false) {
513             $all = self::get_all_ids();
514             $parentvisible = $all[0];
515             $rv = array();
516             foreach ($all as $id => $children) {
517                 if ($id && in_array($id, $parentvisible) &&
518                         ($coursecat = self::get($id, IGNORE_MISSING)) &&
519                         (!$coursecat->parent || isset($rv[$coursecat->parent]))) {
520                     $rv[$id] = $coursecat;
521                     $parentvisible += $children;
522                 }
523             }
524             $coursecatcache->set('user'. $USER->id, array_keys($rv));
525         } else {
526             $rv = array();
527             foreach ($ids as $id) {
528                 if ($coursecat = self::get($id, IGNORE_MISSING)) {
529                     $rv[$id] = $coursecat;
530                 }
531             }
532         }
533         return $rv;
534     }
536     /**
537      * Returns the entry from categories tree and makes sure the application-level tree cache is built
538      *
539      * The following keys can be requested:
540      *
541      * 'countall' - total number of categories in the system (always present)
542      * 0 - array of ids of top-level categories (always present)
543      * '0i' - array of ids of top-level categories that have visible=0 (always present but may be empty array)
544      * $id (int) - array of ids of categories that are direct children of category with id $id. If
545      *   category with id $id does not exist returns false. If category has no children returns empty array
546      * $id.'i' - array of ids of children categories that have visible=0
547      *
548      * @param int|string $id
549      * @return mixed
550      */
551     protected static function get_tree($id) {
552         global $DB;
553         $coursecattreecache = cache::make('core', 'coursecattree');
554         $rv = $coursecattreecache->get($id);
555         if ($rv !== false) {
556             return $rv;
557         }
558         // We did not find the entry in cache but it also can mean that tree is not built.
559         // The keys 0 and 'countall' must always be present if tree is built.
560         if ($id !== 0 && $id !== 'countall' && $coursecattreecache->has('countall')) {
561             // Tree was built, it means the non-existing $id was requested.
562             return false;
563         }
564         // Re-build the tree.
565         $sql = "SELECT cc.id, cc.parent, cc.visible
566                 FROM {course_categories} cc
567                 ORDER BY cc.sortorder";
568         $rs = $DB->get_recordset_sql($sql, array());
569         $all = array(0 => array(), '0i' => array());
570         $count = 0;
571         foreach ($rs as $record) {
572             $all[$record->id] = array();
573             $all[$record->id. 'i'] = array();
574             if (array_key_exists($record->parent, $all)) {
575                 $all[$record->parent][] = $record->id;
576                 if (!$record->visible) {
577                     $all[$record->parent. 'i'][] = $record->id;
578                 }
579             } else {
580                 // parent not found. This is data consistency error but next fix_course_sortorder() should fix it
581                 $all[0][] = $record->id;
582             }
583             $count++;
584         }
585         $rs->close();
586         if (!$count) {
587             // No categories found.
588             // This may happen after upgrade from very old moodle version. In new versions the default category is created on install.
589             $defcoursecat = self::create(array('name' => get_string('miscellaneous')));
590             set_config('defaultrequestcategory', $defcoursecat->id);
591             $all[0] = array($defcoursecat->id);
592             $all[$defcoursecat->id] = array();
593             $count++;
594         }
595         $all['countall'] = $count;
596         foreach ($all as $key => $children) {
597             $coursecattreecache->set($key, $children);
598         }
599         if (array_key_exists($id, $all)) {
600             return $all[$id];
601         }
602         return false;
603     }
605     /**
606      * Returns number of ALL categories in the system regardless if
607      * they are visible to current user or not
608      *
609      * @return int
610      */
611     public static function count_all() {
612         return self::get_tree('countall');
613     }
615     /**
616      * Retrieves number of records from course_categories table
617      *
618      * Only cached fields are retrieved. Records are ready for preloading context
619      *
620      * @param string $whereclause
621      * @param array $params
622      * @return array array of stdClass objects
623      */
624     protected static function get_records($whereclause, $params) {
625         global $DB;
626         // Retrieve from DB only the fields that need to be stored in cache
627         $fields = array_keys(array_filter(self::$coursecatfields));
628         $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
629         $sql = "SELECT cc.". join(',cc.', $fields). ", $ctxselect
630                 FROM {course_categories} cc
631                 JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
632                 WHERE ". $whereclause." ORDER BY cc.sortorder";
633         return $DB->get_records_sql($sql,
634                 array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
635     }
637     /**
638      * Given list of DB records from table course populates each record with list of users with course contact roles
639      *
640      * This function fills the courses with raw information as {@link get_role_users()} would do.
641      * See also {@link course_in_list::get_course_contacts()} for more readable return
642      *
643      * $courses[$i]->managers = array(
644      *   $roleassignmentid => $roleuser,
645      *   ...
646      * );
647      *
648      * where $roleuser is an stdClass with the following properties:
649      *
650      * $roleuser->raid - role assignment id
651      * $roleuser->id - user id
652      * $roleuser->username
653      * $roleuser->firstname
654      * $roleuser->lastname
655      * $roleuser->rolecoursealias
656      * $roleuser->rolename
657      * $roleuser->sortorder - role sortorder
658      * $roleuser->roleid
659      * $roleuser->roleshortname
660      *
661      * @todo MDL-38596 minimize number of queries to preload contacts for the list of courses
662      *
663      * @param array $courses
664      */
665     public static function preload_course_contacts(&$courses) {
666         global $CFG, $DB;
667         if (empty($courses) || empty($CFG->coursecontact)) {
668             return;
669         }
670         $managerroles = explode(',', $CFG->coursecontact);
672         // List of ids of courses for which we need to retrieve contacts
673         $courseids = array_keys($courses);
675         // first build the array of all context ids of the courses and their categories
676         $allcontexts = array();
677         foreach ($courseids as $id) {
678             $context = context_course::instance($id);
679             $courses[$id]->managers = array();
680             foreach (preg_split('|/|', $context->path, 0, PREG_SPLIT_NO_EMPTY) as $ctxid) {
681                 if (!isset($allcontexts[$ctxid])) {
682                     $allcontexts[$ctxid] = array();
683                 }
684                 $allcontexts[$ctxid][] = $id;
685             }
686         }
688         // fetch list of all users with course contact roles in any of the courses contexts or parent contexts
689         list($sql1, $params1) = $DB->get_in_or_equal(array_keys($allcontexts), SQL_PARAMS_NAMED, 'ctxid');
690         list($sql2, $params2) = $DB->get_in_or_equal($managerroles, SQL_PARAMS_NAMED, 'rid');
691         list($sort, $sortparams) = users_order_by_sql('u');
692         $sql = "SELECT ra.contextid, ra.id AS raid,
693                        r.id AS roleid, r.name AS rolename, r.shortname AS roleshortname,
694                        rn.name AS rolecoursealias, u.id, u.username, u.firstname, u.lastname
695                   FROM {role_assignments} ra
696                   JOIN {user} u ON ra.userid = u.id
697                   JOIN {role} r ON ra.roleid = r.id
698              LEFT JOIN {role_names} rn ON (rn.contextid = ra.contextid AND rn.roleid = r.id)
699                 WHERE  ra.contextid ". $sql1." AND ra.roleid ". $sql2."
700              ORDER BY r.sortorder, $sort";
701         $rs = $DB->get_recordset_sql($sql, $params1 + $params2 + $sortparams);
702         $checkenrolments = array();
703         foreach($rs as $ra) {
704             foreach ($allcontexts[$ra->contextid] as $id) {
705                 $courses[$id]->managers[$ra->raid] = $ra;
706                 if (!isset($checkenrolments[$id])) {
707                     $checkenrolments[$id] = array();
708                 }
709                 $checkenrolments[$id][] = $ra->id;
710             }
711         }
712         $rs->close();
714         // remove from course contacts users who are not enrolled in the course
715         $enrolleduserids = self::ensure_users_enrolled($checkenrolments);
716         foreach ($checkenrolments as $id => $userids) {
717             if (empty($enrolleduserids[$id])) {
718                 $courses[$id]->managers = array();
719             } else if ($notenrolled = array_diff($userids, $enrolleduserids[$id])) {
720                 foreach ($courses[$id]->managers as $raid => $ra) {
721                     if (in_array($ra->id, $notenrolled)) {
722                         unset($courses[$id]->managers[$raid]);
723                     }
724                 }
725             }
726         }
727     }
729     /**
730      * Verify user enrollments for multiple course-user combinations
731      *
732      * @param array $courseusers array where keys are course ids and values are array
733      *     of users in this course whose enrolment we wish to verify
734      * @return array same structure as input array but values list only users from input
735      *     who are enrolled in the course
736      */
737     protected static function ensure_users_enrolled($courseusers) {
738         global $DB;
739         // If the input array is too big, split it into chunks
740         $maxcoursesinquery = 20;
741         if (count($courseusers) > $maxcoursesinquery) {
742             $rv = array();
743             for ($offset = 0; $offset < count($courseusers); $offset += $maxcoursesinquery) {
744                 $chunk = array_slice($courseusers, $offset, $maxcoursesinquery, true);
745                 $rv = $rv + self::ensure_users_enrolled($chunk);
746             }
747             return $rv;
748         }
750         // create a query verifying valid user enrolments for the number of courses
751         $sql = "SELECT DISTINCT e.courseid, ue.userid
752           FROM {user_enrolments} ue
753           JOIN {enrol} e ON e.id = ue.enrolid
754           WHERE ue.status = :active
755             AND e.status = :enabled
756             AND ue.timestart < :now1 AND (ue.timeend = 0 OR ue.timeend > :now2)";
757         $now = round(time(), -2); // rounding helps caching in DB
758         $params = array('enabled' => ENROL_INSTANCE_ENABLED,
759             'active' => ENROL_USER_ACTIVE,
760             'now1' => $now, 'now2' => $now);
761         $cnt = 0;
762         $subsqls = array();
763         $enrolled = array();
764         foreach ($courseusers as $id => $userids) {
765             $enrolled[$id] = array();
766             if (count($userids)) {
767                 list($sql2, $params2) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'userid'.$cnt.'_');
768                 $subsqls[] = "(e.courseid = :courseid$cnt AND ue.userid ".$sql2.")";
769                 $params = $params + array('courseid'.$cnt => $id) + $params2;
770                 $cnt++;
771             }
772         }
773         if (count($subsqls)) {
774             $sql .= "AND (". join(' OR ', $subsqls).")";
775             $rs = $DB->get_recordset_sql($sql, $params);
776             foreach ($rs as $record) {
777                 $enrolled[$record->courseid][] = $record->userid;
778             }
779             $rs->close();
780         }
781         return $enrolled;
782     }
784     /**
785      * Retrieves number of records from course table
786      *
787      * Not all fields are retrieved. Records are ready for preloading context
788      *
789      * @param string $whereclause
790      * @param array $params
791      * @param array $options may indicate that summary and/or coursecontacts need to be retrieved
792      * @param bool $checkvisibility if true, capability 'moodle/course:viewhiddencourses' will be checked
793      *     on not visible courses
794      * @return array array of stdClass objects
795      */
796     protected static function get_course_records($whereclause, $params, $options, $checkvisibility = false) {
797         global $DB;
798         $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
799         $fields = array('c.id', 'c.category', 'c.sortorder',
800                         'c.shortname', 'c.fullname', 'c.idnumber',
801                         'c.startdate', 'c.visible');
802         if (!empty($options['summary'])) {
803             $fields[] = 'c.summary';
804             $fields[] = 'c.summaryformat';
805         } else {
806             $fields[] = $DB->sql_substr('c.summary', 1, 1). ' hassummary';
807         }
808         $sql = "SELECT ". join(',', $fields). ", $ctxselect
809                 FROM {course} c
810                 JOIN {context} ctx ON c.id = ctx.instanceid AND ctx.contextlevel = :contextcourse
811                 WHERE ". $whereclause." ORDER BY c.sortorder";
812         $list = $DB->get_records_sql($sql,
813                 array('contextcourse' => CONTEXT_COURSE) + $params);
815         if ($checkvisibility) {
816             // Loop through all records and make sure we only return the courses accessible by user.
817             foreach ($list as $course) {
818                 if (isset($list[$course->id]->hassummary)) {
819                     $list[$course->id]->hassummary = strlen($list[$course->id]->hassummary) > 0;
820                 }
821                 if (empty($course->visible)) {
822                     // load context only if we need to check capability
823                     context_helper::preload_from_record($course);
824                     if (!has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
825                         unset($list[$course->id]);
826                     }
827                 }
828             }
829         }
831         // preload course contacts if necessary
832         if (!empty($options['coursecontacts'])) {
833             self::preload_course_contacts($list);
834         }
835         return $list;
836     }
838     /**
839      * Returns array of ids of children categories that current user can not see
840      *
841      * This data is cached in user session cache
842      *
843      * @return array
844      */
845     protected function get_not_visible_children_ids() {
846         global $DB;
847         $coursecatcache = cache::make('core', 'coursecat');
848         if (($invisibleids = $coursecatcache->get('ic'. $this->id)) === false) {
849             // we never checked visible children before
850             $hidden = self::get_tree($this->id.'i');
851             $invisibleids = array();
852             if ($hidden) {
853                 // preload categories contexts
854                 list($sql, $params) = $DB->get_in_or_equal($hidden, SQL_PARAMS_NAMED, 'id');
855                 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
856                 $contexts = $DB->get_records_sql("SELECT $ctxselect FROM {context} ctx
857                     WHERE ctx.contextlevel = :contextcoursecat AND ctx.instanceid ".$sql,
858                         array('contextcoursecat' => CONTEXT_COURSECAT) + $params);
859                 foreach ($contexts as $record) {
860                     context_helper::preload_from_record($record);
861                 }
862                 // check that user has 'viewhiddencategories' capability for each hidden category
863                 foreach ($hidden as $id) {
864                     if (!has_capability('moodle/category:viewhiddencategories', context_coursecat::instance($id))) {
865                         $invisibleids[] = $id;
866                     }
867                 }
868             }
869             $coursecatcache->set('ic'. $this->id, $invisibleids);
870         }
871         return $invisibleids;
872     }
874     /**
875      * Sorts list of records by several fields
876      *
877      * @param array $records array of stdClass objects
878      * @param array $sortfields assoc array where key is the field to sort and value is 1 for asc or -1 for desc
879      * @return int
880      */
881     protected static function sort_records(&$records, $sortfields) {
882         if (empty($records)) {
883             return;
884         }
885         // If sorting by course display name, calculate it (it may be fullname or shortname+fullname)
886         if (array_key_exists('displayname', $sortfields)) {
887             foreach ($records as $key => $record) {
888                 if (!isset($record->displayname)) {
889                     $records[$key]->displayname = get_course_display_name_for_list($record);
890                 }
891             }
892         }
893         // sorting by one field - use collatorlib
894         if (count($sortfields) == 1) {
895             $property = key($sortfields);
896             if (in_array($property, array('sortorder', 'id', 'visible', 'parent', 'depth'))) {
897                 $sortflag = collatorlib::SORT_NUMERIC;
898             } else if (in_array($property, array('idnumber', 'displayname', 'name', 'shortname', 'fullname'))) {
899                 $sortflag = collatorlib::SORT_STRING;
900             } else {
901                 $sortflag = collatorlib::SORT_REGULAR;
902             }
903             collatorlib::asort_objects_by_property($records, $property, $sortflag);
904             if ($sortfields[$property] < 0) {
905                 $records = array_reverse($records, true);
906             }
907             return;
908         }
909         $records = coursecat_sortable_records::sort($records, $sortfields);
910     }
912     /**
913      * Returns array of children categories visible to the current user
914      *
915      * @param array $options options for retrieving children
916      *    - sort - list of fields to sort. Example
917      *             array('idnumber' => 1, 'name' => 1, 'id' => -1)
918      *             will sort by idnumber asc, name asc and id desc.
919      *             Default: array('sortorder' => 1)
920      *             Only cached fields may be used for sorting!
921      *    - offset
922      *    - limit - maximum number of children to return, 0 or null for no limit
923      * @return array of coursecat objects indexed by category id
924      */
925     public function get_children($options = array()) {
926         global $DB;
927         $coursecatcache = cache::make('core', 'coursecat');
929         // get default values for options
930         if (!empty($options['sort']) && is_array($options['sort'])) {
931             $sortfields = $options['sort'];
932         } else {
933             $sortfields = array('sortorder' => 1);
934         }
935         $limit = null;
936         if (!empty($options['limit']) && (int)$options['limit']) {
937             $limit = (int)$options['limit'];
938         }
939         $offset = 0;
940         if (!empty($options['offset']) && (int)$options['offset']) {
941             $offset = (int)$options['offset'];
942         }
944         // first retrieve list of user-visible and sorted children ids from cache
945         $sortedids = $coursecatcache->get('c'. $this->id. ':'.  serialize($sortfields));
946         if ($sortedids === false) {
947             $sortfieldskeys = array_keys($sortfields);
948             if ($sortfieldskeys[0] === 'sortorder') {
949                 // no DB requests required to build the list of ids sorted by sortorder.
950                 // We can easily ignore other sort fields because sortorder is always different
951                 $sortedids = self::get_tree($this->id);
952                 if ($sortedids && ($invisibleids = $this->get_not_visible_children_ids())) {
953                     $sortedids = array_diff($sortedids, $invisibleids);
954                     if ($sortfields['sortorder'] == -1) {
955                         $sortedids = array_reverse($sortedids, true);
956                     }
957                 }
958             } else {
959                 // we need to retrieve and sort all children. Good thing that it is done only on first request
960                 if ($invisibleids = $this->get_not_visible_children_ids()) {
961                     list($sql, $params) = $DB->get_in_or_equal($invisibleids, SQL_PARAMS_NAMED, 'id', false);
962                     $records = self::get_records('cc.parent = :parent AND cc.id '. $sql,
963                             array('parent' => $this->id) + $params);
964                 } else {
965                     $records = self::get_records('cc.parent = :parent', array('parent' => $this->id));
966                 }
967                 self::sort_records($records, $sortfields);
968                 $sortedids = array_keys($records);
969             }
970             $coursecatcache->set('c'. $this->id. ':'.serialize($sortfields), $sortedids);
971         }
973         if (empty($sortedids)) {
974             return array();
975         }
977         // now retrieive and return categories
978         if ($offset || $limit) {
979             $sortedids = array_slice($sortedids, $offset, $limit);
980         }
981         if (isset($records)) {
982             // easy, we have already retrieved records
983             if ($offset || $limit) {
984                 $records = array_slice($records, $offset, $limit, true);
985             }
986         } else {
987             list($sql, $params) = $DB->get_in_or_equal($sortedids, SQL_PARAMS_NAMED, 'id');
988             $records = self::get_records('cc.id '. $sql,
989                     array('parent' => $this->id) + $params);
990         }
992         $rv = array();
993         foreach ($sortedids as $id) {
994             if (isset($records[$id])) {
995                 $rv[$id] = new coursecat($records[$id]);
996             }
997         }
998         return $rv;
999     }
1001     /**
1002      * Returns number of subcategories visible to the current user
1003      *
1004      * @return int
1005      */
1006     public function get_children_count() {
1007         $sortedids = self::get_tree($this->id);
1008         $invisibleids = $this->get_not_visible_children_ids();
1009         return count($sortedids) - count($invisibleids);
1010     }
1012     /**
1013      * Returns true if the category has ANY children, including those not visible to the user
1014      *
1015      * @return boolean
1016      */
1017     public function has_children() {
1018         $allchildren = self::get_tree($this->id);
1019         return !empty($allchildren);
1020     }
1022     /**
1023      * Returns true if the category has courses in it (count does not include courses
1024      * in child categories)
1025      *
1026      * @return bool
1027      */
1028     public function has_courses() {
1029         global $DB;
1030         return $DB->record_exists_sql("select 1 from {course} where category = ?",
1031                 array($this->id));
1032     }
1034     /**
1035      * Searches courses
1036      *
1037      * List of found course ids is cached for 10 minutes. Cache may be purged prior
1038      * to this when somebody edits courses or categories, however it is very
1039      * difficult to keep track of all possible changes that may affect list of courses.
1040      *
1041      * @param array $search contains search criterias, such as:
1042      *     - search - search string
1043      *     - blocklist - id of block (if we are searching for courses containing specific block0
1044      *     - modulelist - name of module (if we are searching for courses containing specific module
1045      *     - tagid - id of tag
1046      * @param array $options display options, same as in get_courses() except 'recursive' is ignored - search is always category-independent
1047      * @return array
1048      */
1049     public static function search_courses($search, $options = array()) {
1050         global $DB;
1051         $offset = !empty($options['offset']) ? $options['offset'] : 0;
1052         $limit = !empty($options['limit']) ? $options['limit'] : null;
1053         $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
1055         $coursecatcache = cache::make('core', 'coursecat');
1056         $cachekey = 's-'. serialize($search + array('sort' => $sortfields));
1057         $cntcachekey = 'scnt-'. serialize($search);
1059         $ids = $coursecatcache->get($cachekey);
1060         if ($ids !== false) {
1061             // we already cached last search result
1062             $ids = array_slice($ids, $offset, $limit);
1063             $courses = array();
1064             if (!empty($ids)) {
1065                 list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id');
1066                 $records = self::get_course_records("c.id ". $sql, $params, $options);
1067                 foreach ($ids as $id) {
1068                     $courses[$id] = new course_in_list($records[$id]);
1069                 }
1070             }
1071             return $courses;
1072         }
1074         $preloadcoursecontacts = !empty($options['coursecontacts']);
1075         unset($options['coursecontacts']);
1077         if (!empty($search['search'])) {
1078             // search courses that have specified words in their names/summaries
1079             $searchterms = preg_split('|\s+|', trim($search['search']), 0, PREG_SPLIT_NO_EMPTY);
1080             $searchterms = array_filter($searchterms, create_function('$v', 'return strlen($v) > 1;'));
1081             $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount);
1082             self::sort_records($courselist, $sortfields);
1083             $coursecatcache->set($cachekey, array_keys($courselist));
1084             $coursecatcache->set($cntcachekey, $totalcount);
1085             $records = array_slice($courselist, $offset, $limit, true);
1086         } else {
1087             if (!empty($search['blocklist'])) {
1088                 // search courses that have block with specified id
1089                 $blockname = $DB->get_field('block', 'name', array('id' => $search['blocklist']));
1090                 $where = 'ctx.id in (SELECT distinct bi.parentcontextid FROM {block_instances} bi
1091                     WHERE bi.blockname = :blockname)';
1092                 $params = array('blockname' => $blockname);
1093             } else if (!empty($search['modulelist'])) {
1094                 // search courses that have module with specified name
1095                 $where = "c.id IN (SELECT DISTINCT module.course ".
1096                         "FROM {".$search['modulelist']."} module)";
1097                 $params = array();
1098             } else if (!empty($search['tagid'])) {
1099                 // search courses that are tagged with the specified tag
1100                 $where = "c.id IN (SELECT t.itemid ".
1101                         "FROM {tag_instance} t WHERE t.tagid = :tagid AND t.itemtype = :itemtype)";
1102                 $params = array('tagid' => $search['tagid'], 'itemtype' => 'course');
1103             } else {
1104                 debugging('No criteria is specified while searching courses', DEBUG_DEVELOPER);
1105                 return array();
1106             }
1107             $courselist = self::get_course_records($where, $params, $options, true);
1108             self::sort_records($courselist, $sortfields);
1109             $coursecatcache->set($cachekey, array_keys($courselist));
1110             $coursecatcache->set($cntcachekey, count($courselist));
1111             $records = array_slice($courselist, $offset, $limit, true);
1112         }
1114         // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1115         if (!empty($preloadcoursecontacts)) {
1116             self::preload_course_contacts($records);
1117         }
1118         $courses = array();
1119         foreach ($records as $record) {
1120             $courses[$record->id] = new course_in_list($record);
1121         }
1122         return $courses;
1123     }
1125     /**
1126      * Returns number of courses in the search results
1127      *
1128      * It is recommended to call this function after {@link coursecat::search_courses()}
1129      * and not before because only course ids are cached. Otherwise search_courses() may
1130      * perform extra DB queries.
1131      *
1132      * @param array $search search criteria, see method search_courses() for more details
1133      * @param array $options display options. They do not affect the result but
1134      *     the 'sort' property is used in cache key for storing list of course ids
1135      * @return int
1136      */
1137     public static function search_courses_count($search, $options = array()) {
1138         $coursecatcache = cache::make('core', 'coursecat');
1139         $cntcachekey = 'scnt-'. serialize($search);
1140         if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
1141             self::search_courses($search, $options);
1142             $cnt = $coursecatcache->get($cntcachekey);
1143         }
1144         return $cnt;
1145     }
1147     /**
1148      * Retrieves the list of courses accessible by user
1149      *
1150      * Not all information is cached, try to avoid calling this method
1151      * twice in the same request.
1152      *
1153      * The following fields are always retrieved:
1154      * - id, visible, fullname, shortname, idnumber, category, sortorder
1155      *
1156      * If you plan to use properties/methods course_in_list::$summary and/or
1157      * course_in_list::get_course_contacts()
1158      * you can preload this information using appropriate 'options'. Otherwise
1159      * they will be retrieved from DB on demand and it may end with bigger DB load.
1160      *
1161      * Note that method course_in_list::has_summary() will not perform additional
1162      * DB queries even if $options['summary'] is not specified
1163      *
1164      * List of found course ids is cached for 10 minutes. Cache may be purged prior
1165      * to this when somebody edits courses or categories, however it is very
1166      * difficult to keep track of all possible changes that may affect list of courses.
1167      *
1168      * @param array $options options for retrieving children
1169      *    - recursive - return courses from subcategories as well. Use with care,
1170      *      this may be a huge list!
1171      *    - summary - preloads fields 'summary' and 'summaryformat'
1172      *    - coursecontacts - preloads course contacts
1173      *    - sort - list of fields to sort. Example
1174      *             array('idnumber' => 1, 'shortname' => 1, 'id' => -1)
1175      *             will sort by idnumber asc, shortname asc and id desc.
1176      *             Default: array('sortorder' => 1)
1177      *             Only cached fields may be used for sorting!
1178      *    - offset
1179      *    - limit - maximum number of children to return, 0 or null for no limit
1180      * @return array array of instances of course_in_list
1181      */
1182     public function get_courses($options = array()) {
1183         global $DB;
1184         $recursive = !empty($options['recursive']);
1185         $offset = !empty($options['offset']) ? $options['offset'] : 0;
1186         $limit = !empty($options['limit']) ? $options['limit'] : null;
1187         $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
1189         // Check if this category is hidden.
1190         // Also 0-category never has courses unless this is recursive call.
1191         if (!$this->is_uservisible() || (!$this->id && !$recursive)) {
1192             return array();
1193         }
1195         $coursecatcache = cache::make('core', 'coursecat');
1196         $cachekey = 'l-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '').
1197                  '-'. serialize($sortfields);
1198         $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '');
1200         // check if we have already cached results
1201         $ids = $coursecatcache->get($cachekey);
1202         if ($ids !== false) {
1203             // we already cached last search result and it did not expire yet
1204             $ids = array_slice($ids, $offset, $limit);
1205             $courses = array();
1206             if (!empty($ids)) {
1207                 list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id');
1208                 $records = self::get_course_records("c.id ". $sql, $params, $options);
1209                 foreach ($ids as $id) {
1210                     $courses[$id] = new course_in_list($records[$id]);
1211                 }
1212             }
1213             return $courses;
1214         }
1216         // retrieve list of courses in category
1217         $where = 'c.id <> :siteid';
1218         $params = array('siteid' => SITEID);
1219         if ($recursive) {
1220             if ($this->id) {
1221                 $context = context_coursecat::instance($this->id);
1222                 $where .= ' AND ctx.path like :path';
1223                 $params['path'] = $context->path. '/%';
1224             }
1225         } else {
1226             $where .= ' AND c.category = :categoryid';
1227             $params['categoryid'] = $this->id;
1228         }
1229         // get list of courses without preloaded coursecontacts because we don't need them for every course
1230         $list = $this->get_course_records($where, $params, array_diff_key($options, array('coursecontacts' => 1)), true);
1232         // sort and cache list
1233         self::sort_records($list, $sortfields);
1234         $coursecatcache->set($cachekey, array_keys($list));
1235         $coursecatcache->set($cntcachekey, count($list));
1237         // Apply offset/limit, convert to course_in_list and return.
1238         $courses = array();
1239         if (isset($list)) {
1240             if ($offset || $limit) {
1241                 $list = array_slice($list, $offset, $limit, true);
1242             }
1243             // Preload course contacts if necessary - saves DB queries later to do it for each course separately.
1244             if (!empty($options['coursecontacts'])) {
1245                 self::preload_course_contacts($list);
1246             }
1247             foreach ($list as $record) {
1248                 $courses[$record->id] = new course_in_list($record);
1249             }
1250         }
1251         return $courses;
1252     }
1254     /**
1255      * Returns number of courses visible to the user
1256      *
1257      * @param array $options similar to get_courses() except some options do not affect
1258      *     number of courses (i.e. sort, summary, offset, limit etc.)
1259      * @return int
1260      */
1261     public function get_courses_count($options = array()) {
1262         $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : '');
1263         $coursecatcache = cache::make('core', 'coursecat');
1264         if (($cnt = $coursecatcache->get($cntcachekey)) === false) {
1265             $this->get_courses($options);
1266             $cnt = $coursecatcache->get($cntcachekey);
1267         }
1268         return $cnt;
1269     }
1271     /**
1272      * Returns true if user can delete current category and all its contents
1273      *
1274      * To be able to delete course category the user must have permission
1275      * 'moodle/category:manage' in ALL child course categories AND
1276      * be able to delete all courses
1277      *
1278      * @return bool
1279      */
1280     public function can_delete_full() {
1281         global $DB;
1282         if (!$this->id) {
1283             // fool-proof
1284             return false;
1285         }
1287         $context = context_coursecat::instance($this->id);
1288         if (!$this->is_uservisible() ||
1289                 !has_capability('moodle/category:manage', $context)) {
1290             return false;
1291         }
1293         // Check all child categories (not only direct children)
1294         $sql = context_helper::get_preload_record_columns_sql('ctx');
1295         $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
1296             ' FROM {context} ctx '.
1297             ' JOIN {course_categories} c ON c.id = ctx.instanceid'.
1298             ' WHERE ctx.path like ? AND ctx.contextlevel = ?',
1299                 array($context->path. '/%', CONTEXT_COURSECAT));
1300         foreach ($childcategories as $childcat) {
1301             context_helper::preload_from_record($childcat);
1302             $childcontext = context_coursecat::instance($childcat->id);
1303             if ((!$childcat->visible && !has_capability('moodle/category:viewhiddencategories', $childcontext)) ||
1304                     !has_capability('moodle/category:manage', $childcontext)) {
1305                 return false;
1306             }
1307         }
1309         // Check courses
1310         $sql = context_helper::get_preload_record_columns_sql('ctx');
1311         $coursescontexts = $DB->get_records_sql('SELECT ctx.instanceid AS courseid, '.
1312                     $sql. ' FROM {context} ctx '.
1313                     'WHERE ctx.path like :pathmask and ctx.contextlevel = :courselevel',
1314                 array('pathmask' => $context->path. '/%',
1315                     'courselevel' => CONTEXT_COURSE));
1316         foreach ($coursescontexts as $ctxrecord) {
1317             context_helper::preload_from_record($ctxrecord);
1318             if (!can_delete_course($ctxrecord->courseid)) {
1319                 return false;
1320             }
1321         }
1323         return true;
1324     }
1326     /**
1327      * Recursively delete category including all subcategories and courses
1328      *
1329      * Function {@link coursecat::can_delete_full()} MUST be called prior
1330      * to calling this function because there is no capability check
1331      * inside this function
1332      *
1333      * @param boolean $showfeedback display some notices
1334      * @return array return deleted courses
1335      */
1336     public function delete_full($showfeedback = true) {
1337         global $CFG, $DB;
1338         require_once($CFG->libdir.'/gradelib.php');
1339         require_once($CFG->libdir.'/questionlib.php');
1340         require_once($CFG->dirroot.'/cohort/lib.php');
1342         $deletedcourses = array();
1344         // Get children. Note, we don't want to use cache here because
1345         // it would be rebuilt too often
1346         $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC');
1347         foreach ($children as $record) {
1348             $coursecat = new coursecat($record);
1349             $deletedcourses += $coursecat->delete_full($showfeedback);
1350         }
1352         if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) {
1353             foreach ($courses as $course) {
1354                 if (!delete_course($course, false)) {
1355                     throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname);
1356                 }
1357                 $deletedcourses[] = $course;
1358             }
1359         }
1361         // move or delete cohorts in this context
1362         cohort_delete_category($this);
1364         // now delete anything that may depend on course category context
1365         grade_course_category_delete($this->id, 0, $showfeedback);
1366         if (!question_delete_course_category($this, 0, $showfeedback)) {
1367             throw new moodle_exception('cannotdeletecategoryquestions', '', '', $this->get_formatted_name());
1368         }
1370         // finally delete the category and it's context
1371         $DB->delete_records('course_categories', array('id' => $this->id));
1372         delete_context(CONTEXT_COURSECAT, $this->id);
1373         add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");
1375         cache_helper::purge_by_event('changesincoursecat');
1377         events_trigger('course_category_deleted', $this);
1379         // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
1380         if ($this->id == $CFG->defaultrequestcategory) {
1381             set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
1382         }
1383         return $deletedcourses;
1384     }
1386     /**
1387      * Checks if user can delete this category and move content (courses, subcategories and questions)
1388      * to another category. If yes returns the array of possible target categories names
1389      *
1390      * If user can not manage this category or it is completely empty - empty array will be returned
1391      *
1392      * @return array
1393      */
1394     public function move_content_targets_list() {
1395         global $CFG;
1396         require_once($CFG->libdir . '/questionlib.php');
1397         $context = context_coursecat::instance($this->id);
1398         if (!$this->is_uservisible() ||
1399                 !has_capability('moodle/category:manage', $context)) {
1400             // User is not able to manage current category, he is not able to delete it.
1401             // No possible target categories.
1402             return array();
1403         }
1405         $testcaps = array();
1406         // If this category has courses in it, user must have 'course:create' capability in target category.
1407         if ($this->has_courses()) {
1408             $testcaps[] = 'moodle/course:create';
1409         }
1410         // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
1411         if ($this->has_children() || question_context_has_any_questions($context)) {
1412             $testcaps[] = 'moodle/category:manage';
1413         }
1414         if (!empty($testcaps)) {
1415             // return list of categories excluding this one and it's children
1416             return self::make_categories_list($testcaps, $this->id);
1417         }
1419         // Category is completely empty, no need in target for contents.
1420         return array();
1421     }
1423     /**
1424      * Checks if user has capability to move all category content to the new parent before
1425      * removing this category
1426      *
1427      * @param int $newcatid
1428      * @return bool
1429      */
1430     public function can_move_content_to($newcatid) {
1431         global $CFG;
1432         require_once($CFG->libdir . '/questionlib.php');
1433         $context = context_coursecat::instance($this->id);
1434         if (!$this->is_uservisible() ||
1435                 !has_capability('moodle/category:manage', $context)) {
1436             return false;
1437         }
1438         $testcaps = array();
1439         // If this category has courses in it, user must have 'course:create' capability in target category.
1440         if ($this->has_courses()) {
1441             $testcaps[] = 'moodle/course:create';
1442         }
1443         // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
1444         if ($this->has_children() || question_context_has_any_questions($context)) {
1445             $testcaps[] = 'moodle/category:manage';
1446         }
1447         if (!empty($testcaps)) {
1448             return has_all_capabilities($testcaps, context_coursecat::instance($newcatid));
1449         }
1451         // there is no content but still return true
1452         return true;
1453     }
1455     /**
1456      * Deletes a category and moves all content (children, courses and questions) to the new parent
1457      *
1458      * Note that this function does not check capabilities, {@link coursecat::can_move_content_to()}
1459      * must be called prior
1460      *
1461      * @param int $newparentid
1462      * @param bool $showfeedback
1463      * @return bool
1464      */
1465     public function delete_move($newparentid, $showfeedback = false) {
1466         global $CFG, $DB, $OUTPUT;
1467         require_once($CFG->libdir.'/gradelib.php');
1468         require_once($CFG->libdir.'/questionlib.php');
1469         require_once($CFG->dirroot.'/cohort/lib.php');
1471         // get all objects and lists because later the caches will be reset so
1472         // we don't need to make extra queries
1473         $newparentcat = self::get($newparentid, MUST_EXIST, true);
1474         $catname = $this->get_formatted_name();
1475         $children = $this->get_children();
1476         $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', array('category' => $this->id));
1477         $context = context_coursecat::instance($this->id);
1479         if ($children) {
1480             foreach ($children as $childcat) {
1481                 $childcat->change_parent_raw($newparentcat);
1482                 // Log action.
1483                 add_to_log(SITEID, "category", "move", "editcategory.php?id=$childcat->id", $childcat->id);
1484             }
1485             fix_course_sortorder();
1486         }
1488         if ($coursesids) {
1489             if (!move_courses($coursesids, $newparentid)) {
1490                 if ($showfeedback) {
1491                     echo $OUTPUT->notification("Error moving courses");
1492                 }
1493                 return false;
1494             }
1495             if ($showfeedback) {
1496                 echo $OUTPUT->notification(get_string('coursesmovedout', '', $catname), 'notifysuccess');
1497             }
1498         }
1500         // move or delete cohorts in this context
1501         cohort_delete_category($this);
1503         // now delete anything that may depend on course category context
1504         grade_course_category_delete($this->id, $newparentid, $showfeedback);
1505         if (!question_delete_course_category($this, $newparentcat, $showfeedback)) {
1506             if ($showfeedback) {
1507                 echo $OUTPUT->notification(get_string('errordeletingquestionsfromcategory', 'question', $catname), 'notifysuccess');
1508             }
1509             return false;
1510         }
1512         // finally delete the category and it's context
1513         $DB->delete_records('course_categories', array('id' => $this->id));
1514         $context->delete();
1515         add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)");
1517         events_trigger('course_category_deleted', $this);
1519         cache_helper::purge_by_event('changesincoursecat');
1521         if ($showfeedback) {
1522             echo $OUTPUT->notification(get_string('coursecategorydeleted', '', $catname), 'notifysuccess');
1523         }
1525         // If we deleted $CFG->defaultrequestcategory, make it point somewhere else.
1526         if ($this->id == $CFG->defaultrequestcategory) {
1527             set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0)));
1528         }
1529         return true;
1530     }
1532     /**
1533      * Checks if user can move current category to the new parent
1534      *
1535      * This checks if new parent category exists, user has manage cap there
1536      * and new parent is not a child of this category
1537      *
1538      * @param int|stdClass|coursecat $newparentcat
1539      * @return bool
1540      */
1541     public function can_change_parent($newparentcat) {
1542         if (!has_capability('moodle/category:manage', context_coursecat::instance($this->id))) {
1543             return false;
1544         }
1545         if (is_object($newparentcat)) {
1546             $newparentcat = self::get($newparentcat->id, IGNORE_MISSING);
1547         } else {
1548             $newparentcat = self::get((int)$newparentcat, IGNORE_MISSING);
1549         }
1550         if (!$newparentcat) {
1551             return false;
1552         }
1553         if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
1554             // can not move to itself or it's own child
1555             return false;
1556         }
1557         if ($newparentcat->id) {
1558             return has_capability('moodle/category:manage', context_coursecat::instance($newparentcat->id));
1559         } else {
1560             return has_capability('moodle/category:manage', context_system::instance());
1561         }
1562     }
1564     /**
1565      * Moves the category under another parent category. All associated contexts are moved as well
1566      *
1567      * This is protected function, use change_parent() or update() from outside of this class
1568      *
1569      * @see coursecat::change_parent()
1570      * @see coursecat::update()
1571      *
1572      * @param coursecat $newparentcat
1573      */
1574      protected function change_parent_raw(coursecat $newparentcat) {
1575         global $DB;
1577         $context = context_coursecat::instance($this->id);
1579         $hidecat = false;
1580         if (empty($newparentcat->id)) {
1581             $DB->set_field('course_categories', 'parent', 0, array('id' => $this->id));
1582             $newparent = context_system::instance();
1583         } else {
1584             if ($newparentcat->id == $this->id || in_array($this->id, $newparentcat->get_parents())) {
1585                 // can not move to itself or it's own child
1586                 throw new moodle_exception('cannotmovecategory');
1587             }
1588             $DB->set_field('course_categories', 'parent', $newparentcat->id, array('id' => $this->id));
1589             $newparent = context_coursecat::instance($newparentcat->id);
1591             if (!$newparentcat->visible and $this->visible) {
1592                 // better hide category when moving into hidden category, teachers may unhide afterwards and the hidden children will be restored properly
1593                 $hidecat = true;
1594             }
1595         }
1596         $this->parent = $newparentcat->id;
1598         $context->update_moved($newparent);
1600         // now make it last in new category
1601         $DB->set_field('course_categories', 'sortorder', MAX_COURSES_IN_CATEGORY*MAX_COURSE_CATEGORIES, array('id' => $this->id));
1603         if ($hidecat) {
1604             fix_course_sortorder();
1605             $this->restore();
1606             // Hide object but store 1 in visibleold, because when parent category visibility changes this category must become visible again.
1607             $this->hide_raw(1);
1608         }
1609     }
1611     /**
1612      * Efficiently moves a category - NOTE that this can have
1613      * a huge impact access-control-wise...
1614      *
1615      * Note that this function does not check capabilities.
1616      *
1617      * Example of usage:
1618      * $coursecat = coursecat::get($categoryid);
1619      * if ($coursecat->can_change_parent($newparentcatid)) {
1620      *     $coursecat->change_parent($newparentcatid);
1621      * }
1622      *
1623      * This function does not update field course_categories.timemodified
1624      * If you want to update timemodified, use
1625      * $coursecat->update(array('parent' => $newparentcat));
1626      *
1627      * @param int|stdClass|coursecat $newparentcat
1628      */
1629     public function change_parent($newparentcat) {
1630         // Make sure parent category exists but do not check capabilities here that it is visible to current user.
1631         if (is_object($newparentcat)) {
1632             $newparentcat = self::get($newparentcat->id, MUST_EXIST, true);
1633         } else {
1634             $newparentcat = self::get((int)$newparentcat, MUST_EXIST, true);
1635         }
1636         if ($newparentcat->id != $this->parent) {
1637             $this->change_parent_raw($newparentcat);
1638             fix_course_sortorder();
1639             cache_helper::purge_by_event('changesincoursecat');
1640             $this->restore();
1641             add_to_log(SITEID, "category", "move", "editcategory.php?id=$this->id", $this->id);
1642         }
1643     }
1645     /**
1646      * Hide course category and child course and subcategories
1647      *
1648      * If this category has changed the parent and is moved under hidden
1649      * category we will want to store it's current visibility state in
1650      * the field 'visibleold'. If admin clicked 'hide' for this particular
1651      * category, the field 'visibleold' should become 0.
1652      *
1653      * All subcategories and courses will have their current visibility in the field visibleold
1654      *
1655      * This is protected function, use hide() or update() from outside of this class
1656      *
1657      * @see coursecat::hide()
1658      * @see coursecat::update()
1659      *
1660      * @param int $visibleold value to set in field $visibleold for this category
1661      * @return bool whether changes have been made and caches need to be purged afterwards
1662      */
1663     protected function hide_raw($visibleold = 0) {
1664         global $DB;
1665         $changes = false;
1667         // Note that field 'visibleold' is not cached so we must retrieve it from DB if it is missing
1668         if ($this->id && $this->__get('visibleold') != $visibleold) {
1669             $this->visibleold = $visibleold;
1670             $DB->set_field('course_categories', 'visibleold', $visibleold, array('id' => $this->id));
1671             $changes = true;
1672         }
1673         if (!$this->visible || !$this->id) {
1674             // already hidden or can not be hidden
1675             return $changes;
1676         }
1678         $this->visible = 0;
1679         $DB->set_field('course_categories', 'visible', 0, array('id'=>$this->id));
1680         $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($this->id)); // store visible flag so that we can return to it if we immediately unhide
1681         $DB->set_field('course', 'visible', 0, array('category' => $this->id));
1682         // get all child categories and hide too
1683         if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visible')) {
1684             foreach ($subcats as $cat) {
1685                 $DB->set_field('course_categories', 'visibleold', $cat->visible, array('id' => $cat->id));
1686                 $DB->set_field('course_categories', 'visible', 0, array('id' => $cat->id));
1687                 $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($cat->id));
1688                 $DB->set_field('course', 'visible', 0, array('category' => $cat->id));
1689             }
1690         }
1691         return true;
1692     }
1694     /**
1695      * Hide course category and child course and subcategories
1696      *
1697      * Note that there is no capability check inside this function
1698      *
1699      * This function does not update field course_categories.timemodified
1700      * If you want to update timemodified, use
1701      * $coursecat->update(array('visible' => 0));
1702      */
1703     public function hide() {
1704         if ($this->hide_raw(0)) {
1705             cache_helper::purge_by_event('changesincoursecat');
1706             add_to_log(SITEID, "category", "hide", "editcategory.php?id=$this->id", $this->id);
1707         }
1708     }
1710     /**
1711      * Show course category and restores visibility for child course and subcategories
1712      *
1713      * Note that there is no capability check inside this function
1714      *
1715      * This is protected function, use show() or update() from outside of this class
1716      *
1717      * @see coursecat::show()
1718      * @see coursecat::update()
1719      *
1720      * @return bool whether changes have been made and caches need to be purged afterwards
1721      */
1722     protected function show_raw() {
1723         global $DB;
1725         if ($this->visible) {
1726             // already visible
1727             return false;
1728         }
1730         $this->visible = 1;
1731         $this->visibleold = 1;
1732         $DB->set_field('course_categories', 'visible', 1, array('id' => $this->id));
1733         $DB->set_field('course_categories', 'visibleold', 1, array('id' => $this->id));
1734         $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($this->id));
1735         // get all child categories and unhide too
1736         if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visibleold')) {
1737             foreach ($subcats as $cat) {
1738                 if ($cat->visibleold) {
1739                     $DB->set_field('course_categories', 'visible', 1, array('id' => $cat->id));
1740                 }
1741                 $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($cat->id));
1742             }
1743         }
1744         return true;
1745     }
1747     /**
1748      * Show course category and restores visibility for child course and subcategories
1749      *
1750      * Note that there is no capability check inside this function
1751      *
1752      * This function does not update field course_categories.timemodified
1753      * If you want to update timemodified, use
1754      * $coursecat->update(array('visible' => 1));
1755      */
1756     public function show() {
1757         if ($this->show_raw()) {
1758             cache_helper::purge_by_event('changesincoursecat');
1759             add_to_log(SITEID, "category", "show", "editcategory.php?id=$this->id", $this->id);
1760         }
1761     }
1763     /**
1764      * Returns name of the category formatted as a string
1765      *
1766      * @param array $options formatting options other than context
1767      * @return string
1768      */
1769     public function get_formatted_name($options = array()) {
1770         if ($this->id) {
1771             $context = context_coursecat::instance($this->id);
1772             return format_string($this->name, true, array('context' => $context) + $options);
1773         } else {
1774             return ''; // TODO 'Top'?
1775         }
1776     }
1778     /**
1779      * Returns ids of all parents of the category. Last element in the return array is the direct parent
1780      *
1781      * For example, if you have a tree of categories like:
1782      *   Miscellaneous (id = 1)
1783      *      Subcategory (id = 2)
1784      *         Sub-subcategory (id = 4)
1785      *   Other category (id = 3)
1786      *
1787      * coursecat::get(1)->get_parents() == array()
1788      * coursecat::get(2)->get_parents() == array(1)
1789      * coursecat::get(4)->get_parents() == array(1, 2);
1790      *
1791      * Note that this method does not check if all parents are accessible by current user
1792      *
1793      * @return array of category ids
1794      */
1795     public function get_parents() {
1796         $parents = preg_split('|/|', $this->path, 0, PREG_SPLIT_NO_EMPTY);
1797         array_pop($parents);
1798         return $parents;
1799     }
1801     /**
1802      * This function returns a nice list representing category tree
1803      * for display or to use in a form <select> element
1804      *
1805      * List is cached for 10 minutes
1806      *
1807      * For example, if you have a tree of categories like:
1808      *   Miscellaneous (id = 1)
1809      *      Subcategory (id = 2)
1810      *         Sub-subcategory (id = 4)
1811      *   Other category (id = 3)
1812      * Then after calling this function you will have
1813      * array(1 => 'Miscellaneous',
1814      *       2 => 'Miscellaneous / Subcategory',
1815      *       4 => 'Miscellaneous / Subcategory / Sub-subcategory',
1816      *       3 => 'Other category');
1817      *
1818      * If you specify $requiredcapability, then only categories where the current
1819      * user has that capability will be added to $list.
1820      * If you only have $requiredcapability in a child category, not the parent,
1821      * then the child catgegory will still be included.
1822      *
1823      * If you specify the option $excludeid, then that category, and all its children,
1824      * are omitted from the tree. This is useful when you are doing something like
1825      * moving categories, where you do not want to allow people to move a category
1826      * to be the child of itself.
1827      *
1828      * See also {@link make_categories_options()}
1829      *
1830      * @param string/array $requiredcapability if given, only categories where the current
1831      *      user has this capability will be returned. Can also be an array of capabilities,
1832      *      in which case they are all required.
1833      * @param integer $excludeid Exclude this category and its children from the lists built.
1834      * @param string $separator string to use as a separator between parent and child category. Default ' / '
1835      * @return array of strings
1836      */
1837     public static function make_categories_list($requiredcapability = '', $excludeid = 0, $separator = ' / ') {
1838         global $DB;
1839         $coursecatcache = cache::make('core', 'coursecat');
1841         // Check if we cached the complete list of user-accessible category names ($baselist) or list of ids with requried cap ($thislist).
1842         $basecachekey = 'catlist';
1843         $baselist = $coursecatcache->get($basecachekey);
1844         if ($baselist !== false) {
1845             $baselist = false;
1846         }
1847         $thislist = false;
1848         if (!empty($requiredcapability)) {
1849             $requiredcapability = (array)$requiredcapability;
1850             $thiscachekey = 'catlist:'. serialize($requiredcapability);
1851             if ($baselist !== false && ($thislist = $coursecatcache->get($thiscachekey)) !== false) {
1852                 $thislist = preg_split('|,|', $thislist, -1, PREG_SPLIT_NO_EMPTY);
1853             }
1854         } else if ($baselist !== false) {
1855             $thislist = array_keys($baselist);
1856         }
1858         if ($baselist === false) {
1859             // We don't have $baselist cached, retrieve it. Retrieve $thislist again in any case.
1860             $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
1861             $sql = "SELECT cc.id, cc.sortorder, cc.name, cc.visible, cc.parent, cc.path, $ctxselect
1862                     FROM {course_categories} cc
1863                     JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat
1864                     ORDER BY cc.sortorder";
1865             $rs = $DB->get_recordset_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
1866             $baselist = array();
1867             $thislist = array();
1868             foreach ($rs as $record) {
1869                 // If the category's parent is not visible to the user, it is not visible as well.
1870                 if (!$record->parent || isset($baselist[$record->parent])) {
1871                     $context = context_coursecat::instance($record->id);
1872                     if (!$record->visible && !has_capability('moodle/category:viewhiddencategories', $context)) {
1873                         // No cap to view category, added to neither $baselist nor $thislist
1874                         continue;
1875                     }
1876                     $baselist[$record->id] = array(
1877                         'name' => format_string($record->name, true, array('context' => $context)),
1878                         'path' => $record->path
1879                     );
1880                     if (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context)) {
1881                         // No required capability, added to $baselist but not to $thislist.
1882                         continue;
1883                     }
1884                     $thislist[] = $record->id;
1885                 }
1886             }
1887             $rs->close();
1888             $coursecatcache->set($basecachekey, $baselist);
1889             if (!empty($requiredcapability)) {
1890                 $coursecatcache->set($thiscachekey, join(',', $thislist));
1891             }
1892         } else if ($thislist === false) {
1893             // We have $baselist cached but not $thislist. Simplier query is used to retrieve.
1894             $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
1895             $sql = "SELECT ctx.instanceid id, $ctxselect
1896                     FROM {context} ctx WHERE ctx.contextlevel = :contextcoursecat";
1897             $contexts = $DB->get_records_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
1898             $thislist = array();
1899             foreach (array_keys($baselist) as $id) {
1900                 context_helper::preload_from_record($contexts[$id]);
1901                 if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
1902                     $thislist[] = $id;
1903                 }
1904             }
1905             $coursecatcache->set($thiscachekey, join(',', $thislist));
1906         }
1908         // Now build the array of strings to return, mind $separator and $excludeid.
1909         $names = array();
1910         foreach ($thislist as $id) {
1911             $path = preg_split('|/|', $baselist[$id]['path'], -1, PREG_SPLIT_NO_EMPTY);
1912             if (!$excludeid || !in_array($excludeid, $path)) {
1913                 $namechunks = array();
1914                 foreach ($path as $parentid) {
1915                     $namechunks[] = $baselist[$parentid]['name'];
1916                 }
1917                 $names[$id] = join($separator, $namechunks);
1918             }
1919         }
1920         return $names;
1921     }
1923     /**
1924      * Prepares the object for caching. Works like the __sleep method.
1925      *
1926      * implementing method from interface cacheable_object
1927      *
1928      * @return array ready to be cached
1929      */
1930     public function prepare_to_cache() {
1931         $a = array();
1932         foreach (self::$coursecatfields as $property => $cachedirectives) {
1933             if ($cachedirectives !== null) {
1934                 list($shortname, $defaultvalue) = $cachedirectives;
1935                 if ($this->$property !== $defaultvalue) {
1936                     $a[$shortname] = $this->$property;
1937                 }
1938             }
1939         }
1940         $context = context_coursecat::instance($this->id);
1941         $a['xi'] = $context->id;
1942         $a['xp'] = $context->path;
1943         return $a;
1944     }
1946     /**
1947      * Takes the data provided by prepare_to_cache and reinitialises an instance of the associated from it.
1948      *
1949      * implementing method from interface cacheable_object
1950      *
1951      * @param array $a
1952      * @return coursecat
1953      */
1954     public static function wake_from_cache($a) {
1955         $record = new stdClass;
1956         foreach (self::$coursecatfields as $property => $cachedirectives) {
1957             if ($cachedirectives !== null) {
1958                 list($shortname, $defaultvalue) = $cachedirectives;
1959                 if (array_key_exists($shortname, $a)) {
1960                     $record->$property = $a[$shortname];
1961                 } else {
1962                     $record->$property = $defaultvalue;
1963                 }
1964             }
1965         }
1966         $record->ctxid = $a['xi'];
1967         $record->ctxpath = $a['xp'];
1968         $record->ctxdepth = $record->depth + 1;
1969         $record->ctxlevel = CONTEXT_COURSECAT;
1970         $record->ctxinstance = $record->id;
1971         return new coursecat($record, true);
1972     }
1975 /**
1976  * Class to store information about one course in a list of courses
1977  *
1978  * Not all information may be retrieved when object is created but
1979  * it will be retrieved on demand when appropriate property or method is
1980  * called.
1981  *
1982  * Instances of this class are usually returned by functions
1983  * {@link coursecat::search_courses()}
1984  * and
1985  * {@link coursecat::get_courses()}
1986  *
1987  * @package    core
1988  * @subpackage course
1989  * @copyright  2013 Marina Glancy
1990  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1991  */
1992 class course_in_list implements IteratorAggregate {
1994     /** @var stdClass record retrieved from DB, may have additional calculated property such as managers and hassummary */
1995     protected $record;
1997     /** @var array array of course contacts - stores result of call to get_course_contacts() */
1998     protected $coursecontacts;
2000     /**
2001      * Creates an instance of the class from record
2002      *
2003      * @param stdClass $record except fields from course table it may contain
2004      *     field hassummary indicating that summary field is not empty.
2005      *     Also it is recommended to have context fields here ready for
2006      *     context preloading
2007      */
2008     public function __construct(stdClass $record) {
2009         context_instance_preload($record);
2010         $this->record = new stdClass();
2011         foreach ($record as $key => $value) {
2012             $this->record->$key = $value;
2013         }
2014     }
2016     /**
2017      * Indicates if the course has non-empty summary field
2018      *
2019      * @return bool
2020      */
2021     public function has_summary() {
2022         if (isset($this->record->hassummary)) {
2023             return !empty($this->record->hassummary);
2024         }
2025         if (!isset($this->record->summary)) {
2026             // we need to retrieve summary
2027             $this->__get('summary');
2028         }
2029         return !empty($this->record->summary);
2030     }
2032     /**
2033      * Indicates if the course have course contacts to display
2034      *
2035      * @return bool
2036      */
2037     public function has_course_contacts() {
2038         if (!isset($this->record->managers)) {
2039             $courses = array($this->id => &$this->record);
2040             coursecat::preload_course_contacts($courses);
2041         }
2042         return !empty($this->record->managers);
2043     }
2045     /**
2046      * Returns list of course contacts (usually teachers) to display in course link
2047      *
2048      * Roles to display are set up in $CFG->coursecontact
2049      *
2050      * The result is the list of users where user id is the key and the value
2051      * is an array with elements:
2052      *  - 'user' - object containing basic user information
2053      *  - 'role' - object containing basic role information (id, name, shortname, coursealias)
2054      *  - 'rolename' => role_get_name($role, $context, ROLENAME_ALIAS)
2055      *  - 'username' => fullname($user, $canviewfullnames)
2056      *
2057      * @return array
2058      */
2059     public function get_course_contacts() {
2060         global $CFG;
2061         if (empty($CFG->coursecontact)) {
2062             // no roles are configured to be displayed as course contacts
2063             return array();
2064         }
2065         if ($this->coursecontacts === null) {
2066             $this->coursecontacts = array();
2067             $context = context_course::instance($this->id);
2069             if (!isset($this->record->managers)) {
2070                 // preload course contacts from DB
2071                 $courses = array($this->id => &$this->record);
2072                 coursecat::preload_course_contacts($courses);
2073             }
2075             // build return array with full roles names (for this course context) and users names
2076             $canviewfullnames = has_capability('moodle/site:viewfullnames', $context);
2077             foreach ($this->record->managers as $ruser) {
2078                 if (isset($this->coursecontacts[$ruser->id])) {
2079                     //  only display a user once with the highest sortorder role
2080                     continue;
2081                 }
2082                 $user = new stdClass();
2083                 $user->id = $ruser->id;
2084                 $user->username = $ruser->username;
2085                 $user->firstname = $ruser->firstname;
2086                 $user->lastname = $ruser->lastname;
2087                 $role = new stdClass();
2088                 $role->id = $ruser->roleid;
2089                 $role->name = $ruser->rolename;
2090                 $role->shortname = $ruser->roleshortname;
2091                 $role->coursealias = $ruser->rolecoursealias;
2093                 $this->coursecontacts[$user->id] = array(
2094                     'user' => $user,
2095                     'role' => $role,
2096                     'rolename' => role_get_name($role, $context, ROLENAME_ALIAS),
2097                     'username' => fullname($user, $canviewfullnames)
2098                 );
2099             }
2100         }
2101         return $this->coursecontacts;
2102     }
2104     /**
2105      * Checks if course has any associated overview files
2106      *
2107      * @return bool
2108      */
2109     public function has_course_overviewfiles() {
2110         global $CFG;
2111         if (empty($CFG->courseoverviewfileslimit)) {
2112             return 0;
2113         }
2114         require_once($CFG->libdir. '/filestorage/file_storage.php');
2115         $fs = get_file_storage();
2116         $context = context_course::instance($this->id);
2117         return $fs->is_area_empty($context->id, 'course', 'overviewfiles');
2118     }
2120     /**
2121      * Returns all course overview files
2122      *
2123      * @return array array of stored_file objects
2124      */
2125     public function get_course_overviewfiles() {
2126         global $CFG;
2127         if (empty($CFG->courseoverviewfileslimit)) {
2128             return array();
2129         }
2130         require_once($CFG->libdir. '/filestorage/file_storage.php');
2131         require_once($CFG->dirroot. '/course/lib.php');
2132         $fs = get_file_storage();
2133         $context = context_course::instance($this->id);
2134         $files = $fs->get_area_files($context->id, 'course', 'overviewfiles', false, 'filename', false);
2135         if (count($files)) {
2136             $overviewfilesoptions = course_overviewfiles_options($this->id);
2137             $acceptedtypes = $overviewfilesoptions['accepted_types'];
2138             if ($acceptedtypes !== '*') {
2139                 // filter only files with allowed extensions
2140                 require_once($CFG->libdir. '/filelib.php');
2141                 foreach ($files as $key => $file) {
2142                     if (!file_extension_in_typegroup($file->get_filename(), $acceptedtypes)) {
2143                         unset($files[$key]);
2144                     }
2145                 }
2146             }
2147             if (count($files) > $CFG->courseoverviewfileslimit) {
2148                 // return no more than $CFG->courseoverviewfileslimit files
2149                 $files = array_slice($files, 0, $CFG->courseoverviewfileslimit, true);
2150             }
2151         }
2152         return $files;
2153     }
2155     // ====== magic methods =======
2157     public function __isset($name) {
2158         return isset($this->record->$name);
2159     }
2161     /**
2162      * Magic method to get a course property
2163      *
2164      * Returns any field from table course (from cache or from DB) and/or special field 'hassummary'
2165      *
2166      * @param string $name
2167      * @return mixed
2168      */
2169     public function __get($name) {
2170         global $DB;
2171         if (property_exists($this->record, $name)) {
2172             return $this->record->$name;
2173         } else if ($name === 'summary' || $name === 'summaryformat') {
2174             // retrieve fields summary and summaryformat together because they are most likely to be used together
2175             $record = $DB->get_record('course', array('id' => $this->record->id), 'summary, summaryformat', MUST_EXIST);
2176             $this->record->summary = $record->summary;
2177             $this->record->summaryformat = $record->summaryformat;
2178             return $this->record->$name;
2179         } else if (array_key_exists($name, $DB->get_columns('course'))) {
2180             // another field from table 'course' that was not retrieved
2181             $this->record->$name = $DB->get_field('course', $name, array('id' => $this->record->id), MUST_EXIST);
2182             return $this->record->$name;
2183         }
2184         debugging('Invalid course property accessed! '.$name);
2185         return null;
2186     }
2188     /**
2189      * ALl properties are read only, sorry.
2190      * @param string $name
2191      */
2192     public function __unset($name) {
2193         debugging('Can not unset '.get_class($this).' instance properties!');
2194     }
2196     /**
2197      * Magic setter method, we do not want anybody to modify properties from the outside
2198      * @param string $name
2199      * @param mixed $value
2200      */
2201     public function __set($name, $value) {
2202         debugging('Can not change '.get_class($this).' instance properties!');
2203     }
2205     // ====== implementing method from interface IteratorAggregate ======
2207     /**
2208      * Create an iterator because magic vars can't be seen by 'foreach'.
2209      * Exclude context fields
2210      */
2211     public function getIterator() {
2212         $ret = array('id' => $this->record->id);
2213         foreach ($this->record as $property => $value) {
2214             $ret[$property] = $value;
2215         }
2216         return new ArrayIterator($ret);
2217     }
2220 /**
2221  * An array of records that is sortable by many fields.
2222  *
2223  * For more info on the ArrayObject class have a look at php.net.
2224  *
2225  * @package    core
2226  * @subpackage course
2227  * @copyright  2013 Sam Hemelryk
2228  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2229  */
2230 class coursecat_sortable_records extends ArrayObject {
2232     /**
2233      * An array of sortable fields.
2234      * Gets set temporarily when sort is called.
2235      * @var array
2236      */
2237     protected $sortfields = array();
2239     /**
2240      * Sorts this array using the given fields.
2241      *
2242      * @param array $records
2243      * @param array $fields
2244      * @return array
2245      */
2246     public static function sort(array $records, array $fields) {
2247         $records = new coursecat_sortable_records($records);
2248         $records->sortfields = $fields;
2249         $records->uasort(array($records, 'sort_by_many_fields'));
2250         return $records->getArrayCopy();
2251     }
2253     /**
2254      * Sorts the two records based upon many fields.
2255      *
2256      * This method should not be called itself, please call $sort instead.
2257      * It has been marked as access private as such.
2258      *
2259      * @access private
2260      * @param stdClass $a
2261      * @param stdClass $b
2262      * @return int
2263      */
2264     public function sort_by_many_fields($a, $b) {
2265         foreach ($this->sortfields as $field => $mult) {
2266             // nulls first
2267             if (is_null($a->$field) && !is_null($b->$field)) {
2268                 return -$mult;
2269             }
2270             if (is_null($b->$field) && !is_null($a->$field)) {
2271                 return $mult;
2272             }
2274             if (is_string($a->$field) || is_string($b->$field)) {
2275                 // string fields
2276                 if ($cmp = strcoll($a->$field, $b->$field)) {
2277                     return $mult * $cmp;
2278                 }
2279             } else {
2280                 // int fields
2281                 if ($a->$field > $b->$field) {
2282                     return $mult;
2283                 }
2284                 if ($a->$field < $b->$field) {
2285                     return -$mult;
2286                 }
2287             }
2288         }
2289         return 0;
2290     }