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 310 and 401] [Versions 311 and 401] [Versions 39 and 401]

   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   * Question type class for the random question type.
  19   *
  20   * @package    qtype
  21   * @subpackage random
  22   * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  require_once($CFG->dirroot . '/question/type/questiontypebase.php');
  30  
  31  
  32  /**
  33   * The random question type.
  34   *
  35   * This question type does not have a question definition class, nor any
  36   * renderers. When you load a question of this type, it actually loads a
  37   * question chosen randomly from a particular category in the question bank.
  38   *
  39   * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
  40   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  41   */
  42  class qtype_random extends question_type {
  43      /** @var string comma-separated list of qytpe names not to select, can be used in SQL. */
  44      protected $excludedqtypes = null;
  45  
  46      /** @var string comma-separated list of manually graded qytpe names, can be used in SQL. */
  47      protected $manualqtypes = null;
  48  
  49      /**
  50       * Cache of availabe question ids from a particular category.
  51       * @var array two-dimensional array. The first key is a category id, the
  52       * second key is wether subcategories should be included.
  53       */
  54      private $availablequestionsbycategory = array();
  55  
  56      public function menu_name() {
  57          // Don't include this question type in the 'add new question' menu.
  58          return false;
  59      }
  60  
  61      public function is_manual_graded() {
  62          return true;
  63      }
  64  
  65      public function is_usable_by_random() {
  66          return false;
  67      }
  68  
  69      public function is_question_manual_graded($question, $otherquestionsinuse) {
  70          global $DB;
  71          // We take our best shot at working whether a particular question is manually
  72          // graded follows: We look to see if any of the questions that this random
  73          // question might select if of a manually graded type. If a category contains
  74          // a mixture of manual and non-manual questions, and if all the attempts so
  75          // far selected non-manual ones, this will give the wrong answer, but we
  76          // don't care. Even so, this is an expensive calculation!
  77          $this->init_qtype_lists();
  78          if (!$this->manualqtypes) {
  79              return false;
  80          }
  81          if ($question->questiontext) {
  82              $categorylist = question_categorylist($question->category);
  83          } else {
  84              $categorylist = array($question->category);
  85          }
  86          list($qcsql, $qcparams) = $DB->get_in_or_equal($categorylist);
  87          // TODO use in_or_equal for $otherquestionsinuse and $this->manualqtypes.
  88  
  89          $readystatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY;
  90          $sql = "SELECT q.*
  91                    FROM {question} q
  92                    JOIN {question_versions} qv ON qv.questionid = q.id
  93                    JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
  94                   WHERE qbe.questioncategoryid {$qcsql}
  95                         AND q.parent = 0
  96                         AND qv.status = '$readystatus'
  97                         AND q.id NOT IN ($otherquestionsinuse)
  98                         AND q.qtype IN ($this->manualqtypes)";
  99  
 100          return $DB->record_exists_sql($sql, $qcparams);
 101      }
 102  
 103      /**
 104       * This method needs to be called before the ->excludedqtypes and
 105       *      ->manualqtypes fields can be used.
 106       */
 107      protected function init_qtype_lists() {
 108          if (!is_null($this->excludedqtypes)) {
 109              return; // Already done.
 110          }
 111          $excludedqtypes = array();
 112          $manualqtypes = array();
 113          foreach (question_bank::get_all_qtypes() as $qtype) {
 114              $quotedname = "'" . $qtype->name() . "'";
 115              if (!$qtype->is_usable_by_random()) {
 116                  $excludedqtypes[] = $quotedname;
 117              } else if ($qtype->is_manual_graded()) {
 118                  $manualqtypes[] = $quotedname;
 119              }
 120          }
 121          $this->excludedqtypes = implode(',', $excludedqtypes);
 122          $this->manualqtypes = implode(',', $manualqtypes);
 123      }
 124  
 125      public function get_question_options($question) {
 126          parent::get_question_options($question);
 127          return true;
 128      }
 129  
 130      /**
 131       * Random questions always get a question name that is Random (cateogryname).
 132       * This function is a centralised place to calculate that, given the category.
 133       * @param stdClass $category the category this question picks from. (->parent, ->name & ->contextid are used.)
 134       * @param bool $includesubcategories whether this question also picks from subcategories.
 135       * @param string[] $tagnames Name of tags this question picks from.
 136       * @return string the name this question should have.
 137       */
 138      public function question_name($category, $includesubcategories, $tagnames = []) {
 139          $categoryname = '';
 140          if ($category->parent && $includesubcategories) {
 141              $stringid = 'randomqplusname';
 142              $categoryname = shorten_text($category->name, 100);
 143          } else if ($category->parent) {
 144              $stringid = 'randomqname';
 145              $categoryname = shorten_text($category->name, 100);
 146          } else if ($includesubcategories) {
 147              $context = context::instance_by_id($category->contextid);
 148  
 149              switch ($context->contextlevel) {
 150                  case CONTEXT_MODULE:
 151                      $stringid = 'randomqplusnamemodule';
 152                      break;
 153                  case CONTEXT_COURSE:
 154                      $stringid = 'randomqplusnamecourse';
 155                      break;
 156                  case CONTEXT_COURSECAT:
 157                      $stringid = 'randomqplusnamecoursecat';
 158                      $categoryname = shorten_text($context->get_context_name(false), 100);
 159                      break;
 160                  case CONTEXT_SYSTEM:
 161                      $stringid = 'randomqplusnamesystem';
 162                      break;
 163                  default: // Impossible.
 164              }
 165          } else {
 166              // No question will ever be selected. So, let's warn the teacher.
 167              $stringid = 'randomqnamefromtop';
 168          }
 169  
 170          if ($tagnames) {
 171              $stringid .= 'tags';
 172              $a = new stdClass();
 173              if ($categoryname) {
 174                  $a->category = $categoryname;
 175              }
 176              $a->tags = implode(', ', array_map(function($tagname) {
 177                  return explode(',', $tagname)[1];
 178              }, $tagnames));
 179          } else {
 180              $a = $categoryname ? : null;
 181          }
 182  
 183          $name = get_string($stringid, 'qtype_random', $a);
 184  
 185          return shorten_text($name, 255);
 186      }
 187  
 188      protected function set_selected_question_name($question, $randomname) {
 189          $a = new stdClass();
 190          $a->randomname = $randomname;
 191          $a->questionname = $question->name;
 192          $question->name = get_string('selectedby', 'qtype_random', $a);
 193      }
 194  
 195      public function save_question($question, $form) {
 196          global $DB;
 197  
 198          $form->name = '';
 199          list($category) = explode(',', $form->category);
 200  
 201          if (!$form->includesubcategories) {
 202              if ($DB->record_exists('question_categories', ['id' => $category, 'parent' => 0])) {
 203                  // The chosen category is a top category.
 204                  $form->includesubcategories = true;
 205              }
 206          }
 207  
 208          $form->tags = array();
 209  
 210          if (empty($form->fromtags)) {
 211              $form->fromtags = array();
 212          }
 213  
 214          $form->questiontext = array(
 215              'text'   => $form->includesubcategories ? '1' : '0',
 216              'format' => 0
 217          );
 218  
 219          // Name is not a required field for random questions, but
 220          // parent::save_question Assumes that it is.
 221          return parent::save_question($question, $form);
 222      }
 223  
 224      public function save_question_options($question) {
 225          global $DB;
 226  
 227          // No options, as such, but we set the parent field to the question's
 228          // own id. Setting the parent field has the effect of hiding this
 229          // question in various places.
 230          $updateobject = new stdClass();
 231          $updateobject->id = $question->id;
 232          $updateobject->parent = $question->id;
 233  
 234          // We also force the question name to be 'Random (categoryname)'.
 235          $category = $DB->get_record('question_categories',
 236                  array('id' => $question->category), '*', MUST_EXIST);
 237          $updateobject->name = $this->question_name($category, $question->includesubcategories, $question->fromtags);
 238          return $DB->update_record('question', $updateobject);
 239      }
 240  
 241      /**
 242       * During unit tests we need to be able to reset all caches so that each new test starts in a known state.
 243       * Intended for use only for testing. This is a stop gap until we start using the MUC caching api here.
 244       * You need to call this before every test that loads one or more random questions.
 245       */
 246      public function clear_caches_before_testing() {
 247          $this->availablequestionsbycategory = array();
 248      }
 249  
 250      /**
 251       * Get all the usable questions from a particular question category.
 252       *
 253       * @param int $categoryid the id of a question category.
 254       * @param bool whether to include questions from subcategories.
 255       * @param string $questionsinuse comma-separated list of question ids to
 256       *      exclude from consideration.
 257       * @return array of question records.
 258       */
 259      public function get_available_questions_from_category($categoryid, $subcategories) {
 260          if (isset($this->availablequestionsbycategory[$categoryid][$subcategories])) {
 261              return $this->availablequestionsbycategory[$categoryid][$subcategories];
 262          }
 263  
 264          $this->init_qtype_lists();
 265          if ($subcategories) {
 266              $categoryids = question_categorylist($categoryid);
 267          } else {
 268              $categoryids = array($categoryid);
 269          }
 270  
 271          $questionids = question_bank::get_finder()->get_questions_from_categories(
 272                  $categoryids, 'qtype NOT IN (' . $this->excludedqtypes . ')');
 273          $this->availablequestionsbycategory[$categoryid][$subcategories] = $questionids;
 274          return $questionids;
 275      }
 276  
 277      public function make_question($questiondata) {
 278          return $this->choose_other_question($questiondata, array());
 279      }
 280  
 281      /**
 282       * Load the definition of another question picked randomly by this question.
 283       * @param object       $questiondata the data defining a random question.
 284       * @param array        $excludedquestions of question ids. We will no pick any question whose id is in this list.
 285       * @param bool         $allowshuffle      if false, then any shuffle option on the selected quetsion is disabled.
 286       * @param null|integer $forcequestionid   if not null then force the picking of question with id $forcequestionid.
 287       * @throws coding_exception
 288       * @return question_definition|null the definition of the question that was
 289       *      selected, or null if no suitable question could be found.
 290       */
 291      public function choose_other_question($questiondata, $excludedquestions, $allowshuffle = true, $forcequestionid = null) {
 292          $available = $this->get_available_questions_from_category($questiondata->category,
 293                  !empty($questiondata->questiontext));
 294          shuffle($available);
 295  
 296          if ($forcequestionid !== null) {
 297              $forcedquestionkey = array_search($forcequestionid, $available);
 298              if ($forcedquestionkey !== false) {
 299                  unset($available[$forcedquestionkey]);
 300                  array_unshift($available, $forcequestionid);
 301              } else {
 302                  throw new coding_exception('thisquestionidisnotavailable', $forcequestionid);
 303              }
 304          }
 305  
 306          foreach ($available as $questionid) {
 307              if (in_array($questionid, $excludedquestions)) {
 308                  continue;
 309              }
 310  
 311              $question = question_bank::load_question($questionid, $allowshuffle);
 312              $this->set_selected_question_name($question, $questiondata->name);
 313              return $question;
 314          }
 315          return null;
 316      }
 317  
 318      public function get_random_guess_score($questiondata) {
 319          return null;
 320      }
 321  }