Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
   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  }