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  namespace mod_quiz\question\bank;
  18  
  19  use core_question\local\bank\question_version_status;
  20  use core_question\local\bank\random_question_loader;
  21  use core_question\question_reference_manager;
  22  use qbank_tagquestion\tag_condition;
  23  use qubaid_condition;
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  require_once($CFG->dirroot . '/question/engine/bank.php');
  28  
  29  /**
  30   * Helper class for question bank and its associated data.
  31   *
  32   * @package    mod_quiz
  33   * @category   question
  34   * @copyright  2021 Catalyst IT Australia Pty Ltd
  35   * @author     Safat Shahin <safatshahin@catalyst-au.net>
  36   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class qbank_helper {
  39  
  40      /**
  41       * Get the available versions of a question where one of the version has the given question id.
  42       *
  43       * @param int $questionid id of a question.
  44       * @return \stdClass[] other versions of this question. Each object has fields versionid,
  45       *       version and questionid. Array is returned most recent version first.
  46       */
  47      public static function get_version_options(int $questionid): array {
  48          global $DB;
  49  
  50          return $DB->get_records_sql("
  51                  SELECT allversions.id AS versionid,
  52                         allversions.version,
  53                         allversions.questionid
  54  
  55                    FROM {question_versions} allversions
  56  
  57                   WHERE allversions.questionbankentryid = (
  58                              SELECT givenversion.questionbankentryid
  59                                FROM {question_versions} givenversion
  60                               WHERE givenversion.questionid = ?
  61                         )
  62                     AND allversions.status <> ?
  63  
  64                ORDER BY allversions.version DESC
  65                ", [$questionid, question_version_status::QUESTION_STATUS_DRAFT]);
  66      }
  67  
  68      /**
  69       * Get the information about which questions should be used to create a quiz attempt.
  70       *
  71       * Each element in the returned array is indexed by slot.slot (slot number) an each object hass:
  72       * - All the field of the slot table.
  73       * - contextid for where the question(s) come from.
  74       * - category id for where the questions come from.
  75       * - For non-random questions, All the fields of the question table (but id is in questionid).
  76       *   Also question version and question bankentryid.
  77       * - For random questions, filtercondition, which is also unpacked into category, randomrecurse,
  78       *   randomtags, and note that these also have a ->name set and ->qtype set to 'random'.
  79       *
  80       * @param int $quizid the id of the quiz to load the data for.
  81       * @param \context_module $quizcontext the context of this quiz.
  82       * @param int|null $slotid optional, if passed only load the data for this one slot (if it is in this quiz).
  83       * @return array indexed by slot, with information about the content of each slot.
  84       */
  85      public static function get_question_structure(int $quizid, \context_module $quizcontext,
  86              int $slotid = null): array {
  87          global $DB;
  88  
  89          $params = [
  90              'draft' => question_version_status::QUESTION_STATUS_DRAFT,
  91              'quizcontextid' => $quizcontext->id,
  92              'quizcontextid2' => $quizcontext->id,
  93              'quizcontextid3' => $quizcontext->id,
  94              'quizid' => $quizid,
  95              'quizid2' => $quizid,
  96          ];
  97          $slotidtest = '';
  98          $slotidtest2 = '';
  99          if ($slotid !== null) {
 100              $params['slotid'] = $slotid;
 101              $params['slotid2'] = $slotid;
 102              $slotidtest = ' AND slot.id = :slotid';
 103              $slotidtest2 = ' AND lslot.id = :slotid2';
 104          }
 105  
 106          // Load all the data about each slot.
 107          $slotdata = $DB->get_records_sql("
 108                  SELECT slot.slot,
 109                         slot.id AS slotid,
 110                         slot.page,
 111                         slot.maxmark,
 112                         slot.displaynumber,
 113                         slot.requireprevious,
 114                         qsr.filtercondition,
 115                         qsr.usingcontextid,
 116                         qv.status,
 117                         qv.id AS versionid,
 118                         qv.version,
 119                         qr.version AS requestedversion,
 120                         qv.questionbankentryid,
 121                         q.id AS questionid,
 122                         q.*,
 123                         qc.id AS category,
 124                         COALESCE(qc.contextid, qsr.questionscontextid) AS contextid
 125  
 126                    FROM {quiz_slots} slot
 127  
 128               -- case where a particular question has been added to the quiz.
 129               LEFT JOIN {question_references} qr ON qr.usingcontextid = :quizcontextid AND qr.component = 'mod_quiz'
 130                                          AND qr.questionarea = 'slot' AND qr.itemid = slot.id
 131               LEFT JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid
 132  
 133               -- This way of getting the latest version for each slot is a bit more complicated
 134               -- than we would like, but the simpler SQL did not work in Oracle 11.2.
 135               -- (It did work fine in Oracle 19.x, so once we have updated our min supported
 136               -- version we could consider digging the old code out of git history from
 137               -- just before the commit that added this comment.
 138               -- For relevant question_bank_entries, this gets the latest non-draft slot number.
 139               LEFT JOIN (
 140                     SELECT lv.questionbankentryid,
 141                            MAX(CASE WHEN lv.status <> :draft THEN lv.version END) AS usableversion,
 142                            MAX(lv.version) AS anyversion
 143                       FROM {quiz_slots} lslot
 144                       JOIN {question_references} lqr ON lqr.usingcontextid = :quizcontextid2 AND lqr.component = 'mod_quiz'
 145                                          AND lqr.questionarea = 'slot' AND lqr.itemid = lslot.id
 146                       JOIN {question_versions} lv ON lv.questionbankentryid = lqr.questionbankentryid
 147                      WHERE lslot.quizid = :quizid2
 148                            $slotidtest2
 149                        AND lqr.version IS NULL
 150                   GROUP BY lv.questionbankentryid
 151               ) latestversions ON latestversions.questionbankentryid = qr.questionbankentryid
 152  
 153               LEFT JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
 154                                         -- Either specified version, or latest usable version, or a draft version.
 155                                         AND qv.version = COALESCE(qr.version,
 156                                             latestversions.usableversion,
 157                                             latestversions.anyversion)
 158               LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
 159               LEFT JOIN {question} q ON q.id = qv.questionid
 160  
 161               -- Case where a random question has been added.
 162               LEFT JOIN {question_set_references} qsr ON qsr.usingcontextid = :quizcontextid3 AND qsr.component = 'mod_quiz'
 163                                          AND qsr.questionarea = 'slot' AND qsr.itemid = slot.id
 164  
 165                   WHERE slot.quizid = :quizid
 166                         $slotidtest
 167  
 168                ORDER BY slot.slot
 169                ", $params);
 170  
 171          // Unpack the random info from question_set_reference.
 172          foreach ($slotdata as $slot) {
 173              // Ensure the right id is the id.
 174              $slot->id = $slot->slotid;
 175  
 176              if ($slot->filtercondition) {
 177                  // Unpack the information about a random question.
 178                  $slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
 179                  $filter = json_decode($slot->filtercondition, true);
 180                  $slot->filtercondition = question_reference_manager::convert_legacy_set_reference_filter_condition($filter);
 181  
 182                  $slot->category = $slot->filtercondition['filter']['category']['values'][0] ?? 0;
 183  
 184                  $slot->qtype = 'random';
 185                  $slot->name = get_string('random', 'quiz');
 186                  $slot->length = 1;
 187              } else if ($slot->qtype === null) {
 188                  // This question must have gone missing. Put in a placeholder.
 189                  $slot->questionid = 's' . $slot->id; // Sometimes this is used as an array key, so needs to be unique.
 190                  $slot->category = 0;
 191                  $slot->qtype = 'missingtype';
 192                  $slot->name = get_string('missingquestion', 'quiz');
 193                  $slot->questiontext = ' ';
 194                  $slot->questiontextformat = FORMAT_HTML;
 195                  $slot->length = 1;
 196              } else if (!\question_bank::qtype_exists($slot->qtype)) {
 197                  // Question of unknown type found in the database. Set to placeholder question types instead.
 198                  $slot->qtype = 'missingtype';
 199              } else {
 200                  $slot->_partiallyloaded = 1;
 201              }
 202          }
 203  
 204          return $slotdata;
 205      }
 206  
 207      /**
 208       * Get this list of random selection tag ids from one of the slots returned by get_question_structure.
 209       *
 210       * @param \stdClass $slotdata one of the array elements returned by get_question_structure.
 211       * @return array list of tag ids.
 212       */
 213      public static function get_tag_ids_for_slot(\stdClass $slotdata): array {
 214          $tagids = [];
 215          if (!isset($slotdata->filtercondition['filter'])) {
 216              return $tagids;
 217          }
 218          $filter = $slotdata->filtercondition['filter'];
 219          if (isset($filter['qtagids'])) {
 220              $tagids = $filter['qtagids']['values'];
 221          }
 222          return $tagids;
 223      }
 224  
 225      /**
 226       * Given a slot from the array returned by get_question_structure, describe the random question it represents.
 227       *
 228       * @param \stdClass $slotdata one of the array elements returned by get_question_structure.
 229       * @return string that can be used to display the random slot.
 230       */
 231      public static function describe_random_question(\stdClass $slotdata): string {
 232          $qtagids = self::get_tag_ids_for_slot($slotdata);
 233  
 234          if ($qtagids) {
 235              $tagnames = [];
 236              $tags = \core_tag_tag::get_bulk($qtagids, 'id, name');
 237              foreach ($tags as $tag) {
 238                  $tagnames[] = $tag->name;
 239              }
 240              $description = get_string('randomqnametags', 'mod_quiz', implode(",", $tagnames));
 241          } else {
 242              $description = get_string('randomqname', 'mod_quiz');
 243          }
 244          return shorten_text($description, 255);
 245      }
 246  
 247      /**
 248       * Choose question for redo in a particular slot.
 249       *
 250       * @param int $quizid the id of the quiz to load the data for.
 251       * @param \context_module $quizcontext the context of this quiz.
 252       * @param int $slotid optional, if passed only load the data for this one slot (if it is in this quiz).
 253       * @param qubaid_condition $qubaids attempts to consider when avoiding picking repeats of random questions.
 254       * @return int the id of the question to use.
 255       */
 256      public static function choose_question_for_redo(int $quizid, \context_module $quizcontext,
 257              int $slotid, qubaid_condition $qubaids): int {
 258          $slotdata = self::get_question_structure($quizid, $quizcontext, $slotid);
 259          $slotdata = reset($slotdata);
 260  
 261          // Non-random question.
 262          if ($slotdata->qtype != 'random') {
 263              return $slotdata->questionid;
 264          }
 265  
 266          // Random question.
 267          $randomloader = new random_question_loader($qubaids, []);
 268          $fitlercondition = $slotdata->filtercondition;
 269          $filter = $fitlercondition['filter'] ?? [];
 270          $newqusetionid = $randomloader->get_next_filtered_question_id($filter);
 271  
 272          if ($newqusetionid === null) {
 273              throw new \moodle_exception('notenoughrandomquestions', 'quiz');
 274          }
 275          return $newqusetionid;
 276      }
 277  }