Differences Between: [Versions 400 and 403] [Versions 401 and 403] [Versions 402 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 random question based on filter conditions 81 * 82 * @param array $filters filter array 83 * @return int|null 84 */ 85 public function get_next_filtered_question_id(array $filters): ?int { 86 $this->ensure_filtered_questions_loaded($filters); 87 88 $key = $this->get_filtered_questions_key($filters); 89 if (empty($this->availablequestionscache[$key])) { 90 return null; 91 } 92 93 reset($this->availablequestionscache[$key]); 94 $lowestcount = key($this->availablequestionscache[$key]); 95 reset($this->availablequestionscache[$key][$lowestcount]); 96 $questionid = key($this->availablequestionscache[$key][$lowestcount]); 97 $this->use_question($questionid); 98 return $questionid; 99 } 100 101 102 /** 103 * Pick a question at random from the given category, from among those with the fewest uses. 104 * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected. 105 * 106 * It is up the the caller to verify that the cateogry exists. An unknown category 107 * behaves like an empty one. 108 * 109 * @param int $categoryid the id of a category in the question bank. 110 * @param bool $includesubcategories wether to pick a question from exactly 111 * that category, or that category and subcategories. 112 * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) 113 * in order to be eligible for being picked. 114 * @return int|null the id of the question picked, or null if there aren't any. 115 * @deprecated since Moodle 4.3 116 * @todo Final deprecation on Moodle 4.7 MDL-78091 117 */ 118 public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int { 119 debugging( 120 'Function get_next_question_id() is deprecated, please use get_next_filtered_question_id() instead.', 121 DEBUG_DEVELOPER 122 ); 123 124 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 125 126 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 127 if (empty($this->availablequestionscache[$categorykey])) { 128 return null; 129 } 130 131 reset($this->availablequestionscache[$categorykey]); 132 $lowestcount = key($this->availablequestionscache[$categorykey]); 133 reset($this->availablequestionscache[$categorykey][$lowestcount]); 134 $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); 135 $this->use_question($questionid); 136 return $questionid; 137 } 138 139 /** 140 * Key for filtered questions. 141 * This function replace get_category_key 142 * 143 * @param array $filters filter array 144 * @return String 145 */ 146 protected function get_filtered_questions_key(array $filters): String { 147 return sha1(json_encode($filters)); 148 } 149 150 /** 151 * Get the key into {@see $availablequestionscache} for this combination of options. 152 * 153 * @param int $categoryid the id of a category in the question bank. 154 * @param bool $includesubcategories wether to pick a question from exactly 155 * that category, or that category and subcategories. 156 * @param array $tagids an array of tag ids. 157 * @return string the cache key. 158 * 159 * @deprecated since Moodle 4.3 160 * @todo Final deprecation on Moodle 4.7 MDL-78091 161 */ 162 protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string { 163 debugging( 164 'Function get_category_key() is deprecated, please get_fitlered_questions_key instead.', 165 DEBUG_DEVELOPER 166 ); 167 if ($includesubcategories) { 168 $key = $categoryid . '|1'; 169 } else { 170 $key = $categoryid . '|0'; 171 } 172 173 if (!empty($tagids)) { 174 $key .= '|' . implode('|', $tagids); 175 } 176 177 return $key; 178 } 179 180 /** 181 * Populate {@see $availablequestionscache} according to filter conditions. 182 * 183 * @param array $filters filter array 184 * @return void 185 */ 186 protected function ensure_filtered_questions_loaded(array $filters) { 187 global $DB; 188 189 $key = $this->get_filtered_questions_key($filters); 190 if (isset($this->availablequestionscache[$key])) { 191 // Data is already in the cache, nothing to do. 192 return; 193 } 194 195 [$extraconditions, $extraparams] = $DB->get_in_or_equal($this->excludedqtypes, 196 SQL_PARAMS_NAMED, 'excludedqtype', false); 197 198 $previoussql = "SELECT COUNT(1) 199 FROM " . $this->qubaids->from_question_attempts('qa') . " 200 WHERE qa.questionid = q.id AND " . $this->qubaids->where(); 201 $previousparams = $this->qubaids->from_where_params(); 202 203 // Latest version. 204 $latestversionsql = "SELECT MAX(v.version) 205 FROM {question_versions} v 206 JOIN {question_bank_entries} be ON be.id = v.questionbankentryid 207 WHERE be.id = qbe.id"; 208 209 $sql = "SELECT q.id, ($previoussql) AS previous_attempts 210 FROM {question} q 211 JOIN {question_versions} qv ON qv.questionid = q.id 212 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 213 WHERE "; 214 215 $where = [ 216 'q.parent = :noparent', 217 'qv.status = :ready', 218 "qv.version = ($latestversionsql)", 219 ]; 220 $params = array_merge( 221 $previousparams, 222 ['noparent' => 0, 'ready' => question_version_status::QUESTION_STATUS_READY]); 223 224 // Get current enabled condition classes. 225 $conditionclasses = \core_question\local\bank\filter_condition_manager::get_condition_classes(); 226 // Build filter conditions. 227 foreach ($conditionclasses as $conditionclass) { 228 $filter = $conditionclass::get_filter_from_list($filters); 229 if (is_null($filter)) { 230 continue; 231 } 232 [$filterwhere, $filterparams] = $conditionclass::build_query_from_filter($filter); 233 if (!empty($filterwhere)) { 234 $where[] = '(' . $filterwhere . ')'; 235 } 236 if (!empty($filterparams)) { 237 $params = array_merge($params, $filterparams); 238 } 239 } 240 241 // Extra conditions. 242 if ($extraconditions) { 243 $where[] = 'q.qtype ' . $extraconditions; 244 $params = array_merge($params, $extraparams); 245 } 246 247 // Build query. 248 $sql .= implode(' AND ', $where); 249 $sql .= "ORDER BY previous_attempts"; 250 251 $questionidsandcounts = $DB->get_records_sql_menu($sql, $params); 252 253 if (!$questionidsandcounts) { 254 // No questions in this category. 255 $this->availablequestionscache[$key] = []; 256 return; 257 } 258 259 // Put all the questions with each value of $prevusecount in separate arrays. 260 $idsbyusecount = []; 261 foreach ($questionidsandcounts as $questionid => $prevusecount) { 262 if (isset($this->recentlyusedquestions[$questionid])) { 263 // Recently used questions are never returned. 264 continue; 265 } 266 $idsbyusecount[$prevusecount][] = $questionid; 267 } 268 269 // Now put that data into our cache. For each count, we need to shuffle 270 // questionids, and make those the keys of an array. 271 $this->availablequestionscache[$key] = []; 272 foreach ($idsbyusecount as $prevusecount => $questionids) { 273 shuffle($questionids); 274 $this->availablequestionscache[$key][$prevusecount] = array_combine( 275 $questionids, array_fill(0, count($questionids), 1)); 276 } 277 ksort($this->availablequestionscache[$key]); 278 } 279 280 /** 281 * Populate {@see $availablequestionscache} for this combination of options. 282 * 283 * @param int $categoryid The id of a category in the question bank. 284 * @param bool $includesubcategories Whether to pick a question from exactly 285 * that category, or that category and subcategories. 286 * @param array $tagids An array of tag ids. If an array is provided, then 287 * only the questions that are tagged with ALL the provided tagids will be loaded. 288 * @deprecated since Moodle 4.3 289 * @todo Final deprecation on Moodle 4.7 MDL-78091 290 */ 291 protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void { 292 debugging( 293 'Function ensure_questions_for_category_loaded() is deprecated, please use the function ' . 294 'ensure_filtered_questions_loaded.', 295 DEBUG_DEVELOPER 296 ); 297 298 global $DB; 299 300 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 301 302 if (isset($this->availablequestionscache[$categorykey])) { 303 // Data is already in the cache, nothing to do. 304 return; 305 } 306 307 // Load the available questions from the question bank. 308 if ($includesubcategories) { 309 $categoryids = question_categorylist($categoryid); 310 } else { 311 $categoryids = [$categoryid]; 312 } 313 314 list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, 315 SQL_PARAMS_NAMED, 'excludedqtype', false); 316 317 $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( 318 $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); 319 if (!$questionidsandcounts) { 320 // No questions in this category. 321 $this->availablequestionscache[$categorykey] = []; 322 return; 323 } 324 325 // Put all the questions with each value of $prevusecount in separate arrays. 326 $idsbyusecount = []; 327 foreach ($questionidsandcounts as $questionid => $prevusecount) { 328 if (isset($this->recentlyusedquestions[$questionid])) { 329 // Recently used questions are never returned. 330 continue; 331 } 332 $idsbyusecount[$prevusecount][] = $questionid; 333 } 334 335 // Now put that data into our cache. For each count, we need to shuffle 336 // questionids, and make those the keys of an array. 337 $this->availablequestionscache[$categorykey] = []; 338 foreach ($idsbyusecount as $prevusecount => $questionids) { 339 shuffle($questionids); 340 $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( 341 $questionids, array_fill(0, count($questionids), 1)); 342 } 343 ksort($this->availablequestionscache[$categorykey]); 344 } 345 346 /** 347 * Update the internal data structures to indicate that a given question has 348 * been used one more time. 349 * 350 * @param int $questionid the question that is being used. 351 */ 352 protected function use_question($questionid): void { 353 if (isset($this->recentlyusedquestions[$questionid])) { 354 $this->recentlyusedquestions[$questionid] += 1; 355 } else { 356 $this->recentlyusedquestions[$questionid] = 1; 357 } 358 359 foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { 360 foreach ($questionsforcategory as $numuses => $questionids) { 361 if (!isset($questionids[$questionid])) { 362 continue; 363 } 364 unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); 365 if (empty($this->availablequestionscache[$categorykey][$numuses])) { 366 unset($this->availablequestionscache[$categorykey][$numuses]); 367 } 368 } 369 } 370 } 371 372 /** 373 * Get filtered questions. 374 * 375 * @param array $filters filter array 376 * @return array list of filtered questions 377 */ 378 protected function get_filtered_question_ids(array $filters): array { 379 $this->ensure_filtered_questions_loaded($filters); 380 $key = $this->get_filtered_questions_key($filters); 381 382 $cachedvalues = $this->availablequestionscache[$key]; 383 $questionids = []; 384 385 foreach ($cachedvalues as $usecount => $ids) { 386 $questionids = array_merge($questionids, array_keys($ids)); 387 } 388 389 return $questionids; 390 } 391 392 /** 393 * Get the list of available question ids for the given criteria. 394 * 395 * @param int $categoryid The id of a category in the question bank. 396 * @param bool $includesubcategories Whether to pick a question from exactly 397 * that category, or that category and subcategories. 398 * @param array $tagids An array of tag ids. If an array is provided, then 399 * only the questions that are tagged with ALL the provided tagids will be loaded. 400 * @return int[] The list of question ids 401 * @deprecated since Moodle 4.3 402 * @todo Final deprecation on Moodle 4.7 MDL-78091 403 */ 404 protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array { 405 debugging( 406 'Function get_question_ids() is deprecated, please use get_filtered_question_ids() instead.', 407 DEBUG_DEVELOPER 408 ); 409 410 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 411 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 412 $cachedvalues = $this->availablequestionscache[$categorykey]; 413 $questionids = []; 414 415 foreach ($cachedvalues as $usecount => $ids) { 416 $questionids = array_merge($questionids, array_keys($ids)); 417 } 418 419 return $questionids; 420 } 421 422 /** 423 * Check whether a given question is available in a given category. If so, mark it used. 424 * If an optional list of tag ids are provided, then the question must be tagged with 425 * ALL of the provided tags to be considered as available. 426 * 427 * @param array $filters filter array 428 * @param int $questionid the question that is being used. 429 * @return bool whether the question is available in the requested category. 430 */ 431 public function is_filtered_question_available(array $filters, int $questionid): bool { 432 $this->ensure_filtered_questions_loaded($filters); 433 $categorykey = $this->get_filtered_questions_key($filters); 434 435 foreach ($this->availablequestionscache[$categorykey] as $questionids) { 436 if (isset($questionids[$questionid])) { 437 $this->use_question($questionid); 438 return true; 439 } 440 } 441 442 return false; 443 } 444 445 /** 446 * Check whether a given question is available in a given category. If so, mark it used. 447 * If an optional list of tag ids are provided, then the question must be tagged with 448 * ALL of the provided tags to be considered as available. 449 * 450 * @param int $categoryid the id of a category in the question bank. 451 * @param bool $includesubcategories wether to pick a question from exactly 452 * that category, or that category and subcategories. 453 * @param int $questionid the question that is being used. 454 * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available. 455 * @return bool whether the question is available in the requested category. 456 * @deprecated since Moodle 4.3 457 * @todo Final deprecation on Moodle 4.7 MDL-78091 458 */ 459 public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool { 460 debugging( 461 'Function is_question_available() is deprecated, please use is_filtered_question_available() instead.', 462 DEBUG_DEVELOPER 463 ); 464 $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); 465 $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); 466 467 foreach ($this->availablequestionscache[$categorykey] as $questionids) { 468 if (isset($questionids[$questionid])) { 469 $this->use_question($questionid); 470 return true; 471 } 472 } 473 474 return false; 475 } 476 477 /** 478 * Get the list of available questions for the given criteria. 479 * 480 * @param array $filters filter array 481 * @param int $limit Maximum number of results to return. 482 * @param int $offset Number of items to skip from the begging of the result set. 483 * @param string[] $fields The fields to return for each question. 484 * @return \stdClass[] The list of question records 485 */ 486 public function get_filtered_questions($filters, $limit = 100, $offset = 0, $fields = []) { 487 global $DB; 488 489 $questionids = $this->get_filtered_question_ids($filters); 490 491 if (empty($questionids)) { 492 return []; 493 } 494 495 if (empty($fields)) { 496 // Return all fields. 497 $fieldsstring = '*'; 498 } else { 499 $fieldsstring = implode(',', $fields); 500 } 501 502 // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). 503 $hasquestions = false; 504 if (!empty($questionids)) { 505 $hasquestions = true; 506 } 507 if ($hasquestions) { 508 [$condition, $param] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); 509 $condition = 'WHERE q.id ' . $condition; 510 $sql = "SELECT {$fieldsstring} 511 FROM (SELECT q.*, qbe.questioncategoryid as category 512 FROM {question} q 513 JOIN {question_versions} qv ON qv.questionid = q.id 514 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 515 {$condition}) q"; 516 517 return $DB->get_records_sql($sql, $param, $offset, $limit); 518 } else { 519 return []; 520 } 521 } 522 523 /** 524 * Get the list of available questions for the given criteria. 525 * 526 * @param int $categoryid The id of a category in the question bank. 527 * @param bool $includesubcategories Whether to pick a question from exactly 528 * that category, or that category and subcategories. 529 * @param array $tagids An array of tag ids. If an array is provided, then 530 * only the questions that are tagged with ALL the provided tagids will be loaded. 531 * @param int $limit Maximum number of results to return. 532 * @param int $offset Number of items to skip from the begging of the result set. 533 * @param string[] $fields The fields to return for each question. 534 * @return \stdClass[] The list of question records 535 * @deprecated since Moodle 4.3 536 * @todo Final deprecation on Moodle 4.7 MDL-78091 537 */ 538 public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) { 539 debugging( 540 'Function get_questions() is deprecated, please use get_filtered_questions() instead.', 541 DEBUG_DEVELOPER 542 ); 543 global $DB; 544 545 $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); 546 if (empty($questionids)) { 547 return []; 548 } 549 550 if (empty($fields)) { 551 // Return all fields. 552 $fieldsstring = '*'; 553 } else { 554 $fieldsstring = implode(',', $fields); 555 } 556 557 // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). 558 $hasquestions = false; 559 if (!empty($questionids)) { 560 $hasquestions = true; 561 } 562 if ($hasquestions) { 563 list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); 564 $condition = 'WHERE q.id ' . $condition; 565 $sql = "SELECT {$fieldsstring} 566 FROM (SELECT q.*, qbe.questioncategoryid as category 567 FROM {question} q 568 JOIN {question_versions} qv ON qv.questionid = q.id 569 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid 570 {$condition}) q ORDER BY q.id"; 571 572 return $DB->get_records_sql($sql, $param, $offset, $limit); 573 } else { 574 return []; 575 } 576 } 577 578 /** 579 * Count number of filtered questions 580 * 581 * @param array $filters filter array 582 * @return int number of question 583 */ 584 public function count_filtered_questions(array $filters): int { 585 $questionids = $this->get_filtered_question_ids($filters); 586 return count($questionids); 587 } 588 589 /** 590 * Count the number of available questions for the given criteria. 591 * 592 * @param int $categoryid The id of a category in the question bank. 593 * @param bool $includesubcategories Whether to pick a question from exactly 594 * that category, or that category and subcategories. 595 * @param array $tagids An array of tag ids. If an array is provided, then 596 * only the questions that are tagged with ALL the provided tagids will be loaded. 597 * @return int The number of questions matching the criteria. 598 * @deprecated since Moodle 4.3 599 * @todo Final deprecation on Moodle 4.7 MDL-78091 600 */ 601 public function count_questions($categoryid, $includesubcategories, $tagids = []): int { 602 debugging( 603 'Function count_questions() is deprecated, please use count_filtered_questions() instead.', 604 DEBUG_DEVELOPER 605 ); 606 $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); 607 return count($questionids); 608 } 609 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body