See Release Notes
Long Term Support Release
Differences Between: [Versions 401 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * A class for efficiently finds questions at random from the question bank. 19 * 20 * @package core_question 21 * @copyright 2015 The Open University 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 namespace core_question\local\bank; 26 27 /** 28 * This class efficiently finds questions at random from the question bank. 29 * 30 * You can ask for questions at random one at a time. Each time you ask, you 31 * pass a category id, and whether to pick from that category and all subcategories 32 * or just that category. 33 * 34 * The number of teams each question has been used is tracked, and we will always 35 * return a question from among those elegible that has been used the fewest times. 36 * So, if there are questions that have not been used yet in the category asked for, 37 * one of those will be returned. However, within one instantiation of this class, 38 * we will never return a given question more than once, and we will never return 39 * questions passed into the constructor as $usedquestions. 40 * 41 * @copyright 2015 The Open University 42 * @author 2021 Safat Shahin <safatshahin@catalyst-au.net> 43 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 44 */ 45 class random_question_loader { 46 /** @var \qubaid_condition which usages to consider previous attempts from. */ 47 protected $qubaids; 48 49 /** @var array qtypes that cannot be used by random questions. */ 50 protected $excludedqtypes; 51 52 /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */ 53 protected $availablequestionscache = []; 54 55 /** 56 * @var array questionid => num recent uses. Questions that have been used, 57 * but that is not yet recorded in the DB. 58 */ 59 protected $recentlyusedquestions; 60 61 /** 62 * Constructor. 63 * 64 * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question. 65 * @param array $usedquestions questionid => number of times used count. If we should allow for 66 * further existing uses of a question in addition to the ones in $qubaids. 67 */ 68 public function __construct(\qubaid_condition $qubaids, array $usedquestions = []) { 69 $this->qubaids = $qubaids; 70 $this->recentlyusedquestions = $usedquestions; 71 72 foreach (\question_bank::get_all_qtypes() as $qtype) { 73 if (!$qtype->is_usable_by_random()) { 74 $this->excludedqtypes[] = $qtype->name(); 75 } 76 } 77 } 78 79 /** 80 * Pick a question at random from the given category, from among those with the fewest uses. 81 * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected. 82 * 83 * It is up the the caller to verify that the cateogry exists. An unknown category 84 * behaves like an empty one. 85 * 86 * @param int $categoryid the id of a category in the question bank. 87 * @param bool $includesubcategories wether to pick a question from exactly 88 * that category, or that category and subcategories. 89 * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) 90 * in order to be eligible for being picked. 91 * @return int|null the id of the question picked, or null if there aren't any. 92 */ 93 public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int { 94 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 95 96 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 97 if (empty($this->availablequestionscache[$categorykey])) { 98 return null; 99 } 100 101 reset($this->availablequestionscache[$categorykey]); 102 $lowestcount = key($this->availablequestionscache[$categorykey]); 103 reset($this->availablequestionscache[$categorykey][$lowestcount]); 104 $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); 105 $this->use_question($questionid); 106 return $questionid; 107 } 108 109 /** 110 * Get the key into {@see $availablequestionscache} for this combination of options. 111 * 112 * @param int $categoryid the id of a category in the question bank. 113 * @param bool $includesubcategories wether to pick a question from exactly 114 * that category, or that category and subcategories. 115 * @param array $tagids an array of tag ids. 116 * @return string the cache key. 117 */ 118 protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string { 119 if ($includesubcategories) { 120 $key = $categoryid . '|1'; 121 } else { 122 $key = $categoryid . '|0'; 123 } 124 125 if (!empty($tagids)) { 126 $key .= '|' . implode('|', $tagids); 127 } 128 129 return $key; 130 } 131 132 /** 133 * Populate {@see $availablequestionscache} for this combination of options. 134 * 135 * @param int $categoryid The id of a category in the question bank. 136 * @param bool $includesubcategories Whether to pick a question from exactly 137 * that category, or that category and subcategories. 138 * @param array $tagids An array of tag ids. If an array is provided, then 139 * only the questions that are tagged with ALL the provided tagids will be loaded. 140 */ 141 protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void { 142 global $DB; 143 144 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 145 146 if (isset($this->availablequestionscache[$categorykey])) { 147 // Data is already in the cache, nothing to do. 148 return; 149 } 150 151 // Load the available questions from the question bank. 152 if ($includesubcategories) { 153 $categoryids = question_categorylist($categoryid); 154 } else { 155 $categoryids = [$categoryid]; 156 } 157 158 list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, 159 SQL_PARAMS_NAMED, 'excludedqtype', false); 160 161 $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( 162 $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); 163 if (!$questionidsandcounts) { 164 // No questions in this category. 165 $this->availablequestionscache[$categorykey] = []; 166 return; 167 } 168 169 // Put all the questions with each value of $prevusecount in separate arrays. 170 $idsbyusecount = []; 171 foreach ($questionidsandcounts as $questionid => $prevusecount) { 172 if (isset($this->recentlyusedquestions[$questionid])) { 173 // Recently used questions are never returned. 174 continue; 175 } 176 $idsbyusecount[$prevusecount][] = $questionid; 177 } 178 179 // Now put that data into our cache. For each count, we need to shuffle 180 // questionids, and make those the keys of an array. 181 $this->availablequestionscache[$categorykey] = []; 182 foreach ($idsbyusecount as $prevusecount => $questionids) { 183 shuffle($questionids); 184 $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( 185 $questionids, array_fill(0, count($questionids), 1)); 186 } 187 ksort($this->availablequestionscache[$categorykey]); 188 } 189 190 /** 191 * Update the internal data structures to indicate that a given question has 192 * been used one more time. 193 * 194 * @param int $questionid the question that is being used. 195 */ 196 protected function use_question($questionid): void { 197 if (isset($this->recentlyusedquestions[$questionid])) { 198 $this->recentlyusedquestions[$questionid] += 1; 199 } else { 200 $this->recentlyusedquestions[$questionid] = 1; 201 } 202 203 foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { 204 foreach ($questionsforcategory as $numuses => $questionids) { 205 if (!isset($questionids[$questionid])) { 206 continue; 207 } 208 unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); 209 if (empty($this->availablequestionscache[$categorykey][$numuses])) { 210 unset($this->availablequestionscache[$categorykey][$numuses]); 211 } 212 } 213 } 214 } 215 216 /** 217 * Get the list of available question ids for the given criteria. 218 * 219 * @param int $categoryid The id of a category in the question bank. 220 * @param bool $includesubcategories Whether to pick a question from exactly 221 * that category, or that category and subcategories. 222 * @param array $tagids An array of tag ids. If an array is provided, then 223 * only the questions that are tagged with ALL the provided tagids will be loaded. 224 * @return int[] The list of question ids 225 */ 226 protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array { 227 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 228 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 229 $cachedvalues = $this->availablequestionscache[$categorykey]; 230 $questionids = []; 231 232 foreach ($cachedvalues as $usecount => $ids) { 233 $questionids = array_merge($questionids, array_keys($ids)); 234 } 235 236 return $questionids; 237 } 238 239 /** 240 * Check whether a given question is available in a given category. If so, mark it used. 241 * If an optional list of tag ids are provided, then the question must be tagged with 242 * ALL of the provided tags to be considered as available. 243 * 244 * @param int $categoryid the id of a category in the question bank. 245 * @param bool $includesubcategories wether to pick a question from exactly 246 * that category, or that category and subcategories. 247 * @param int $questionid the question that is being used. 248 * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available. 249 * @return bool whether the question is available in the requested category. 250 */ 251 public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool { 252 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 253 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 254 255 foreach ($this->availablequestionscache[$categorykey] as $questionids) { 256 if (isset($questionids[$questionid])) { 257 $this->use_question($questionid); 258 return true; 259 } 260 } 261 262 return false; 263 } 264 265 /** 266 * Get the list of available questions for the given criteria. 267 * 268 * @param int $categoryid The id of a category in the question bank. 269 * @param bool $includesubcategories Whether to pick a question from exactly 270 * that category, or that category and subcategories. 271 * @param array $tagids An array of tag ids. If an array is provided, then 272 * only the questions that are tagged with ALL the provided tagids will be loaded. 273 * @param int $limit Maximum number of results to return. 274 * @param int $offset Number of items to skip from the begging of the result set. 275 * @param string[] $fields The fields to return for each question. 276 * @return \stdClass[] The list of question records 277 */ 278 public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) { 279 global $DB; 280 281 $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); 282 if (empty($questionids)) { 283 return []; 284 } 285 286 if (empty($fields)) { 287 // Return all fields. 288 $fieldsstring = '*'; 289 } else { 290 $fieldsstring = implode(',', $fields); 291 } 292 293 // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). 294 $hasquestions = false; 295 if (!empty($questionids)) { 296 $hasquestions = true; 297 } 298 if ($hasquestions) { 299 list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); 300 $condition = 'WHERE q.id ' . $condition; 301 $sql = "SELECT {$fieldsstring} 302 FROM (SELECT q.*, qbe.questioncategoryid as category 303 FROM {question} q 304 JOIN {question_versions} qv ON qv.questionid = q.id 305 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 306 {$condition}) q ORDER BY q.id"; 307 308 return $DB->get_records_sql($sql, $param, $offset, $limit); 309 } else { 310 return []; 311 } 312 } 313 314 /** 315 * Count the number of available questions for the given criteria. 316 * 317 * @param int $categoryid The id of a category in the question bank. 318 * @param bool $includesubcategories Whether to pick a question from exactly 319 * that category, or that category and subcategories. 320 * @param array $tagids An array of tag ids. If an array is provided, then 321 * only the questions that are tagged with ALL the provided tagids will be loaded. 322 * @return int The number of questions matching the criteria. 323 */ 324 public function count_questions($categoryid, $includesubcategories, $tagids = []): int { 325 $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); 326 return count($questionids); 327 } 328 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body