Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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  }