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\bank; 26 27 28 /** 29 * This class efficiently finds questions at random from the question bank. 30 * 31 * You can ask for questions at random one at a time. Each time you ask, you 32 * pass a category id, and whether to pick from that category and all subcategories 33 * or just that category. 34 * 35 * The number of teams each question has been used is tracked, and we will always 36 * return a question from among those elegible that has been used the fewest times. 37 * So, if there are questions that have not been used yet in the category asked for, 38 * one of those will be returned. However, within one instantiation of this class, 39 * we will never return a given question more than once, and we will never return 40 * questions passed into the constructor as $usedquestions. 41 * 42 * @copyright 2015 The Open University 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 = array(); 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 * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question. 64 * @param array $usedquestions questionid => number of times used count. If we should allow for 65 * further existing uses of a question in addition to the ones in $qubaids. 66 */ 67 public function __construct(\qubaid_condition $qubaids, array $usedquestions = array()) { 68 $this->qubaids = $qubaids; 69 $this->recentlyusedquestions = $usedquestions; 70 71 foreach (\question_bank::get_all_qtypes() as $qtype) { 72 if (!$qtype->is_usable_by_random()) { 73 $this->excludedqtypes[] = $qtype->name(); 74 } 75 } 76 } 77 78 /** 79 * Pick a question at random from the given category, from among those with the fewest uses. 80 * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected. 81 * 82 * It is up the the caller to verify that the cateogry exists. An unknown category 83 * behaves like an empty one. 84 * 85 * @param int $categoryid the id of a category in the question bank. 86 * @param bool $includesubcategories wether to pick a question from exactly 87 * that category, or that category and subcategories. 88 * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) 89 * in order to be eligible for being picked. 90 * @return int|null the id of the question picked, or null if there aren't any. 91 */ 92 public function get_next_question_id($categoryid, $includesubcategories, $tagids = []) { 93 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 94 95 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 96 if (empty($this->availablequestionscache[$categorykey])) { 97 return null; 98 } 99 100 reset($this->availablequestionscache[$categorykey]); 101 $lowestcount = key($this->availablequestionscache[$categorykey]); 102 reset($this->availablequestionscache[$categorykey][$lowestcount]); 103 $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); 104 $this->use_question($questionid); 105 return $questionid; 106 } 107 108 /** 109 * Get the key into {@link $availablequestionscache} for this combination of options. 110 * @param int $categoryid the id of a category in the question bank. 111 * @param bool $includesubcategories wether to pick a question from exactly 112 * that category, or that category and subcategories. 113 * @param array $tagids an array of tag ids. 114 * @return string the cache key. 115 */ 116 protected function get_category_key($categoryid, $includesubcategories, $tagids = []) { 117 if ($includesubcategories) { 118 $key = $categoryid . '|1'; 119 } else { 120 $key = $categoryid . '|0'; 121 } 122 123 if (!empty($tagids)) { 124 $key .= '|' . implode('|', $tagids); 125 } 126 127 return $key; 128 } 129 130 /** 131 * Populate {@link $availablequestionscache} for this combination of options. 132 * @param int $categoryid The id of a category in the question bank. 133 * @param bool $includesubcategories Whether to pick a question from exactly 134 * that category, or that category and subcategories. 135 * @param array $tagids An array of tag ids. If an array is provided, then 136 * only the questions that are tagged with ALL the provided tagids will be loaded. 137 */ 138 protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []) { 139 global $DB; 140 141 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 142 143 if (isset($this->availablequestionscache[$categorykey])) { 144 // Data is already in the cache, nothing to do. 145 return; 146 } 147 148 // Load the available questions from the question bank. 149 if ($includesubcategories) { 150 $categoryids = question_categorylist($categoryid); 151 } else { 152 $categoryids = array($categoryid); 153 } 154 155 list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, 156 SQL_PARAMS_NAMED, 'excludedqtype', false); 157 158 $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( 159 $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); 160 if (!$questionidsandcounts) { 161 // No questions in this category. 162 $this->availablequestionscache[$categorykey] = array(); 163 return; 164 } 165 166 // Put all the questions with each value of $prevusecount in separate arrays. 167 $idsbyusecount = array(); 168 foreach ($questionidsandcounts as $questionid => $prevusecount) { 169 if (isset($this->recentlyusedquestions[$questionid])) { 170 // Recently used questions are never returned. 171 continue; 172 } 173 $idsbyusecount[$prevusecount][] = $questionid; 174 } 175 176 // Now put that data into our cache. For each count, we need to shuffle 177 // questionids, and make those the keys of an array. 178 $this->availablequestionscache[$categorykey] = array(); 179 foreach ($idsbyusecount as $prevusecount => $questionids) { 180 shuffle($questionids); 181 $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( 182 $questionids, array_fill(0, count($questionids), 1)); 183 } 184 ksort($this->availablequestionscache[$categorykey]); 185 } 186 187 /** 188 * Update the internal data structures to indicate that a given question has 189 * been used one more time. 190 * 191 * @param int $questionid the question that is being used. 192 */ 193 protected function use_question($questionid) { 194 if (isset($this->recentlyusedquestions[$questionid])) { 195 $this->recentlyusedquestions[$questionid] += 1; 196 } else { 197 $this->recentlyusedquestions[$questionid] = 1; 198 } 199 200 foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { 201 foreach ($questionsforcategory as $numuses => $questionids) { 202 if (!isset($questionids[$questionid])) { 203 continue; 204 } 205 unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); 206 if (empty($this->availablequestionscache[$categorykey][$numuses])) { 207 unset($this->availablequestionscache[$categorykey][$numuses]); 208 } 209 } 210 } 211 } 212 213 /** 214 * Get the list of available question ids for the given criteria. 215 * 216 * @param int $categoryid The id of a category in the question bank. 217 * @param bool $includesubcategories Whether to pick a question from exactly 218 * that category, or that category and subcategories. 219 * @param array $tagids An array of tag ids. If an array is provided, then 220 * only the questions that are tagged with ALL the provided tagids will be loaded. 221 * @return int[] The list of question ids 222 */ 223 protected function get_question_ids($categoryid, $includesubcategories, $tagids = []) { 224 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 225 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 226 $cachedvalues = $this->availablequestionscache[$categorykey]; 227 $questionids = []; 228 229 foreach ($cachedvalues as $usecount => $ids) { 230 $questionids = array_merge($questionids, array_keys($ids)); 231 } 232 233 return $questionids; 234 } 235 236 /** 237 * Check whether a given question is available in a given category. If so, mark it used. 238 * If an optional list of tag ids are provided, then the question must be tagged with 239 * ALL of the provided tags to be considered as available. 240 * 241 * @param int $categoryid the id of a category in the question bank. 242 * @param bool $includesubcategories wether to pick a question from exactly 243 * that category, or that category and subcategories. 244 * @param int $questionid the question that is being used. 245 * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available. 246 * @return bool whether the question is available in the requested category. 247 */ 248 public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []) { 249 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 250 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 251 252 foreach ($this->availablequestionscache[$categorykey] as $questionids) { 253 if (isset($questionids[$questionid])) { 254 $this->use_question($questionid); 255 return true; 256 } 257 } 258 259 return false; 260 } 261 262 /** 263 * Get the list of available questions for the given criteria. 264 * 265 * @param int $categoryid The id of a category in the question bank. 266 * @param bool $includesubcategories Whether to pick a question from exactly 267 * that category, or that category and subcategories. 268 * @param array $tagids An array of tag ids. If an array is provided, then 269 * only the questions that are tagged with ALL the provided tagids will be loaded. 270 * @param int $limit Maximum number of results to return. 271 * @param int $offset Number of items to skip from the begging of the result set. 272 * @param string[] $fields The fields to return for each question. 273 * @return \stdClass[] The list of question records 274 */ 275 public function get_questions( 276 $categoryid, 277 $includesubcategories, 278 $tagids = [], 279 $limit = 100, 280 $offset = 0, 281 $fields = [] 282 ) { 283 global $DB; 284 285 $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); 286 if (empty($questionids)) { 287 return []; 288 } 289 290 if (empty($fields)) { 291 // Return all fields. 292 $fieldsstring = '*'; 293 } else { 294 $fieldsstring = implode(',', $fields); 295 } 296 297 return $DB->get_records_list( 298 'question', 299 'id', 300 $questionids, 301 'id', 302 $fieldsstring, 303 $offset, 304 $limit 305 ); 306 } 307 308 /** 309 * Count the number of available questions for the given criteria. 310 * 311 * @param int $categoryid The id of a category in the question bank. 312 * @param bool $includesubcategories Whether to pick a question from exactly 313 * that category, or that category and subcategories. 314 * @param array $tagids An array of tag ids. If an array is provided, then 315 * only the questions that are tagged with ALL the provided tagids will be loaded. 316 * @return int The number of questions matching the criteria. 317 */ 318 public function count_questions($categoryid, $includesubcategories, $tagids = []) { 319 $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); 320 return count($questionids); 321 } 322 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body