From b33389d22884fb65d811e6075915ff8be8513405 Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Thu, 21 Feb 2013 09:57:40 +1100 Subject: [PATCH 01/17] MDL-38147 Created class coursecat, added cache definition and lang strings --- cohort/lib.php | 2 +- lang/en/cache.php | 1 + lang/en/error.php | 3 + lib/coursecatlib.php | 1298 ++++++++++++++++++++++++++++++++++++++++++ lib/datalib.php | 3 + lib/db/caches.php | 6 + lib/questionlib.php | 4 +- 7 files changed, 1314 insertions(+), 3 deletions(-) create mode 100644 lib/coursecatlib.php diff --git a/cohort/lib.php b/cohort/lib.php index b71b00b1b7e..19441a51749 100644 --- a/cohort/lib.php +++ b/cohort/lib.php @@ -102,7 +102,7 @@ function cohort_delete_cohort($cohort) { * Somehow deal with cohorts when deleting course category, * we can not just delete them because they might be used in enrol * plugins or referenced in external systems. - * @param stdClass $category + * @param stdClass|coursecat $category * @return void */ function cohort_delete_category($category) { diff --git a/lang/en/cache.php b/lang/en/cache.php index aec3fe82e2a..712d06c41f5 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -36,6 +36,7 @@ $string['cacheadmin'] = 'Cache administration'; $string['cacheconfig'] = 'Configuration'; $string['cachedef_calendar_subscriptions'] = 'Calendar subscriptions'; $string['cachedef_config'] = 'Config settings'; +$string['cachedef_coursecat'] = 'Course categories'; $string['cachedef_databasemeta'] = 'Database meta information'; $string['cachedef_eventinvalidation'] = 'Event invalidation'; $string['cachedef_groupdata'] = 'Course group information'; diff --git a/lang/en/error.php b/lang/en/error.php index 81544ccabee..622b2473463 100644 --- a/lang/en/error.php +++ b/lang/en/error.php @@ -105,6 +105,7 @@ $string['cannotmarktopic'] = 'Could not mark that topic for this course'; $string['cannotmigratedatacomments'] = 'Cannot migrate data module comments'; $string['cannotmodulename'] = 'Cannot get the module name in build navigation'; $string['cannotmoduletype'] = 'Cannot get the module type in build navigation'; +$string['cannotmovecategory'] = 'Cannot move category'; $string['cannotmoverolewithid'] = 'Cannot move role with ID {$a}'; $string['cannotopencsv'] = 'Cannot open CSV file'; $string['cannotopenfile'] = 'Cannot open file ({$a})'; @@ -159,7 +160,9 @@ $string['cannotviewprofile'] = 'You cannot view the profile of this user'; $string['cannotviewreport'] = 'You cannot view this report'; $string['cannotwritefile'] = 'Cannot write to file ({$a})'; $string['categoryerror'] = 'Category error'; +$string['categorynamerequired'] = 'Category name is required'; $string['categorytoolong'] = 'Category name too long'; +$string['categoryidnumbertaken'] = 'ID number is already used for another category'; $string['commentmisconf'] = 'Comment ID is misconfigured'; $string['componentisuptodate'] = 'Component is up-to-date'; $string['confirmsesskeybad'] = 'Sorry, but your session key could not be confirmed to carry out this action. This security feature prevents against accidental or malicious execution of important functions in your name. Please make sure you really wanted to execute this function.'; diff --git a/lib/coursecatlib.php b/lib/coursecatlib.php new file mode 100644 index 00000000000..8a1b73f44fc --- /dev/null +++ b/lib/coursecatlib.php @@ -0,0 +1,1298 @@ +. + +/** + * Contains class coursecat reponsible for course category operations + * + * @package core + * @subpackage course + * @copyright 2013 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class to store, cache, render and manage course category + * + * @package core + * @subpackage course + * @copyright 2013 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class coursecat implements renderable, cacheable_object, IteratorAggregate { + /** @var coursecat stores pseudo category with id=0. Use coursecat::get(0) to retrieve */ + protected static $coursecat0; + + /** @var array list of all fields and their short name and default value for caching */ + protected static $coursecatfields = array( + 'id' => array('id', 0), + 'name' => array('na', ''), + 'idnumber' => array('in', null), + 'description' => array('de', null), + 'descriptionformat' => array('df', 0 /*FORMAT_MOODLE*/), + 'parent' => array('pa', 0), + 'sortorder' => array('so', 0), + 'coursecount' => array('cc', 0), + 'visible' => array('vi', 1), + 'visibleold' => array('vo', 1), + 'timemodified' => null, // not cached + 'depth' => array('dh', 1), + 'path' => array('ph', null), + 'theme' => array('th', null) + ); + + /** @var int */ + protected $id; + + /** @var string */ + protected $name = ''; + + /** @var string */ + protected $idnumber = null; + + /** @var string */ + protected $description = null; + + /** @var int */ + protected $descriptionformat = 0; + + /** @var int */ + protected $parent = 0; + + /** @var int */ + protected $sortorder = 0; + + /** @var int */ + protected $coursecount = 0; + + /** @var int */ + protected $visible = 1; + + /** @var int */ + protected $visibleold = 1; + + /** @var int */ + protected $timemodified = 0; + + /** @var int */ + protected $depth = 0; + + /** @var string */ + protected $path = ''; + + /** @var string */ + protected $theme = null; + + /** @var bool */ + protected $fromcache; + + // ====== magic methods ======= + + /** + * Magic setter method, we do not want anybody to modify properties from the outside + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) { + debugging('Can not change coursecat instance properties!', DEBUG_DEVELOPER); + } + + /** + * Magic method getter, redirects to read only values. + * @param string $name + * @return mixed + */ + public function __get($name) { + if (array_key_exists($name, self::$coursecatfields)) { + return $this->$name; + } + debugging('Invalid coursecat property accessed! '.$name, DEBUG_DEVELOPER); + return null; + } + + /** + * Full support for isset on our magic read only properties. + * @param string $name + * @return bool + */ + public function __isset($name) { + if (array_key_exists($name, self::$coursecatfields)) { + return isset($this->$name); + } + return false; + } + + /** + * ALl properties are read only, sorry. + * @param string $name + */ + public function __unset($name) { + debugging('Can not unset coursecat instance properties!', DEBUG_DEVELOPER); + } + + // ====== implementing method from interface IteratorAggregate ====== + + /** + * Create an iterator because magic vars can't be seen by 'foreach'. + */ + public function getIterator() { + $ret = array(); + foreach (self::$coursecatfields as $property => $unused) { + $ret[$property] = $this->$property; + } + return new ArrayIterator($ret); + } + + // ====== general coursecat methods ====== + + /** + * Constructor + * + * Constructor is protected, use coursecat::get($id) to retrieve category + * + * @param stdClass $record + */ + protected function __construct(stdClass $record, $fromcache = false) { + context_instance_preload($record); + foreach ($record as $key => $val) { + if (array_key_exists($key, self::$coursecatfields)) { + $this->$key = $val; + } + } + $this->fromcache = $fromcache; + } + + /** + * Returns coursecat object for requested category + * + * If category is not visible to user it is treated as non existing + * unless $returninvisible is set to true + * + * If id is 0, the pseudo object for root category is returned (convenient + * for calling other functions such as get_children()) + * + * @param int $id category id + * @param int $strictness whether to throw an exception (MUST_EXIST) or + * return null (IGNORE_MISSING) in case the category is not found or + * not visible to current user + * @param bool $returninvisible set to true if you want an object to be + * returned even if this category is not visible to the current user + * (category is hidden and user does not have + * 'moodle/category:viewhiddencategories' capability). Use with care! + * @return null|\coursecat + */ + public static function get($id, $strictness = MUST_EXIST, $returninvisible = false) { + global $DB; + if (!$id) { + if (!isset(self::$coursecat0)) { + $record = array( + 'id' => 0, + 'visible' => 1, + 'depth' => 0, + 'path' => '' + ); + self::$coursecat0 = new coursecat((object)$record); + } + return self::$coursecat0; + } + $coursecatcache = cache::make('core', 'coursecat'); + $coursecat = null; + if ($coursecatcache->has($id)) { + $coursecat = $coursecatcache->get($id); + } else { + $all = self::get_all_ids(); + if (array_key_exists($id, $all)) { + // retrieve from DB and store in cache + list($ccselect, $ccjoin) = context_instance_preload_sql('cc.id', CONTEXT_COURSECAT, 'ctx'); + $sql = "SELECT cc.* $ccselect + FROM {course_categories} cc + $ccjoin + WHERE cc.id = ?"; + if ($record = $DB->get_record_sql($sql, array($id))) { + $coursecat = new coursecat($record); + $coursecatcache->set($id, $coursecat); + } else { + // should not happen because if entry is present in get_all_ids() it should be found + self::purge_cache(); + } + } + } + if ($coursecat && ($returninvisible || $coursecat->is_uservisible())) { + return $coursecat; + } else { + if ($strictness == MUST_EXIST) { + throw new moodle_exception('unknowcategory'); + } + } + return null; + } + + /** + * Returns the first found category + * + * Note that if there are no categories visible to the current user on the first level, + * the invisible category may be returned + * + * @return coursecat + */ + public static function get_default() { + if ($visiblechildren = self::get(0)->get_children()) { + $defcategory = reset($visiblechildren); + } else { + $all = $this->get_all_ids(); + $defcategoryid = $all[0][0]; + $defcategory = self::get($defcategoryid, MUST_EXIST, true); + } + return $defcategory; + } + + /** + * Restores the object after it has been externally modified in DB for example + * during {@link fix_course_sortorder()} + */ + protected function restore() { + // update all fields in the current object + $newrecord = self::get($this->id, MUST_EXIST, true); + foreach (self::$coursecatfields as $key => $unused) { + $this->$key = $newrecord->$key; + } + } + + /** + * Creates a new category either from form data or from raw data + * + * Please note that this function does not verify access control. + * + * Exception is thrown if name is missing or idnumber is duplicating another one in the system. + * + * Category visibility is inherited from parent unless $data->visible = 0 is specified + * + * @param array|stdClass $data + * @param array $editoroptions if specified, the data is considered to be + * form data and file_postupdate_standard_editor() is being called to + * process images in description. + * @return coursecat + * @throws moodle_exception + */ + public static function create($data, $editoroptions = null) { + global $DB, $CFG; + $data = (object)$data; + $newcategory = new stdClass(); + + $newcategory->descriptionformat = FORMAT_MOODLE; + $newcategory->description = ''; + // copy all description* fields regardless of whether this is form data or direct field update + foreach ($data as $key => $value) { + if (preg_match("/^description/", $key)) { + $newcategory->$key = $value; + } + } + + if (empty($data->name)) { + throw new moodle_exception('categorynamerequired'); + } + if (textlib::strlen($data->name) > 255) { + throw new moodle_exception('categorytoolong'); + } + $newcategory->name = $data->name; + + // validate and set idnumber + if (!empty($data->idnumber)) { + if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) { + throw new moodle_exception('categoryidnumbertaken'); + } + if (textlib::strlen($data->idnumber) > 100) { + throw new moodle_exception('idnumbertoolong'); + } + } + if (isset($data->idnumber)) { + $newcategory->idnumber = $data->idnumber; + } + + if (isset($data->theme) && !empty($CFG->allowcategorythemes)) { + $newcategory->theme = $data->theme; + } + + if (empty($data->parent)) { + $parent = self::get(0); + } else { + $parent = self::get($data->parent, MUST_EXIST, true); + } + $newcategory->parent = $parent->id; + $newcategory->depth = $parent->depth + 1; + + // By default category is visible, unless visible = 0 is specified or parent category is hidden + if (isset($data->visible) && !$data->visible) { + // create a hidden category + $newcategory->visible = $newcategory->visibleold = 0; + } else { + // create a category that inherits visibility from parent + $newcategory->visible = $parent->visible; + // in case parent is hidden, when it changes visibility this new subcategory will automatically become visible too + $newcategory->visibleold = 1; + } + + $newcategory->sortorder = 0; + $newcategory->timemodified = time(); + + $newcategory->id = $DB->insert_record('course_categories', $newcategory); + + // update path (only possible after we know the category id + $path = $parent->path . '/' . $newcategory->id; + $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id)); + + // We should mark the context as dirty + context_coursecat::instance($newcategory->id)->mark_dirty(); + + fix_course_sortorder(); + + // if this is data from form results, save embedded files and update description + $categorycontext = context_coursecat::instance($newcategory->id); + if ($editoroptions) { + $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0); + + // update only fields description and descriptionformat + $updatedata = array_intersect_key((array)$newcategory, array('id' => 1, 'description' => 1, 'descriptionformat' => 1)); + $DB->update_record('course_categories', $updatedata); + + self::purge_cache(); + } + + add_to_log(SITEID, "category", 'add', "editcategory.php?id=$newcategory->id", $newcategory->id); + + return self::get($newcategory->id, MUST_EXIST, true); + } + + /** + * Updates the record with either form data or raw data + * + * Please note that this function does not verify access control. + * + * This function calls coursecat::_change_parent if field 'parent' is updated. + * It also calls coursecat::_hide or coursecat::_show if 'visible' is updated. + * Visibility is changed first and then parent is changed. This means that + * if parent category is hidden, the current category will become hidden + * too and it may overwrite whatever was set in field 'visible'. + * + * Note that fields 'path' and 'depth' can not be updated manually + * Also coursecat::update() can not directly update the field 'sortoder' + * + * @param array|stdClass $data + * @param array $editoroptions if specified, the data is considered to be + * form data and file_postupdate_standard_editor() is being called to + * process images in description. + * @throws moodle_exception + */ + public function update($data, $editoroptions = null) { + global $DB, $CFG; + if (!$this->id) { + // there is no actual DB record associated with root category + return; + } + + $data = (object)$data; + $newcategory = new stdClass(); + $newcategory->id = $this->id; + + // copy all description* fields regardless of whether this is form data or direct field update + foreach ($data as $key => $value) { + if (preg_match("/^description/", $key)) { + $newcategory->$key = $value; + } + } + + if (isset($data->name) && empty($data->name)) { + throw new moodle_exception('categorynamerequired'); + } + + if (!empty($data->name) && $data->name !== $this->name) { + if (textlib::strlen($data->name) > 255) { + throw new moodle_exception('categorytoolong'); + } + $newcategory->name = $data->name; + } + + if (isset($data->idnumber) && $data->idnumber != $this->idnumber) { + if (textlib::strlen($data->idnumber) > 100) { + throw new moodle_exception('idnumbertoolong'); + } + if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) { + throw new moodle_exception('categoryidnumbertaken'); + } + $newcategory->idnumber = $data->idnumber; + } + + if (isset($data->theme) && !empty($CFG->allowcategorythemes)) { + $newcategory->theme = $data->theme; + } + + $changes = false; + if (isset($data->visible)) { + if ($data->visible) { + $changes = $this->_show(); + } else { + $changes = $this->_hide(0); + } + } + + if (isset($data->parent) && $data->parent != $this->parent) { + if ($changes) { + self::purge_cache(); + } + $parentcat = self::get($data->parent, MUST_EXIST, true); + $this->_change_parent($parentcat); + fix_course_sortorder(); + } + + $newcategory->timemodified = time(); + + if ($editoroptions) { + $categorycontext = context_coursecat::instance($this->id); + $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0); + } + $DB->update_record('course_categories', $newcategory); + add_to_log(SITEID, "category", 'update', "editcategory.php?id=$this->id", $this->id); + fix_course_sortorder(); + + // update all fields in the current object + $this->restore(); + } + + /** + * Checks if this course category is visible to current user + * + * Please note that methods coursecat::get (without 3rd argumet), + * coursecat::get_children(), etc. return only visible categories so it is + * usually not needed to call this function outside of this class + * + * @return bool + */ + public function is_uservisible() { + return !$this->id || $this->visible || + has_capability('moodle/category:viewhiddencategories', + context_coursecat::instance($this->id)); + } + + /** + * Returns all categories visible to the current user + * + * This is a generic function that returns an array of + * (category id => coursecat object) sorted by sortorder + * + * @see coursecat::get_children() + * @see coursecat::get_all_parents() + * + * @return cacheable_object_array array of coursecat objects + */ + public static function get_all_visible() { + global $USER; + $coursecatcache = cache::make('core', 'coursecat'); + $ids = $coursecatcache->get('user'. $USER->id); + if ($ids === false) { + $all = self::get_all_ids(); + $parentvisible = $all[0]; + $rv = array(); + foreach ($all as $id => $children) { + if ($id && in_array($id, $parentvisible) && + ($coursecat = self::get($id, IGNORE_MISSING)) && + (!$coursecat->parent || isset($rv[$coursecat->parent]))) { + $rv[$id] = $coursecat; + $parentvisible += $children; + } + } + $coursecatcache->set('user'. $USER->id, array_keys($rv)); + } else { + $rv = array(); + foreach ($ids as $id) { + if ($coursecat = self::get($id, IGNORE_MISSING)) { + $rv[$id] = $coursecat; + } + } + } + return $rv; + } + + /** + * Returns tree of categories ids + * + * Return array has categories ids as keys and list of children ids as values. + * Also there is an additional first element with key 0 with list of categories on the top level. + * Therefore the number of elements in the return array is one more than number of categories in the system. + * + * Also this method ensures that all categories are cached together with their contexts. + * + * @return array + */ + protected static function get_all_ids() { + global $DB; + $coursecatcache = cache::make('core', 'coursecat'); + $all = $coursecatcache->get('all'); + if ($all === false) { + $coursecatcache->purge(); // it should be empty already but to be extra sure + $sql = "SELECT cc.id, cc.parent + FROM {course_categories} cc + ORDER BY cc.sortorder"; + $rs = $DB->get_recordset_sql($sql, array()); + $all = array(0 => array()); + foreach ($rs as $record) { + $all[$record->id] = array(); + $all[$record->parent][] = $record->id; + } + $rs->close(); + if (!count($all[0])) { + // No categories found. + // This may happen after upgrade from very old moodle version. In new versions the default category is created on install. + $defcoursecat = self::create(array('name' => get_string('miscellaneous'))); + $coursecatcache->set($defcoursecat->id, $defcoursecat); + set_config('defaultrequestcategory', $defcoursecat->id); + $all[0][$defcoursecat->id] = array(); + } + $coursecatcache->set('all', $all); + } + return $all; + } + + /** + * Returns all categories in the system + * + * This function is protected because all functions operating with the full + * list of categories (including those not visible to the current user) + * must be only inside this class + * + * @return cacheable_object_array array of coursecat objects + */ + protected static function get_all() { + $all = self::get_all_ids(); + $rv = array(); + foreach ($all as $id => $unused) { + if ($coursecat = self::get($id, IGNORE_MISSING, true)) { + $rv[$id] = $coursecat; + } + } + unset($rv[0]); + return $rv; + } + + /** + * Returns number of ALL categories in the system regardless if + * they are visible to current user or not + * + * @return int + */ + public static function cnt_all() { + $all = self::get_all_ids(); + return count($all) - 1; // do not count 0-category + } + + /** + * Returns array of children categories visible to the current user + * + * @return array of coursecat objects indexed by category id + */ + public function get_children() { + $all = self::get_all_ids(); + $rv = array(); + if (!empty($all[$this->id])) { + foreach ($all[$this->id] as $id) { + if ($coursecat = self::get($id, IGNORE_MISSING)) { + // do not return invisible + $rv[$coursecat->id] = $coursecat; + } + } + } + return $rv; + } + + /** + * Returns true if the category has ANY children, including those not visible to the user + * + * @return boolean + */ + public function has_children() { + $all = self::get_all_ids(); + return !empty($all[$this->id]); + } + + /** + * Returns true if the category has courses in it (count does not include courses + * in child categories) + * + * @return bool + */ + public function has_courses() { + global $DB; + return $DB->record_exists_sql("select 1 from {course} where category = ?", + array($this->id)); + } + + /** + * Returns true if user can delete current category and all its contents + * + * To be able to delete course category the user must have permission + * 'moodle/category:manage' in ALL child course categories AND + * be able to delete all courses + * + * @return bool + */ + public function can_delete_full() { + global $DB; + if (!$this->id) { + // fool-proof + return false; + } + + $context = context_coursecat::instance($this->id); + if (!$this->is_uservisible() || + !has_capability('moodle/category:manage', $context)) { + return false; + } + + // Check all child categories (we can't call get_children() because we need to check + // not visible categories too + $all = self::get_all(); + foreach ($all as $coursecat) { + if (preg_match("|^{$this->path}/|", $coursecat->path)) { + // this is a child category + if (!$coursecat->is_uservisible() || + !has_capability('moodle/category:manage', context_coursecat::instance($coursecat->id))) { + return false; + } + } + } + + // Check courses + $courses = $DB->get_fieldset_sql('SELECT instanceid FROM {context} '. + 'WHERE path like :pathmask and contextlevel = :courselevel', + array('pathmask' => $context->path. '/%', + 'courselevel' => CONTEXT_COURSE)); + foreach ($courses as $courseid) { + if (!can_delete_course($courseid)) { + return false; + } + } + + return true; + } + + /** + * Recursively delete category including all subcategories and courses + * + * Function {@link coursecat::can_delete_full()} MUST be called prior + * to calling this function because there is no capability check + * inside this function + * + * @param boolean $showfeedback display some notices + * @return array return deleted courses + */ + function delete_full($showfeedback = true) { + global $CFG, $DB; + require_once($CFG->libdir.'/gradelib.php'); + require_once($CFG->libdir.'/questionlib.php'); + require_once($CFG->dirroot.'/cohort/lib.php'); + + $deletedcourses = array(); + + // Get children. Note, we don't want to use cache here because + // it would be rebuilt too often + $children = $DB->get_records('course_categories', array('parent' => $this->id), 'sortorder ASC'); + foreach ($children as $record) { + $coursecat = new coursecat($record); + $deletedcourses += $coursecat->delete_full($showfeedback); + } + + if ($courses = $DB->get_records('course', array('category' => $this->id), 'sortorder ASC')) { + foreach ($courses as $course) { + if (!delete_course($course, false)) { + throw new moodle_exception('cannotdeletecategorycourse', '', '', $course->shortname); + } + $deletedcourses[] = $course; + } + } + + // move or delete cohorts in this context + cohort_delete_category($this); + + // now delete anything that may depend on course category context + grade_course_category_delete($this->id, 0, $showfeedback); + if (!question_delete_course_category($this, 0, $showfeedback)) { + throw new moodle_exception('cannotdeletecategoryquestions', '', '', $this->get_formatted_name()); + } + + // finally delete the category and it's context + $DB->delete_records('course_categories', array('id' => $this->id)); + delete_context(CONTEXT_COURSECAT, $this->id); + add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)"); + + self::purge_cache(); + + events_trigger('course_category_deleted', $this); + + // If we deleted $CFG->defaultrequestcategory, make it point somewhere else. + if ($this->id == $CFG->defaultrequestcategory) { + set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0))); + } + return $deletedcourses; + } + + /** + * Checks if user can delete this category and move content (courses, subcategories and questions) + * to another category. If yes returns the array of possible target categories names + * + * If user can not manage this category or it is completely empty - empty array will be returned + * + * @return array + */ + public function move_content_targets_list() { + global $CFG; + require_once($CFG->libdir . '/questionlib.php'); + $context = context_coursecat::instance($this->id); + if (!$this->is_uservisible() || + !has_capability('moodle/category:manage', $context)) { + // User is not able to manage current category, he is not able to delete it. + // No possible target categories. + return array(); + } + + $testcaps = array(); + // If this category has courses in it, user must have 'course:create' capability in target category. + if ($this->has_courses()) { + $testcaps[] = 'moodle/course:create'; + } + // If this category has subcategories or questions, user must have 'category:manage' capability in target category. + if ($this->has_children() || question_context_has_any_questions($context)) { + $testcaps[] = 'moodle/category:manage'; + } + if (!empty($testcaps)) { + // return list of categories excluding this one and it's children + return self::make_categories_list($testcaps, $this->id); + } + + // Category is completely empty, no need in target for contents. + return array(); + } + + /** + * Checks if user has capability to move all category content to the new parent before + * removing this category + * + * @param int $newcatid + * @return bool + */ + public function can_move_content_to($newcatid) { + global $CFG; + require_once($CFG->libdir . '/questionlib.php'); + $context = context_coursecat::instance($this->id); + if (!$this->is_uservisible() || + !has_capability('moodle/category:manage', $context)) { + return false; + } + $testcaps = array(); + // If this category has courses in it, user must have 'course:create' capability in target category. + if ($this->has_courses()) { + $testcaps[] = 'moodle/course:create'; + } + // If this category has subcategories or questions, user must have 'category:manage' capability in target category. + if ($this->has_children() || question_context_has_any_questions($context)) { + $testcaps[] = 'moodle/category:manage'; + } + if (!empty($testcaps)) { + return has_all_capabilities($testcaps, context_coursecat::instance($newcatid)); + } + + // there is no content but still return true + return true; + } + + /** + * Deletes a category and moves all content (children, courses and questions) to the new parent + * + * Note that this function does not check capabilities, {@link coursecat::can_move_content_to()} + * must be called prior + * + * @param int $newparentid + * @param bool $showfeedback + * @return bool + */ + public function delete_move($newparentid, $showfeedback = false) { + global $CFG, $DB, $OUTPUT; + require_once($CFG->libdir.'/gradelib.php'); + require_once($CFG->libdir.'/questionlib.php'); + require_once($CFG->dirroot.'/cohort/lib.php'); + + // get all objects and lists because later the caches will be reset so + // we don't need to make extra queries + $newparentcat = self::get($newparentid, MUST_EXIST, true); + $catname = $this->get_formatted_name(); + $children = $this->get_children(); + $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', array('category' => $this->id)); + $context = context_coursecat::instance($this->id); + + if ($children) { + foreach ($children as $childcat) { + $childcat->_change_parent($newparentcat); + // Log action. + add_to_log(SITEID, "category", "move", "editcategory.php?id=$childcat->id", $childcat->id); + } + fix_course_sortorder(); + } + + if ($coursesids) { + if (!move_courses($coursesids, $newparentid)) { + if ($showfeedback) { + echo $OUTPUT->notification("Error moving courses"); + } + return false; + } + if ($showfeedback) { + echo $OUTPUT->notification(get_string('coursesmovedout', '', $catname), 'notifysuccess'); + } + } + + // move or delete cohorts in this context + cohort_delete_category($this); + + // now delete anything that may depend on course category context + grade_course_category_delete($this->id, $newparentid, $showfeedback); + if (!question_delete_course_category($this, $newparentcat, $showfeedback)) { + if ($showfeedback) { + echo $OUTPUT->notification(get_string('errordeletingquestionsfromcategory', 'question', $catname), 'notifysuccess'); + } + return false; + } + + // finally delete the category and it's context + $DB->delete_records('course_categories', array('id' => $this->id)); + $context->delete(); + add_to_log(SITEID, "category", "delete", "index.php", "$this->name (ID $this->id)"); + + events_trigger('course_category_deleted', $this); + + self::purge_cache(); + + if ($showfeedback) { + echo $OUTPUT->notification(get_string('coursecategorydeleted', '', $catname), 'notifysuccess'); + } + + // If we deleted $CFG->defaultrequestcategory, make it point somewhere else. + if ($this->id == $CFG->defaultrequestcategory) { + set_config('defaultrequestcategory', $DB->get_field('course_categories', 'MIN(id)', array('parent' => 0))); + } + return true; + } + + /** + * Checks if user can move current category to the new parent + * + * This checks if new parent category exists, user has manage cap there + * and new parent is not a child of this category + * + * @param int|stdClass|coursecat $newparentcat + * @return bool + */ + public function can_change_parent($newparentcat) { + if (!has_capability('moodle/category:manage', context_coursecat::instance($this->id))) { + return false; + } + if (is_object($newparentcat)) { + $newparentcat = self::get($newparentcat->id, IGNORE_MISSING); + } else { + $newparentcat = self::get((int)$newparentcat, IGNORE_MISSING); + } + if (!$newparentcat) { + return false; + } + $newparents = $newparentcat->get_all_parents(); + if ($newparentcat->id == $this->id || isset($newparents[$this->id])) { + // can not move to itself or it's own child + return false; + } + return has_capability('moodle/category:manage', get_category_or_system_context($newparentcat->id)); + } + + /** + * Moves the category under another parent category. All associated contexts are moved as well + * + * This is protected function, use change_parent() or update() from outside of this class + * + * @see coursecat::change_parent() + * @see coursecat::update() + * + * @param coursecat $newparentcat + */ + protected function _change_parent(coursecat $newparentcat) { + global $DB; + + $context = context_coursecat::instance($this->id); + + $hidecat = false; + if (empty($newparentcat->id)) { + $DB->set_field('course_categories', 'parent', 0, array('id' => $this->id)); + $newparent = context_system::instance(); + } else { + $checkparents = $newparentcat->get_all_parents(); + if ($newparentcat->id == $this->id || isset($checkparents[$this->id])) { + // can not move to itself or it's own child + throw new moodle_exception('cannotmovecategory'); + } + $DB->set_field('course_categories', 'parent', $newparentcat->id, array('id' => $this->id)); + $newparent = context_coursecat::instance($newparentcat->id); + + if (!$newparentcat->visible and $this->visible) { + // better hide category when moving into hidden category, teachers may unhide afterwards and the hidden children will be restored properly + $hidecat = true; + } + } + $this->parent = $newparentcat->id; + + context_moved($context, $newparent); + + // now make it last in new category + $DB->set_field('course_categories', 'sortorder', MAX_COURSES_IN_CATEGORY*MAX_COURSE_CATEGORIES, array('id' => $this->id)); + + if ($hidecat) { + fix_course_sortorder(); + $this->restore(); + // Hide object but store 1 in visibleold, because when parent category visibility changes this category must become visible again. + $this->_hide(1); + } + } + + /** + * Efficiently moves a category - NOTE that this can have + * a huge impact access-control-wise... + * + * Note that this function does not check capabilities. + * + * Example of usage: + * $coursecat = coursecat::get($categoryid); + * if ($coursecat->can_change_parent($newparentcatid)) { + * $coursecat->change_parent($newparentcatid); + * } + * + * This function does not update field course_categories.timemodified + * If you want to update timemodified, use + * $coursecat->update(array('parent' => $newparentcat)); + * + * @param int|stdClass|coursecat $newparentcat + */ + public function change_parent($newparentcat) { + // Make sure parent category exists but do not check capabilities here that it is visible to current user. + if (is_object($newparentcat)) { + $newparentcat = self::get($newparentcat->id, MUST_EXIST, true); + } else { + $newparentcat = self::get((int)$newparentcat, MUST_EXIST, true); + } + if ($newparentcat->id != $this->parent) { + $this->_change_parent($newparentcat); + fix_course_sortorder(); + $this->restore(); + add_to_log(SITEID, "category", "move", "editcategory.php?id=$this->id", $this->id); + } + } + + /** + * Hide course category and child course and subcategories + * + * If this category has changed the parent and is moved under hidden + * category we will want to store it's current visibility state in + * the field 'visibleold'. If admin clicked 'hide' for this particular + * category, the field 'visibleold' should become 0. + * + * All subcategories and courses will have their current visibility in the field visibleold + * + * This is protected function, use hide() or update() from outside of this class + * + * @see coursecat::hide() + * @see coursecat::update() + * + * @param int $visibleold value to set in field $visibleold for this category + * @return bool whether changes have been made and caches need to be purged afterwards + */ + protected function _hide($visibleold = 0) { + global $DB; + $changes = false; + + if ($this->id && $this->visibleold != $visibleold) { + $this->visibleold = $visibleold; + $DB->set_field('course_categories', 'visibleold', $visibleold, array('id' => $this->id)); + $changes = true; + } + if (!$this->visible || !$this->id) { + // already hidden or can not be hidden + return $changes; + } + + $this->visible = 0; + $DB->set_field('course_categories', 'visible', 0, array('id'=>$this->id)); + $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 + $DB->set_field('course', 'visible', 0, array('category' => $this->id)); + // get all child categories and hide too + if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visible')) { + foreach ($subcats as $cat) { + $DB->set_field('course_categories', 'visibleold', $cat->visible, array('id' => $cat->id)); + $DB->set_field('course_categories', 'visible', 0, array('id' => $cat->id)); + $DB->execute("UPDATE {course} SET visibleold = visible WHERE category = ?", array($cat->id)); + $DB->set_field('course', 'visible', 0, array('category' => $cat->id)); + } + } + return true; + } + + /** + * Hide course category and child course and subcategories + * + * Note that there is no capability check inside this function + * + * This function does not update field course_categories.timemodified + * If you want to update timemodified, use + * $coursecat->update(array('visible' => 0)); + */ + public function hide() { + if ($this->_hide(0)) { + self::purge_cache(); + add_to_log(SITEID, "category", "hide", "editcategory.php?id=$this->id", $this->id); + } + } + + /** + * Show course category and restores visibility for child course and subcategories + * + * Note that there is no capability check inside this function + * + * This is protected function, use show() or update() from outside of this class + * + * @see coursecat::show() + * @see coursecat::update() + * + * @return bool whether changes have been made and caches need to be purged afterwards + */ + protected function _show() { + global $DB; + + if ($this->visible) { + // already visible + return false; + } + + $this->visible = 1; + $this->visibleold = 1; + $DB->set_field('course_categories', 'visible', 1, array('id' => $this->id)); + $DB->set_field('course_categories', 'visibleold', 1, array('id' => $this->id)); + $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($this->id)); + // get all child categories and unhide too + if ($subcats = $DB->get_records_select('course_categories', "path LIKE ?", array("$this->path/%"), 'id, visibleold')) { + foreach ($subcats as $cat) { + if ($cat->visibleold) { + $DB->set_field('course_categories', 'visible', 1, array('id' => $cat->id)); + } + $DB->execute("UPDATE {course} SET visible = visibleold WHERE category = ?", array($cat->id)); + } + } + return true; + } + + /** + * Show course category and restores visibility for child course and subcategories + * + * Note that there is no capability check inside this function + * + * This function does not update field course_categories.timemodified + * If you want to update timemodified, use + * $coursecat->update(array('visible' => 1)); + */ + public function show() { + if ($this->_show()) { + self::purge_cache(); + add_to_log(SITEID, "category", "show", "editcategory.php?id=$this->id", $this->id); + } + } + + /** + * Returns name of the category formatted as a string + * + * @param array $options formatting options other than context + * @return string + */ + public function get_formatted_name($options = array()) { + if ($this->id) { + $context = context_coursecat::instance($this->id); + return format_string($this->name, true, array('context' => $context) + $options); + } else { + return ''; // TODO 'Top'? + } + } + + /** + * Returns all parents of the element. Last element in the return array is the direct parent of this category + * + * For example, if you have a tree of categories like: + * Miscellaneous (id = 1) + * Subcategory (id = 2) + * Sub-subcategory (id = 4) + * Other category (id = 3) + * + * coursecat::get(1)->get_all_parents() == array() + * coursecat::get(2)->get_all_parents() == array(1 => coursecat(...)) + * coursecat::get(4)->get_all_parents() == array(1 => coursecat(...), 2 => coursecat(...)); + * + * Note that this method does not check if all parents are accessible by current user + * + * @return array of coursecat objects indexed by category id + */ + public function get_all_parents() { + $parents = array(); + if ($this->parent && ($parent = self::get($this->parent, IGNORE_MISSING, true))) { + $parents += array($parent->id => $parent) + + $parent->get_all_parents(); + } + return array_reverse($parents, true); + } + + /** + * This function recursively travels the categories, building up a nice list + * for display or to use in a form element * + * List is cached for 10 minutes + * * For example, if you have a tree of categories like: * Miscellaneous (id = 1) * Subcategory (id = 2) @@ -1375,46 +1377,89 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { * @return array of strings */ public static function make_categories_list($requiredcapability = '', $excludeid = 0, $separator = ' / ') { - return self::get(0)->get_children_names($requiredcapability, $excludeid, $separator); - } + global $DB; + $coursecatcache = cache::make('core', 'coursecat'); - /** - * Helper function for {@link coursecat::make_categories_list()} - * - * @param string/array $requiredcapability if given, only categories where the current - * user has this capability will be included in return value. Can also be - * an array of capabilities, in which case they are all required. - * @param integer $excludeid Omit this category and its children from the lists built. - * @param string $separator string to use as a separator between parent and child category. Default ' / ' - * @param string $pathprefix For internal use, as part of recursive calls - * @return array of strings - */ - protected function get_children_names($requiredcapability = '', $excludeid = 0, $separator = ' / ', $pathprefix = '') { - $list = array(); - if ($excludeid && $this->id == $excludeid) { - return $list; + // Check if we cached the complete list of user-accessible category names ($baselist) or list of ids with requried cap ($thislist). + $basecachekey = 'catlist'; + $baselist = $coursecatcache->get($basecachekey); + if ($baselist !== false) { + $baselist = false; + } + $thislist = false; + if (!empty($requiredcapability)) { + $requiredcapability = (array)$requiredcapability; + $thiscachekey = 'catlist:'. serialize($requiredcapability); + if ($baselist !== false && ($thislist = $coursecatcache->get($thiscachekey)) !== false) { + $thislist = preg_split('|,|', $thislist, -1, PREG_SPLIT_NO_EMPTY); + } + } else if ($baselist !== false) { + $thislist = array_keys($baselist); } - if ($this->id) { - // Update $path. - if ($pathprefix) { - $pathprefix .= $separator; + if ($baselist === false) { + // We don't have $baselist cached, retrieve it. Retrieve $thislist again in any case. + $ctxselect = context_helper::get_preload_record_columns_sql('ctx'); + $sql = "SELECT cc.id, cc.sortorder, cc.name, cc.visible, cc.parent, cc.path, $ctxselect + FROM {course_categories} cc + JOIN {context} ctx ON cc.id = ctx.instanceid AND ctx.contextlevel = :contextcoursecat + ORDER BY cc.sortorder"; + $rs = $DB->get_recordset_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT)); + $baselist = array(); + $thislist = array(); + foreach ($rs as $record) { + // If the category's parent is not visible to the user, it is not visible as well. + if (!$record->parent || isset($baselist[$record->parent])) { + $context = context_coursecat::instance($record->id); + if (!$record->visible && !has_capability('moodle/category:viewhiddencategories', $context)) { + // No cap to view category, added to neither $baselist nor $thislist + continue; + } + $baselist[$record->id] = array( + 'name' => format_string($record->name, true, array('context' => $context)), + 'path' => $record->path + ); + if (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context)) { + // No required capability, added to $baselist but not to $thislist. + continue; + } + $thislist[] = $record->id; + } } - $pathprefix .= $this->get_formatted_name(); + $rs->close(); + $coursecatcache->set($basecachekey, $baselist); + if (!empty($requiredcapability)) { + $coursecatcache->set($thiscachekey, join(',', $thislist)); + } + } else if ($thislist === false) { + // We have $baselist cached but not $thislist. Simplier query is used to retrieve. + $ctxselect = context_helper::get_preload_record_columns_sql('ctx'); + $sql = "SELECT ctx.instanceid id, $ctxselect + FROM {context} ctx WHERE ctx.contextlevel = :contextcoursecat"; + $contexts = $DB->get_records_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT)); + $thislist = array(); + foreach (array_keys($baselist) as $id) { + context_helper::preload_from_record($contexts[$id]); + if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) { + $thislist[] = $id; + } + } + $coursecatcache->set($thiscachekey, join(',', $thislist)); + } - // Add this category to $list, if the permissions check out. - if (empty($requiredcapability) || - has_all_capabilities((array)$requiredcapability, context_coursecat::instance($this->id))) { - $list[$this->id] = $pathprefix; + // Now build the array of strings to return, mind $separator and $excludeid. + $names = array(); + foreach ($thislist as $id) { + $path = preg_split('|/|', $baselist[$id]['path'], -1, PREG_SPLIT_NO_EMPTY); + if (!$excludeid || !in_array($excludeid, $path)) { + $namechunks = array(); + foreach ($path as $parentid) { + $namechunks[] = $baselist[$parentid]['name']; + } + $names[$id] = join($separator, $namechunks); } } - - // Add all the children recursively, while updating the parents array. - foreach ($this->get_children() as $cat) { - $list += $cat->get_children_names($requiredcapability, $excludeid, $separator, $pathprefix); - } - - return $list; + return $names; } // ====== implementing method from interface cacheable_object ====== From 9c6cfc083f5738f4a57a648a64e82fc0a1d1d371 Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Fri, 22 Mar 2013 12:59:03 +1100 Subject: [PATCH 14/17] MDL-38147 fixes to phpdocs and avoid using deprecated context functions --- lib/coursecatlib.php | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/coursecatlib.php b/lib/coursecatlib.php index d885e575846..cbd33baf485 100644 --- a/lib/coursecatlib.php +++ b/lib/coursecatlib.php @@ -104,6 +104,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { /** * Magic setter method, we do not want anybody to modify properties from the outside + * * @param string $name * @param mixed $value */ @@ -113,6 +114,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { /** * Magic method getter, redirects to read only values. Queries from DB the fields that were not cached + * * @param string $name * @return mixed */ @@ -136,6 +138,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { /** * Full support for isset on our magic read only properties. + * * @param string $name * @return bool */ @@ -147,17 +150,20 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { } /** - * ALl properties are read only, sorry. + * All properties are read only, sorry. + * * @param string $name */ public function __unset($name) { debugging('Can not unset coursecat instance properties!', DEBUG_DEVELOPER); } - // ====== implementing method from interface IteratorAggregate ====== - /** * Create an iterator because magic vars can't be seen by 'foreach'. + * + * implementing method from interface IteratorAggregate + * + * @return ArrayIterator */ public function getIterator() { $ret = array(); @@ -169,17 +175,16 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { return new ArrayIterator($ret); } - // ====== general coursecat methods ====== - /** * Constructor * * Constructor is protected, use coursecat::get($id) to retrieve category * - * @param stdClass $record + * @param stdClass $record record from DB (may not contain all fields) + * @param bool $fromcache whether it is being restored from cache */ protected function __construct(stdClass $record, $fromcache = false) { - context_instance_preload($record); + context_helper::preload_from_record($record); foreach ($record as $key => $val) { if (array_key_exists($key, self::$coursecatfields)) { $this->$key = $val; @@ -310,12 +315,12 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { // validate and set idnumber if (!empty($data->idnumber)) { - if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) { - throw new moodle_exception('categoryidnumbertaken'); - } if (textlib::strlen($data->idnumber) > 100) { throw new moodle_exception('idnumbertoolong'); } + if ($DB->record_exists('course_categories', array('idnumber' => $data->idnumber))) { + throw new moodle_exception('categoryidnumbertaken'); + } } if (isset($data->idnumber)) { $newcategory->idnumber = $data->idnumber; @@ -364,7 +369,10 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { $newcategory = file_postupdate_standard_editor($newcategory, 'description', $editoroptions, $categorycontext, 'coursecat', 'description', 0); // update only fields description and descriptionformat - $updatedata = array_intersect_key((array)$newcategory, array('id' => 1, 'description' => 1, 'descriptionformat' => 1)); + $updatedata = new stdClass(); + $updatedata->id = $newcategory->id; + $updatedata->description = $newcategory->description; + $updatedata->descriptionformat = $newcategory->descriptionformat; $DB->update_record('course_categories', $updatedata); } @@ -427,7 +435,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { if (textlib::strlen($data->idnumber) > 100) { throw new moodle_exception('idnumbertoolong'); } - if ($existing = $DB->get_record('course_categories', array('idnumber' => $data->idnumber))) { + if ($DB->record_exists('course_categories', array('idnumber' => $data->idnumber))) { throw new moodle_exception('categoryidnumbertaken'); } $newcategory->idnumber = $data->idnumber; @@ -1137,7 +1145,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { } $this->parent = $newparentcat->id; - context_moved($context, $newparent); + $context->update_moved($newparent); // now make it last in new category $DB->set_field('course_categories', 'sortorder', MAX_COURSES_IN_CATEGORY*MAX_COURSE_CATEGORIES, array('id' => $this->id)); @@ -1462,11 +1470,11 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { return $names; } - // ====== implementing method from interface cacheable_object ====== - /** * Prepares the object for caching. Works like the __sleep method. * + * implementing method from interface cacheable_object + * * @return array ready to be cached */ public function prepare_to_cache() { @@ -1488,6 +1496,8 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { /** * Takes the data provided by prepare_to_cache and reinitialises an instance of the associated from it. * + * implementing method from interface cacheable_object + * * @param array $a * @return coursecat */ From 93c544bdb031bda0c5c46726330a8c178a1e2457 Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Tue, 5 Mar 2013 11:41:16 +1100 Subject: [PATCH 15/17] MDL-38147 Added functions coursecat::search_courses(), get_courses() Also search_courses_count(), get_courses_count(); Added unittests --- lib/coursecatlib.php | 642 ++++++++++++++++++++++++++++++-- lib/datalib.php | 27 +- lib/tests/coursecatlib_test.php | 59 +++ 3 files changed, 695 insertions(+), 33 deletions(-) diff --git a/lib/coursecatlib.php b/lib/coursecatlib.php index cbd33baf485..5c10951c7db 100644 --- a/lib/coursecatlib.php +++ b/lib/coursecatlib.php @@ -624,8 +624,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { protected static function get_records($whereclause, $params) { global $DB; // Retrieve from DB only the fields that need to be stored in cache - $fields = array_filter(array_keys(self::$coursecatfields), function ($element) - { return (self::$coursecatfields[$element] !== null); } ); + $fields = array_keys(array_filter(self::$coursecatfields)); $ctxselect = context_helper::get_preload_record_columns_sql('ctx'); $sql = "SELECT cc.". join(',cc.', $fields). ", $ctxselect FROM {course_categories} cc @@ -635,6 +634,138 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { array('contextcoursecat' => CONTEXT_COURSECAT) + $params); } + /** + * Given list of DB records from table course populates each record with list of users with course contact roles + * + * This function fills the courses with raw information as {@link get_role_users()} would do. + * See also {@link course_in_list::get_course_contacts()} for more readable return + * + * $courses[$i]->managers = array( + * $roleassignmentid => $roleuser, + * ... + * ); + * + * where $roleuser is an stdClass with the following properties: + * + * $roleuser->raid - role assignment id + * $roleuser->id - user id + * $roleuser->username + * $roleuser->firstname + * $roleuser->lastname + * $roleuser->rolecoursealias + * $roleuser->rolename + * $roleuser->sortorder - role sortorder + * $roleuser->roleid + * $roleuser->roleshortname + * + * @todo MDL-38596 minimize number of queries to preload contacts for the list of courses + * + * @param array $courses + */ + public static function preload_course_contacts(&$courses) { + global $CFG, $DB; + if (empty($courses) || empty($CFG->coursecontact)) { + return; + } + $managerroles = explode(',', $CFG->coursecontact); + /* + // TODO MDL-38596, this commented code is similar to get_courses_wmanagers() + // It bulk-preloads course contacts for all courses BUT it does not check enrolments + + // first build the array of all context ids of the courses and their categories + $allcontexts = array(); + foreach (array_keys($courses) as $id) { + $context = context_course::instance($id); + $courses[$id]->managers = array(); + foreach (preg_split('|/|', $context->path, 0, PREG_SPLIT_NO_EMPTY) as $ctxid) { + if (!isset($allcontexts[$ctxid])) { + $allcontexts[$ctxid] = array(); + } + $allcontexts[$ctxid][] = $id; + } + } + + list($sql1, $params1) = $DB->get_in_or_equal(array_keys($allcontexts), SQL_PARAMS_NAMED, 'ctxid'); + list($sql2, $params2) = $DB->get_in_or_equal($managerroles, SQL_PARAMS_NAMED, 'rid'); + list($sort, $sortparams) = users_order_by_sql('u'); + $sql = "SELECT ra.contextid, ra.id AS raid, + r.id AS roleid, r.name AS rolename, r.shortname AS roleshortname, + rn.name AS rolecoursealias, u.id, u.username, u.firstname, u.lastname + FROM {role_assignments} ra + JOIN {user} u ON ra.userid = u.id + JOIN {role} r ON ra.roleid = r.id + LEFT JOIN {role_names} rn ON (rn.contextid = ra.contextid AND rn.roleid = r.id) + WHERE ra.contextid ". $sql1." AND ra.roleid ". $sql2." + ORDER BY r.sortorder, $sort"; + $rs = $DB->get_recordset_sql($sql, $params1 + $params2 + $sortparams); + foreach($rs as $ra) { + foreach ($allcontexts[$ra->contextid] as $id) { + $courses[$id]->managers[$ra->raid] = $ra; + } + } + $rs->close(); + */ + list($sort, $sortparams) = users_order_by_sql('u'); + foreach (array_keys($courses) as $id) { + $context = context_course::instance($id); + $courses[$id]->managers = get_role_users($managerroles, $context, true, + 'ra.id AS raid, u.id, u.username, u.firstname, u.lastname, rn.name AS rolecoursealias, + r.name AS rolename, r.sortorder, r.id AS roleid, r.shortname AS roleshortname', + 'r.sortorder ASC, ' . $sort, false, '', '', '', '', $sortparams); + } + } + + /** + * Retrieves number of records from course table + * + * Not all fields are retrieved. Records are ready for preloading context + * + * @param string $whereclause + * @param array $params + * @param array $options may indicate that summary and/or coursecontacts need to be retrieved + * @param bool $checkvisibility if true, capability 'moodle/course:viewhiddencourses' will be checked + * on not visible courses + * @return array array of stdClass objects + */ + protected static function get_course_records($whereclause, $params, $options, $checkvisibility = false) { + global $DB; + $ctxselect = context_helper::get_preload_record_columns_sql('ctx'); + $fields = array('c.id', 'c.category', 'c.sortorder', + 'c.shortname', 'c.fullname', 'c.idnumber', + 'c.startdate', 'c.visible'); + if (!empty($options['summary'])) { + $fields[] = 'c.summary'; + $fields[] = 'c.summaryformat'; + } else { + $fields[] = $DB->sql_length('c.summary'). ' hassummary'; + } + $sql = "SELECT ". join(',', $fields). ", $ctxselect + FROM {course} c + JOIN {context} ctx ON c.id = ctx.instanceid AND ctx.contextlevel = :contextcourse + WHERE ". $whereclause." ORDER BY c.sortorder"; + $list = $DB->get_records_sql($sql, + array('contextcourse' => CONTEXT_COURSE) + $params); + + if ($checkvisibility) { + // Loop through all records and make sure we only return the courses accessible by user. + foreach ($list as $course) { + if (empty($course->visible)) { + // load context only if we need to check capability + context_helper::preload_from_record($course); + if (!has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) { + unset($list[$course->id]); + } + } + } + } + + // preload course contacts if necessary + if (!empty($options['coursecontacts'])) { + self::preload_course_contacts($list); + } + return $list; + } + /** * Returns array of ids of children categories that current user can not see * @@ -672,32 +803,68 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { } /** - * Compares two records. For use in uasort() + * Sorts list of records by several fields * - * @param stdClass $a - * @param stdClass $b + * @param array $records array of stdClass objects * @param array $sortfields assoc array where key is the field to sort and value is 1 for asc or -1 for desc * @return int */ - protected static function compare_records($a, $b, $sortfields) { - foreach ($sortfields as $field => $mult) { - if ($field === 'name' || $field === 'idnumber' || $field === 'path') { - // string fields - if ($cmp = strcmp($a->$field, $b->$field)) { - // TODO textlib? - return $mult * $cmp; - } - } else { - // int fields - if ($a->$field > $b->$field) { - return $mult; - } - if ($a->$field < $b->$field) { - return -$mult; + protected static function sort_records(&$records, $sortfields) { + if (empty($records)) { + return; + } + // If sorting by course display name, calculate it (it may be fullname or shortname+fullname) + if (array_key_exists('displayname', $sortfields)) { + foreach ($records as $key => $record) { + if (!isset($record->displayname)) { + $records[$key]->displayname = get_course_display_name_for_list($record); } } } - return 0; + // sorting by one field - use collatorlib + if (count($sortfields) == 1) { + $property = key($sortfields); + if (in_array($property, array('sortorder', 'id', 'visible', 'parent', 'depth'))) { + $sortflag = collatorlib::SORT_NUMERIC; + } else if (in_array($property, array('idnumber', 'displayname', 'name', 'shortname', 'fullname'))) { + $sortflag = collatorlib::SORT_STRING; + } else { + $sortflag = collatorlib::SORT_REGULAR; + } + collatorlib::asort_objects_by_property($records, $property, $sortflag); + if ($sortfields[$property] < 0) { + $records = array_reverse($records, true); + } + return; + } + // sorting by multiple fields + uasort($records, function ($a, $b) use ($sortfields) { + foreach ($sortfields as $field => $mult) { + // nulls first + if (is_null($a->$field) && !is_null($b->$field)) { + return -$mult; + } + if (is_null($b->$field) && !is_null($a->$field)) { + return $mult; + } + + if (is_string($a->$field) || is_string($b->$field)) { + // string fields + if ($cmp = strcoll($a->$field, $b->$field)) { + return $mult * $cmp; + } + } else { + // int fields + if ($a->$field > $b->$field) { + return $mult; + } + if ($a->$field < $b->$field) { + return -$mult; + } + } + } + return 0; + }); } /** @@ -755,7 +922,7 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { } else { $records = self::get_records('cc.parent = :parent', array('parent' => $this->id)); } - uasort($records, function ($a, $b) use ($sortfields) { return self::compare_records($a, $b, $sortfields); }); + self::sort_records($records, $sortfields); $sortedids = array_keys($records); } $coursecatcache->set('c'. $this->id. ':'.serialize($sortfields), $sortedids); @@ -822,6 +989,243 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { array($this->id)); } + /** + * Searches courses + * + * List of found course ids is cached for 10 minutes. Cache may be purged prior + * to this when somebody edits courses or categories, however it is very + * difficult to keep track of all possible changes that may affect list of courses. + * + * @param array $search contains search criterias, such as: + * - search - search string + * - blocklist - id of block (if we are searching for courses containing specific block0 + * - modulelist - name of module (if we are searching for courses containing specific module + * - tagid - id of tag + * @param array $options display options, same as in get_courses() except 'recursive' is ignored - search is always category-independent + * @return array + */ + public static function search_courses($search, $options = array()) { + global $DB; + $offset = !empty($options['offset']) ? $options['offset'] : 0; + $limit = !empty($options['limit']) ? $options['limit'] : null; + $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1); + + $coursecatcache = cache::make('core', 'coursecat'); + $cachekey = 's-'. serialize($search + array('sort' => $sortfields)); + $cntcachekey = 'scnt-'. serialize($search); + + $ids = $coursecatcache->get($cachekey); + if ($ids !== false) { + // we already cached last search result + $ids = array_slice($ids, $offset, $limit); + $courses = array(); + if (!empty($ids)) { + list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id'); + $records = self::get_course_records("c.id ". $sql, $params, $options); + foreach ($ids as $id) { + $courses[$id] = new course_in_list($records[$id]); + } + } + return $courses; + } + + $preloadcoursecontacts = !empty($options['coursecontacts']); + unset($options['coursecontacts']); + + if (!empty($search['search'])) { + // search courses that have specified words in their names/summaries + $searchterms = preg_split('|\s+|', trim($search['search']), 0, PREG_SPLIT_NO_EMPTY); + $searchterms = array_filter($searchterms, function ($v) { return strlen($v) > 1; } ); + $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount); + self::sort_records($courselist, $sortfields); + $coursecatcache->set($cachekey, array_keys($courselist)); + $coursecatcache->set($cntcachekey, $totalcount); + $records = array_slice($courselist, $offset, $limit, true); + } else { + if (!empty($search['blocklist'])) { + // search courses that have block with specified id + $blockname = $DB->get_field('block', 'name', array('id' => $search['blocklist'])); + $where = 'ctx.id in (SELECT distinct bi.parentcontextid FROM {block_instances} bi + WHERE bi.blockname = :blockname)'; + $params = array('blockname' => $blockname); + } else if (!empty($search['modulelist'])) { + // search courses that have module with specified name + $where = "c.id IN (SELECT DISTINCT module.course ". + "FROM {".$search['modulelist']."} module)"; + $params = array(); + } else if (!empty($search['tagid'])) { + // search courses that are tagged with the specified tag + $where = "c.id IN (SELECT t.itemid ". + "FROM {tag_instance} t WHERE t.tagid = :tagid AND t.itemtype = :itemtype)"; + $params = array('tagid' => $search['tagid'], 'itemtype' => 'course'); + } else { + debugging('No criteria is specified while searching courses', DEBUG_DEVELOPER); + return array(); + } + $courselist = self::get_course_records($where, $params, $options, true); + self::sort_records($courselist, $sortfields); + $coursecatcache->set($cachekey, array_keys($courselist)); + $coursecatcache->set($cntcachekey, count($courselist)); + $records = array_slice($courselist, $offset, $limit, true); + } + + // Preload course contacts if necessary - saves DB queries later to do it for each course separately. + if (!empty($preloadcoursecontacts)) { + self::preload_course_contacts($records); + } + $courses = array(); + foreach ($records as $record) { + $courses[$record->id] = new course_in_list($record); + } + return $courses; + } + + /** + * Returns number of courses in the search results + * + * It is recommended to call this function after {@link coursecat::search_courses()} + * and not before because only course ids are cached. Otherwise search_courses() may + * perform extra DB queries. + * + * @param array $search search criteria, see method search_courses() for more details + * @param array $options display options. They do not affect the result but + * the 'sort' property is used in cache key for storing list of course ids + * @return int + */ + public static function search_courses_count($search, $options = array()) { + $coursecatcache = cache::make('core', 'coursecat'); + $cntcachekey = 'scnt-'. serialize($search); + if (($cnt = $coursecatcache->get($cntcachekey)) === false) { + self::search_courses($search, $options); + $cnt = $coursecatcache->get($cntcachekey); + } + return $cnt; + } + + /** + * Retrieves the list of courses accessible by user + * + * Not all information is cached, try to avoid calling this method + * twice in the same request. + * + * The following fields are always retrieved: + * - id, visible, fullname, shortname, idnumber, category, sortorder + * + * If you plan to use properties/methods course_in_list::$summary and/or + * course_in_list::get_course_contacts() + * you can preload this information using appropriate 'options'. Otherwise + * they will be retrieved from DB on demand and it may end with bigger DB load. + * + * Note that method course_in_list::has_summary() will not perform additional + * DB queries even if $options['summary'] is not specified + * + * List of found course ids is cached for 10 minutes. Cache may be purged prior + * to this when somebody edits courses or categories, however it is very + * difficult to keep track of all possible changes that may affect list of courses. + * + * @param array $options options for retrieving children + * - recursive - return courses from subcategories as well. Use with care, + * this may be a huge list! + * - summary - preloads fields 'summary' and 'summaryformat' + * - coursecontacts - preloads course contacts + * - sort - list of fields to sort. Example + * array('idnumber' => 1, 'shortname' => 1, 'id' => -1) + * will sort by idnumber asc, shortname asc and id desc. + * Default: array('sortorder' => 1) + * Only cached fields may be used for sorting! + * - offset + * - limit - maximum number of children to return, 0 or null for no limit + * @return array array of instances of course_in_list + */ + public function get_courses($options = array()) { + global $DB; + $recursive = !empty($options['recursive']); + $offset = !empty($options['offset']) ? $options['offset'] : 0; + $limit = !empty($options['limit']) ? $options['limit'] : null; + $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1); + + // Check if this category is hidden. + // Also 0-category never has courses unless this is recursive call. + if (!$this->is_uservisible() || (!$this->id && !$recursive)) { + return array(); + } + + $coursecatcache = cache::make('core', 'coursecat'); + $cachekey = 'l-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : ''). + '-'. serialize($sortfields); + $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : ''); + + // check if we have already cached results + $ids = $coursecatcache->get($cachekey); + if ($ids !== false) { + // we already cached last search result and it did not expire yet + $ids = array_slice($ids, $offset, $limit); + $courses = array(); + if (!empty($ids)) { + list($sql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'id'); + $records = self::get_course_records("c.id ". $sql, $params, $options); + foreach ($ids as $id) { + $courses[$id] = new course_in_list($records[$id]); + } + } + return $courses; + } + + // retrieve list of courses in category + $where = 'c.id <> :siteid'; + $params = array('siteid' => SITEID); + if ($recursive) { + if ($this->id) { + $context = get_category_or_system_context($this->id); + $where .= ' AND ctx.path like :path'; + $params['path'] = $context->path. '/%'; + } + } else { + $where .= ' AND c.category = :categoryid'; + $params['categoryid'] = $this->id; + } + // get list of courses without preloaded coursecontacts because we don't need them for every course + $list = $this->get_course_records($where, $params, array_diff_key($options, array('coursecontacts' => 1)), true); + + // sort and cache list + self::sort_records($list, $sortfields); + $coursecatcache->set($cachekey, array_keys($list)); + $coursecatcache->set($cntcachekey, count($list)); + + // Apply offset/limit, convert to course_in_list and return. + $courses = array(); + if (isset($list)) { + if ($offset || $limit) { + $list = array_slice($list, $offset, $limit, true); + } + // Preload course contacts if necessary - saves DB queries later to do it for each course separately. + if (!empty($options['coursecontacts'])) { + self::preload_course_contacts($list); + } + foreach ($list as $record) { + $courses[$record->id] = new course_in_list($record); + } + } + return $courses; + } + + /** + * Returns number of courses visible to the user + * + * @param array $options similar to get_courses() except some options do not affect + * number of courses (i.e. sort, summary, offset, limit etc.) + * @return int + */ + public function get_courses_count($options = array()) { + $cntcachekey = 'lcnt-'. $this->id. '-'. (!empty($options['recursive']) ? 'r' : ''); + $coursecatcache = cache::make('core', 'coursecat'); + if (($cnt = $coursecatcache->get($cntcachekey)) === false) { + $this->get_courses($options); + $cnt = $coursecatcache->get($cntcachekey); + } + return $cnt; + } + /** * Returns true if user can delete current category and all its contents * @@ -1521,3 +1925,197 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { return new coursecat($record, true); } } + +/** + * Class to store information about one course in a list of courses + * + * Not all information may be retrieved when object is created but + * it will be retrieved on demand when appropriate property or method is + * called. + * + * Instances of this class are usually returned by functions + * {@link coursecat::search_courses()} + * and + * {@link coursecat::get_courses()} + * + * @package core + * @subpackage course + * @copyright 2013 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_in_list implements IteratorAggregate { + + /** @var stdClass record retrieved from DB, may have additional calculated property such as managers and hassummary */ + protected $record; + + /** @var array array of course contacts - stores result of call to get_course_contacts() */ + protected $coursecontacts; + + /** + * Creates an instance of the class from record + * + * @param stdClass $record except fields from course table it may contain + * field hassummary indicating that summary field is not empty. + * Also it is recommended to have context fields here ready for + * context preloading + */ + public function __construct(stdClass $record) { + context_instance_preload($record); + $this->record = new stdClass(); + foreach ($record as $key => $value) { + $this->record->$key = $value; + } + } + + /** + * Indicates if the course has non-empty summary field + * + * @return bool + */ + public function has_summary() { + if (isset($this->record->hassummary)) { + return !empty($this->record->hassummary); + } + if (!isset($this->record->summary)) { + // we need to retrieve summary + $this->__get('summary'); + } + return !empty($this->record->summary); + } + + /** + * Indicates if the course have course contacts to display + * + * @return bool + */ + public function has_course_contacts() { + if (!isset($this->record->managers)) { + $courses = array($this->id => &$this->record); + coursecat::preload_course_contacts($courses); + } + return !empty($this->record->managers); + } + + /** + * Returns list of course contacts (usually teachers) to display in course link + * + * Roles to display are set up in $CFG->coursecontact + * + * The result is the list of users where user id is the key and the value + * is an array with elements: + * - 'user' - object containing basic user information + * - 'role' - object containing basic role information (id, name, shortname, coursealias) + * - 'rolename' => role_get_name($role, $context, ROLENAME_ALIAS) + * - 'username' => fullname($user, $canviewfullnames) + * + * @return array + */ + public function get_course_contacts() { + global $CFG; + if (empty($CFG->coursecontact)) { + // no roles are configured to be displayed as course contacts + return array(); + } + if ($this->coursecontacts === null) { + $this->coursecontacts = array(); + $context = context_course::instance($this->id); + + if (!isset($this->record->managers)) { + // preload course contacts from DB + $courses = array($this->id => &$this->record); + coursecat::preload_course_contacts($courses); + } + + // build return array with full roles names (for this course context) and users names + $canviewfullnames = has_capability('moodle/site:viewfullnames', $context); + foreach ($this->record->managers as $ruser) { + if (isset($this->coursecontacts[$ruser->id])) { + // only display a user once with the highest sortorder role + continue; + } + $user = new stdClass(); + $user->id = $ruser->id; + $user->username = $ruser->username; + $user->firstname = $ruser->firstname; + $user->lastname = $ruser->lastname; + $role = new stdClass(); + $role->id = $ruser->roleid; + $role->name = $ruser->rolename; + $role->shortname = $ruser->roleshortname; + $role->coursealias = $ruser->rolecoursealias; + + $this->coursecontacts[$user->id] = array( + 'user' => $user, + 'role' => $role, + 'rolename' => role_get_name($role, $context, ROLENAME_ALIAS), + 'username' => fullname($user, $canviewfullnames) + ); + } + } + return $this->coursecontacts; + } + + // ====== magic methods ======= + + public function __isset($name) { + return isset($this->record->$name); + } + + /** + * Magic method to get a course property + * + * Returns any field from table course (from cache or from DB) and/or special field 'hassummary' + * + * @param string $name + * @return mixed + */ + public function __get($name) { + global $DB; + if (property_exists($this->record, $name)) { + return $this->record->$name; + } else if ($name === 'summary' || $name === 'summaryformat') { + // retrieve fields summary and summaryformat together because they are most likely to be used together + $record = $DB->get_record('course', array('id' => $this->record->id), 'summary, summaryformat', MUST_EXIST); + $this->record->summary = $record->summary; + $this->record->summaryformat = $record->summaryformat; + return $this->record->$name; + } else if (array_key_exists($name, $DB->get_columns('course'))) { + // another field from table 'course' that was not retrieved + $this->record->$name = $DB->get_field('course', $name, array('id' => $this->record->id), MUST_EXIST); + return $this->record->$name; + } + debugging('Invalid course property accessed! '.$name); + return null; + } + + /** + * ALl properties are read only, sorry. + * @param string $name + */ + public function __unset($name) { + debugging('Can not unset '.get_class($this).' instance properties!'); + } + + /** + * Magic setter method, we do not want anybody to modify properties from the outside + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) { + debugging('Can not change '.get_class($this).' instance properties!'); + } + + // ====== implementing method from interface IteratorAggregate ====== + + /** + * Create an iterator because magic vars can't be seen by 'foreach'. + * Exclude context fields + */ + public function getIterator() { + $ret = array('id' => $this->record->id); + foreach ($this->record as $property => $value) { + $ret[$property] = $value; + } + return new ArrayIterator($ret); + } +} diff --git a/lib/datalib.php b/lib/datalib.php index b70ad1ebd22..266877636b6 100644 --- a/lib/datalib.php +++ b/lib/datalib.php @@ -837,7 +837,7 @@ function get_courses_wmanagers($categoryid=0, $sort="c.sortorder ASC", $fields=a * @param int $totalcount Passed in by reference. * @return object {@link $COURSE} records */ -function get_courses_search($searchterms, $sort='fullname ASC', $page=0, $recordsperpage=50, &$totalcount) { +function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$totalcount) { global $CFG, $DB; if ($DB->sql_regex_supported()) { @@ -906,7 +906,8 @@ function get_courses_search($searchterms, $sort='fullname ASC', $page=0, $record $limitto = $limitfrom + $recordsperpage; list($ccselect, $ccjoin) = context_instance_preload_sql('c.id', CONTEXT_COURSE, 'ctx'); - $sql = "SELECT c.* $ccselect + $fields = array_diff(array_keys($DB->get_columns('course')), array('modinfo', 'sectioncache')); + $sql = "SELECT c.".join(',c.',$fields)." $ccselect FROM {course} c $ccjoin WHERE $searchcond AND c.id <> ".SITEID." @@ -914,17 +915,21 @@ function get_courses_search($searchterms, $sort='fullname ASC', $page=0, $record $rs = $DB->get_recordset_sql($sql, $params); foreach($rs as $course) { - context_instance_preload($course); - $coursecontext = context_course::instance($course->id); - if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) { - // Don't exit this loop till the end - // we need to count all the visible courses - // to update $totalcount - if ($c >= $limitfrom && $c < $limitto) { - $courses[$course->id] = $course; + if (!$course->visible) { + // preload contexts only for hidden courses or courses we need to return + context_instance_preload($course); + $coursecontext = context_course::instance($course->id); + if (!has_capability('moodle/course:viewhiddencourses', $coursecontext)) { + continue; } - $c++; } + // Don't exit this loop till the end + // we need to count all the visible courses + // to update $totalcount + if ($c >= $limitfrom && $c < $limitto) { + $courses[$course->id] = $course; + } + $c++; } $rs->close(); diff --git a/lib/tests/coursecatlib_test.php b/lib/tests/coursecatlib_test.php index aa9c157dd0a..c071edcc9ba 100644 --- a/lib/tests/coursecatlib_test.php +++ b/lib/tests/coursecatlib_test.php @@ -359,4 +359,63 @@ class coursecatlib_testcase extends advanced_testcase { $this->assertEquals(array($category2->id, $category4->id, $category6->id, $category7->id), array_keys($children)); $this->assertEquals(4, $category1->get_children_count()); } + + public function test_get_search_courses() { + $cat1 = coursecat::create(array('name' => 'Cat1')); + $cat2 = coursecat::create(array('name' => 'Cat2', 'parent' => $cat1->id)); + $c1 = $this->getDataGenerator()->create_course(array('category' => $cat1->id, 'fullname' => 'Test 3', 'summary' => ' ', 'idnumber' => 'ID3')); + $c2 = $this->getDataGenerator()->create_course(array('category' => $cat1->id, 'fullname' => 'Test 1', 'summary' => ' ', 'visible' => 0)); + $c3 = $this->getDataGenerator()->create_course(array('category' => $cat1->id, 'fullname' => 'Математика', 'summary' => ' Test ')); + $c4 = $this->getDataGenerator()->create_course(array('category' => $cat1->id, 'fullname' => 'Test 4', 'summary' => ' ', 'idnumber' => 'ID4')); + + $c5 = $this->getDataGenerator()->create_course(array('category' => $cat2->id, 'fullname' => 'Test 5', 'summary' => ' ')); + $c6 = $this->getDataGenerator()->create_course(array('category' => $cat2->id, 'fullname' => 'Дискретная Математика', 'summary' => ' ')); + $c7 = $this->getDataGenerator()->create_course(array('category' => $cat2->id, 'fullname' => 'Test 7', 'summary' => ' ', 'visible' => 0)); + $c8 = $this->getDataGenerator()->create_course(array('category' => $cat2->id, 'fullname' => 'Test 8', 'summary' => ' ')); + + // get courses in category 1 (returned visible only because user is not enrolled) global $DB; + $res = $cat1->get_courses(array('sortorder' => 1)); + $this->assertEquals(array($c4->id, $c3->id, $c1->id), array_keys($res)); // courses are added in reverse order + $this->assertEquals(3, $cat1->get_courses_count()); + + // get courses in category 1 recursively (returned visible only because user is not enrolled) + $res = $cat1->get_courses(array('recursive' => 1)); + $this->assertEquals(array($c4->id, $c3->id, $c1->id, $c8->id, $c6->id, $c5->id), array_keys($res)); + $this->assertEquals(6, $cat1->get_courses_count(array('recursive' => 1))); + + // get courses sorted by fullname + $res = $cat1->get_courses(array('sort' => array('fullname' => 1))); + $this->assertEquals(array($c1->id, $c4->id, $c3->id), array_keys($res)); + $this->assertEquals(3, $cat1->get_courses_count(array('sort' => array('fullname' => 1)))); + + // get courses sorted by fullname recursively + $res = $cat1->get_courses(array('recursive' => 1, 'sort' => array('fullname' => 1))); + $this->assertEquals(array($c1->id, $c4->id, $c5->id, $c8->id, $c6->id, $c3->id), array_keys($res)); + $this->assertEquals(6, $cat1->get_courses_count(array('recursive' => 1, 'sort' => array('fullname' => 1)))); + + // get courses sorted by fullname recursively, use offset and limit + $res = $cat1->get_courses(array('recursive' => 1, 'offset' => 1, 'limit' => 2, 'sort' => array('fullname' => -1))); + $this->assertEquals(array($c6->id, $c8->id), array_keys($res)); + // offset and limit do not affect get_courses_count() + $this->assertEquals(6, $cat1->get_courses_count(array('recursive' => 1, 'offset' => 1, 'limit' => 2, 'sort' => array('fullname' => 1)))); + + // calling get_courses_count without prior call to get_courses() + $this->assertEquals(3, $cat2->get_courses_count(array('recursive' => 1, 'sort' => array('idnumber' => 1)))); + + // search courses + + // search by text + $res = coursecat::search_courses(array('search' => 'test')); + $this->assertEquals(array($c4->id, $c3->id, $c1->id, $c8->id, $c5->id), array_keys($res)); + $this->assertEquals(5, coursecat::search_courses_count(array('search' => 'test'))); + + $res = coursecat::search_courses(array('search' => 'Математика')); + $this->assertEquals(array($c3->id, $c6->id), array_keys($res)); + $this->assertEquals(2, coursecat::search_courses_count(array('search' => 'Математика'), array())); + + $options = array('sort' => array('fullname' => 1), 'offset' => 1, 'limit' => 2); + $res = coursecat::search_courses(array('search' => 'test'), $options); + $this->assertEquals(array($c4->id, $c5->id), array_keys($res)); + $this->assertEquals(5, coursecat::search_courses_count(array('search' => 'test'), $options)); + } } \ No newline at end of file From 290af25434a02d5bd15dfae08a3abe2a956bcef9 Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Tue, 26 Mar 2013 14:09:00 +1100 Subject: [PATCH 16/17] MDL-38147 fixed small bug on changed categories cache --- lib/coursecatlib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/coursecatlib.php b/lib/coursecatlib.php index 5c10951c7db..30752cea582 100644 --- a/lib/coursecatlib.php +++ b/lib/coursecatlib.php @@ -592,10 +592,10 @@ class coursecat implements renderable, cacheable_object, IteratorAggregate { $all[$defcoursecat->id] = array(); $count++; } + $all['countall'] = $count; foreach ($all as $key => $children) { $coursecattreecache->set($key, $children); } - $coursecattreecache->set('countall', $count); if (array_key_exists($id, $all)) { return $all[$id]; } From 4e53188a4baa461d289dfe051519f7210e95f72b Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Tue, 26 Mar 2013 16:17:34 +1100 Subject: [PATCH 17/17] MDL-38147 bug fix, changed usage of non-existing function --- course/lib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/course/lib.php b/course/lib.php index e2ec83c2745..be231715d15 100644 --- a/course/lib.php +++ b/course/lib.php @@ -1242,7 +1242,7 @@ function get_course_category_tree($id = 0, $depth = 0) { $categories = array(); $categoryids = array(); foreach ($coursecat->get_children() as $child) { - $categories[] = $category = $child->get_db_record(); + $categories[] = $category = (object)convert_to_array($child); $categoryids[$category->id] = $category; if (empty($CFG->maxcategorydepth) || $depth <= $CFG->maxcategorydepth) { list($category->categories, $subcategories) = get_course_category_tree($category->id, $depth+1);