Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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  }