See Release Notes
Long Term Support Release
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 namespace qbank_managecategories; 18 19 use context; 20 use core_question\local\bank\question_version_status; 21 use moodle_exception; 22 use html_writer; 23 24 /** 25 * Class helper contains all the library functions. 26 * 27 * Library functions used by qbank_managecategories. 28 * This code is based on lib/questionlib.php by Martin Dougiamas. 29 * 30 * @package qbank_managecategories 31 * @copyright 2021 Catalyst IT Australia Pty Ltd 32 * @author Guillermo Gomez Arias <guillermogomez@catalyst-au.net> 33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 34 */ 35 class helper { 36 37 /** 38 * Name of this plugin. 39 */ 40 const PLUGINNAME = 'qbank_managecategories'; 41 42 /** 43 * Remove stale questions from a category. 44 * 45 * While questions should not be left behind when they are not used any more, 46 * it does happen, maybe via restore, or old logic, or uncovered scenarios. When 47 * this happens, the users are unable to delete the question category unless 48 * they move those stale questions to another one category, but to them the 49 * category is empty as it does not contain anything. The purpose of this function 50 * is to detect the questions that may have gone stale and remove them. 51 * 52 * You will typically use this prior to checking if the category contains questions. 53 * 54 * The stale questions (unused and hidden to the user) handled are: 55 * - hidden questions 56 * - random questions 57 * 58 * @param int $categoryid The category ID. 59 * @throws \dml_exception 60 */ 61 public static function question_remove_stale_questions_from_category(int $categoryid): void { 62 global $DB; 63 64 $sql = "SELECT q.id 65 FROM {question} q 66 JOIN {question_versions} qv ON qv.questionid = q.id 67 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 68 WHERE qbe.questioncategoryid = :categoryid 69 AND (q.qtype = :qtype OR qv.status = :status)"; 70 71 $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => question_version_status::QUESTION_STATUS_HIDDEN]; 72 $questions = $DB->get_records_sql($sql, $params); 73 foreach ($questions as $question) { 74 // The function question_delete_question does not delete questions in use. 75 question_delete_question($question->id); 76 } 77 } 78 79 /** 80 * Checks whether this is the only child of a top category in a context. 81 * 82 * @param int $categoryid a category id. 83 * @return bool 84 * @throws \dml_exception 85 */ 86 public static function question_is_only_child_of_top_category_in_context(int $categoryid): bool { 87 global $DB; 88 return 1 == $DB->count_records_sql(" 89 SELECT count(*) 90 FROM {question_categories} c 91 JOIN {question_categories} p ON c.parent = p.id 92 JOIN {question_categories} s ON s.parent = c.parent 93 WHERE c.id = ? AND p.parent = 0", [$categoryid]); 94 } 95 96 /** 97 * Checks whether the category is a "Top" category (with no parent). 98 * 99 * @param int $categoryid a category id. 100 * @return bool 101 * @throws \dml_exception 102 */ 103 public static function question_is_top_category(int $categoryid): bool { 104 global $DB; 105 return 0 == $DB->get_field('question_categories', 'parent', ['id' => $categoryid]); 106 } 107 108 /** 109 * Ensures that this user is allowed to delete this category. 110 * 111 * @param int $todelete a category id. 112 * @throws \required_capability_exception 113 * @throws \dml_exception|moodle_exception 114 */ 115 public static function question_can_delete_cat(int $todelete): void { 116 global $DB; 117 if (self::question_is_top_category($todelete)) { 118 throw new moodle_exception('cannotdeletetopcat', 'question'); 119 } else if (self::question_is_only_child_of_top_category_in_context($todelete)) { 120 throw new moodle_exception('cannotdeletecate', 'question'); 121 } else { 122 $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $todelete]); 123 require_capability('moodle/question:managecategory', context::instance_by_id($contextid)); 124 } 125 } 126 127 /** 128 * Only for the use of add_indented_names(). 129 * 130 * Recursively adds an indentedname field to each category, starting with the category 131 * with id $id, and dealing with that category and all its children, and 132 * return a new array, with those categories in the right order. 133 * 134 * @param array $categories an array of categories which has had childids 135 * fields added by flatten_category_tree(). Passed by reference for 136 * performance only. It is not modfied. 137 * @param int $id the category to start the indenting process from. 138 * @param int $depth the indent depth. Used in recursive calls. 139 * @param int $nochildrenof 140 * @return array a new array of categories, in the right order for the tree. 141 */ 142 public static function flatten_category_tree(array &$categories, $id, int $depth = 0, int $nochildrenof = -1): array { 143 144 // Indent the name of this category. 145 $newcategories = []; 146 $newcategories[$id] = $categories[$id]; 147 $newcategories[$id]->indentedname = str_repeat(' ', $depth) . 148 $categories[$id]->name; 149 150 // Recursively indent the children. 151 foreach ($categories[$id]->childids as $childid) { 152 if ($childid != $nochildrenof) { 153 $newcategories = $newcategories + self::flatten_category_tree( 154 $categories, $childid, $depth + 1, $nochildrenof); 155 } 156 } 157 158 // Remove the childids array that were temporarily added. 159 unset($newcategories[$id]->childids); 160 161 return $newcategories; 162 } 163 164 /** 165 * Format categories into an indented list reflecting the tree structure. 166 * 167 * @param array $categories An array of category objects, for example from the. 168 * @param int $nochildrenof 169 * @return array The formatted list of categories. 170 */ 171 public static function add_indented_names(array $categories, int $nochildrenof = -1): array { 172 173 // Add an array to each category to hold the child category ids. This array 174 // will be removed again by flatten_category_tree(). It should not be used 175 // outside these two functions. 176 foreach (array_keys($categories) as $id) { 177 $categories[$id]->childids = []; 178 } 179 180 // Build the tree structure, and record which categories are top-level. 181 // We have to be careful, because the categories array may include published 182 // categories from other courses, but not their parents. 183 $toplevelcategoryids = []; 184 foreach (array_keys($categories) as $id) { 185 if (!empty($categories[$id]->parent) && 186 array_key_exists($categories[$id]->parent, $categories)) { 187 $categories[$categories[$id]->parent]->childids[] = $id; 188 } else { 189 $toplevelcategoryids[] = $id; 190 } 191 } 192 193 // Flatten the tree to and add the indents. 194 $newcategories = []; 195 foreach ($toplevelcategoryids as $id) { 196 $newcategories = $newcategories + self::flatten_category_tree( 197 $categories, $id, 0, $nochildrenof); 198 } 199 200 return $newcategories; 201 } 202 203 /** 204 * Output a select menu of question categories. 205 * 206 * Categories from this course and (optionally) published categories from other courses 207 * are included. Optionally, only categories the current user may edit can be included. 208 * 209 * @param array $contexts 210 * @param bool $top 211 * @param int $currentcat 212 * @param string $selected optionally, the id of a category to be selected by 213 * default in the dropdown. 214 * @param int $nochildrenof 215 * @param bool $return to return the string of the select menu or echo that from the method 216 * @throws \coding_exception|\dml_exception 217 */ 218 public static function question_category_select_menu(array $contexts, bool $top = false, int $currentcat = 0, 219 string $selected = "", int $nochildrenof = -1, bool $return = false) { 220 $categoriesarray = self::question_category_options($contexts, $top, $currentcat, 221 false, $nochildrenof, false); 222 $choose = ''; 223 $options = []; 224 foreach ($categoriesarray as $group => $opts) { 225 $options[] = [$group => $opts]; 226 } 227 $outputhtml = html_writer::label(get_string('questioncategory', 'core_question'), 228 'id_movetocategory', false, ['class' => 'accesshide']); 229 $attrs = [ 230 'id' => 'id_movetocategory', 231 'class' => 'custom-select', 232 'data-action' => 'toggle', 233 'data-togglegroup' => 'qbank', 234 'data-toggle' => 'action', 235 'disabled' => false, 236 ]; 237 $outputhtml .= html_writer::select($options, 'category', $selected, $choose, $attrs); 238 if ($return) { 239 return $outputhtml; 240 } else { 241 echo $outputhtml; 242 } 243 } 244 245 /** 246 * Get all the category objects, including a count of the number of questions in that category, 247 * for all the categories in the lists $contexts. 248 * 249 * @param context $contexts 250 * @param string $sortorder used as the ORDER BY clause in the select statement. 251 * @param bool $top Whether to return the top categories or not. 252 * @param int $showallversions 1 to show all versions not only the latest. 253 * @return array of category objects. 254 * @throws \dml_exception 255 */ 256 public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC', 257 bool $top = false, int $showallversions = 0): array { 258 global $DB; 259 $topwhere = $top ? '' : 'AND c.parent <> 0'; 260 $statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " . 261 " OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )"; 262 263 $sql = "SELECT c.*, 264 (SELECT COUNT(1) 265 FROM {question} q 266 JOIN {question_versions} qv ON qv.questionid = q.id 267 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 268 WHERE q.parent = '0' 269 $statuscondition 270 AND c.id = qbe.questioncategoryid 271 AND ($showallversions = 1 272 OR (qv.version = (SELECT MAX(v.version) 273 FROM {question_versions} v 274 JOIN {question_bank_entries} be ON be.id = v.questionbankentryid 275 WHERE be.id = qbe.id) 276 ) 277 ) 278 ) AS questioncount 279 FROM {question_categories} c 280 WHERE c.contextid IN ($contexts) $topwhere 281 ORDER BY $sortorder"; 282 283 return $DB->get_records_sql($sql); 284 } 285 286 /** 287 * Output an array of question categories. 288 * 289 * @param array $contexts The list of contexts. 290 * @param bool $top Whether to return the top categories or not. 291 * @param int $currentcat 292 * @param bool $popupform 293 * @param int $nochildrenof 294 * @param bool $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. 295 * @return array 296 * @throws \coding_exception|\dml_exception 297 */ 298 public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0, 299 bool $popupform = false, int $nochildrenof = -1, 300 bool $escapecontextnames = true): array { 301 global $CFG; 302 $pcontexts = []; 303 foreach ($contexts as $context) { 304 $pcontexts[] = $context->id; 305 } 306 $contextslist = join(', ', $pcontexts); 307 308 $categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top); 309 310 if ($top) { 311 $categories = self::question_fix_top_names($categories); 312 } 313 314 $categories = self::question_add_context_in_key($categories); 315 $categories = self::add_indented_names($categories, $nochildrenof); 316 317 // Sort cats out into different contexts. 318 $categoriesarray = []; 319 foreach ($pcontexts as $contextid) { 320 $context = \context::instance_by_id($contextid); 321 $contextstring = $context->get_context_name(true, true, $escapecontextnames); 322 foreach ($categories as $category) { 323 if ($category->contextid == $contextid) { 324 $cid = $category->id; 325 if ($currentcat != $cid || $currentcat == 0) { 326 $a = new \stdClass; 327 $a->name = format_string($category->indentedname, true, 328 ['context' => $context]); 329 if ($category->idnumber !== null && $category->idnumber !== '') { 330 $a->idnumber = s($category->idnumber); 331 } 332 if (!empty($category->questioncount)) { 333 $a->questioncount = $category->questioncount; 334 } 335 if (isset($a->idnumber) && isset($a->questioncount)) { 336 $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a); 337 } else if (isset($a->idnumber)) { 338 $formattedname = get_string('categorynamewithidnumber', 'question', $a); 339 } else if (isset($a->questioncount)) { 340 $formattedname = get_string('categorynamewithcount', 'question', $a); 341 } else { 342 $formattedname = $a->name; 343 } 344 $categoriesarray[$contextstring][$cid] = $formattedname; 345 } 346 } 347 } 348 } 349 if ($popupform) { 350 $popupcats = []; 351 foreach ($categoriesarray as $contextstring => $optgroup) { 352 $group = []; 353 foreach ($optgroup as $key => $value) { 354 $key = str_replace($CFG->wwwroot, '', $key); 355 $group[$key] = $value; 356 } 357 $popupcats[] = [$contextstring => $group]; 358 } 359 return $popupcats; 360 } else { 361 return $categoriesarray; 362 } 363 } 364 365 /** 366 * Add context in categories key. 367 * 368 * @param array $categories The list of categories. 369 * @return array 370 */ 371 public static function question_add_context_in_key(array $categories): array { 372 $newcatarray = []; 373 foreach ($categories as $id => $category) { 374 $category->parent = "$category->parent,$category->contextid"; 375 $category->id = "$category->id,$category->contextid"; 376 $newcatarray["$id,$category->contextid"] = $category; 377 } 378 return $newcatarray; 379 } 380 381 /** 382 * Finds top categories in the given categories hierarchy and replace their name with a proper localised string. 383 * 384 * @param array $categories An array of question categories. 385 * @param bool $escape Whether the returned name of the thing is to be HTML escaped or not. 386 * @return array The same question category list given to the function, with the top category names being translated. 387 * @throws \coding_exception 388 */ 389 public static function question_fix_top_names(array $categories, bool $escape = true): array { 390 391 foreach ($categories as $id => $category) { 392 if ($category->parent == 0) { 393 $context = \context::instance_by_id($category->contextid); 394 $categories[$id]->name = get_string('topfor', 'question', $context->get_context_name(false, false, $escape)); 395 } 396 } 397 398 return $categories; 399 } 400 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body