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] [Versions 400 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   * The questiontype class for the multiple choice question type.
  19   *
  20   * @package    qtype
  21   * @subpackage multichoice
  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  global $CFG;
  30  require_once($CFG->libdir . '/questionlib.php');
  31  
  32  
  33  /**
  34   * The multiple choice question type.
  35   *
  36   * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class qtype_multichoice extends question_type {
  40      public function get_question_options($question) {
  41          global $DB, $OUTPUT;
  42  
  43          $question->options = $DB->get_record('qtype_multichoice_options', ['questionid' => $question->id]);
  44  
  45          if ($question->options === false) {
  46              // If this has happened, then we have a problem.
  47              // For the user to be able to edit or delete this question, we need options.
  48              debugging("Question ID {$question->id} was missing an options record. Using default.", DEBUG_DEVELOPER);
  49  
  50              $question->options = $this->create_default_options($question);
  51          }
  52  
  53          parent::get_question_options($question);
  54      }
  55  
  56      /**
  57       * Create a default options object for the provided question.
  58       *
  59       * @param object $question The queston we are working with.
  60       * @return object The options object.
  61       */
  62      protected function create_default_options($question) {
  63          // Create a default question options record.
  64          $options = new stdClass();
  65          $options->questionid = $question->id;
  66  
  67          // Get the default strings and just set the format.
  68          $options->correctfeedback = get_string('correctfeedbackdefault', 'question');
  69          $options->correctfeedbackformat = FORMAT_HTML;
  70          $options->partiallycorrectfeedback = get_string('partiallycorrectfeedbackdefault', 'question');;
  71          $options->partiallycorrectfeedbackformat = FORMAT_HTML;
  72          $options->incorrectfeedback = get_string('incorrectfeedbackdefault', 'question');
  73          $options->incorrectfeedbackformat = FORMAT_HTML;
  74  
  75          $config = get_config('qtype_multichoice');
  76          $options->single = $config->answerhowmany;
  77          if (isset($question->layout)) {
  78              $options->layout = $question->layout;
  79          }
  80          $options->answernumbering = $config->answernumbering;
  81          $options->shuffleanswers = $config->shuffleanswers;
  82          $options->showstandardinstruction = 0;
  83          $options->shownumcorrect = 1;
  84  
  85          return $options;
  86      }
  87  
  88      public function save_defaults_for_new_questions(stdClass $fromform): void {
  89          parent::save_defaults_for_new_questions($fromform);
  90          $this->set_default_value('single', $fromform->single);
  91          $this->set_default_value('shuffleanswers', $fromform->shuffleanswers);
  92          $this->set_default_value('answernumbering', $fromform->answernumbering);
  93          $this->set_default_value('showstandardinstruction', $fromform->showstandardinstruction);
  94      }
  95  
  96      public function save_question_options($question) {
  97          global $DB;
  98          $context = $question->context;
  99          $result = new stdClass();
 100  
 101          $oldanswers = $DB->get_records('question_answers',
 102                  array('question' => $question->id), 'id ASC');
 103  
 104          // Following hack to check at least two answers exist.
 105          $answercount = 0;
 106          foreach ($question->answer as $key => $answer) {
 107              if ($answer != '') {
 108                  $answercount++;
 109              }
 110          }
 111          if ($answercount < 2) { // Check there are at lest 2 answers for multiple choice.
 112              $result->error = get_string('notenoughanswers', 'qtype_multichoice', '2');
 113              return $result;
 114          }
 115  
 116          // Insert all the new answers.
 117          $totalfraction = 0;
 118          $maxfraction = -1;
 119          foreach ($question->answer as $key => $answerdata) {
 120              if (trim($answerdata['text']) == '') {
 121                  continue;
 122              }
 123  
 124              // Update an existing answer if possible.
 125              $answer = array_shift($oldanswers);
 126              if (!$answer) {
 127                  $answer = new stdClass();
 128                  $answer->question = $question->id;
 129                  $answer->answer = '';
 130                  $answer->feedback = '';
 131                  $answer->id = $DB->insert_record('question_answers', $answer);
 132              }
 133  
 134              // Doing an import.
 135              $answer->answer = $this->import_or_save_files($answerdata,
 136                      $context, 'question', 'answer', $answer->id);
 137              $answer->answerformat = $answerdata['format'];
 138              $answer->fraction = $question->fraction[$key];
 139              $answer->feedback = $this->import_or_save_files($question->feedback[$key],
 140                      $context, 'question', 'answerfeedback', $answer->id);
 141              $answer->feedbackformat = $question->feedback[$key]['format'];
 142  
 143              $DB->update_record('question_answers', $answer);
 144  
 145              if ($question->fraction[$key] > 0) {
 146                  $totalfraction += $question->fraction[$key];
 147              }
 148              if ($question->fraction[$key] > $maxfraction) {
 149                  $maxfraction = $question->fraction[$key];
 150              }
 151          }
 152  
 153          // Delete any left over old answer records.
 154          $fs = get_file_storage();
 155          foreach ($oldanswers as $oldanswer) {
 156              $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
 157              $DB->delete_records('question_answers', array('id' => $oldanswer->id));
 158          }
 159  
 160          $options = $DB->get_record('qtype_multichoice_options', array('questionid' => $question->id));
 161          if (!$options) {
 162              $options = new stdClass();
 163              $options->questionid = $question->id;
 164              $options->correctfeedback = '';
 165              $options->partiallycorrectfeedback = '';
 166              $options->incorrectfeedback = '';
 167              $options->showstandardinstruction = 0;
 168              $options->id = $DB->insert_record('qtype_multichoice_options', $options);
 169          }
 170  
 171          $options->single = $question->single;
 172          if (isset($question->layout)) {
 173              $options->layout = $question->layout;
 174          }
 175          $options->answernumbering = $question->answernumbering;
 176          $options->shuffleanswers = $question->shuffleanswers;
 177          $options->showstandardinstruction = !empty($question->showstandardinstruction);
 178          $options = $this->save_combined_feedback_helper($options, $question, $context, true);
 179          $DB->update_record('qtype_multichoice_options', $options);
 180  
 181          $this->save_hints($question, true);
 182  
 183          // Perform sanity checks on fractional grades.
 184          if ($options->single) {
 185              if ($maxfraction != 1) {
 186                  $result->noticeyesno = get_string('fractionsnomax', 'qtype_multichoice',
 187                          $maxfraction * 100);
 188                  return $result;
 189              }
 190          } else {
 191              $totalfraction = round($totalfraction, 2);
 192              if ($totalfraction != 1) {
 193                  $result->noticeyesno = get_string('fractionsaddwrong', 'qtype_multichoice',
 194                          $totalfraction * 100);
 195                  return $result;
 196              }
 197          }
 198      }
 199  
 200      protected function make_question_instance($questiondata) {
 201          question_bank::load_question_definition_classes($this->name());
 202          if ($questiondata->options->single) {
 203              $class = 'qtype_multichoice_single_question';
 204          } else {
 205              $class = 'qtype_multichoice_multi_question';
 206          }
 207          return new $class();
 208      }
 209  
 210      protected function make_hint($hint) {
 211          return question_hint_with_parts::load_from_record($hint);
 212      }
 213  
 214      protected function initialise_question_instance(question_definition $question, $questiondata) {
 215          parent::initialise_question_instance($question, $questiondata);
 216          $question->shuffleanswers = $questiondata->options->shuffleanswers;
 217          $question->answernumbering = $questiondata->options->answernumbering;
 218          $question->showstandardinstruction = $questiondata->options->showstandardinstruction;
 219          if (!empty($questiondata->options->layout)) {
 220              $question->layout = $questiondata->options->layout;
 221          } else {
 222              $question->layout = qtype_multichoice_single_question::LAYOUT_VERTICAL;
 223          }
 224          $this->initialise_combined_feedback($question, $questiondata, true);
 225  
 226          $this->initialise_question_answers($question, $questiondata, false);
 227      }
 228  
 229      public function make_answer($answer) {
 230          // Overridden just so we can make it public for use by question.php.
 231          return parent::make_answer($answer);
 232      }
 233  
 234      public function delete_question($questionid, $contextid) {
 235          global $DB;
 236          $DB->delete_records('qtype_multichoice_options', array('questionid' => $questionid));
 237  
 238          parent::delete_question($questionid, $contextid);
 239      }
 240  
 241      public function get_random_guess_score($questiondata) {
 242          if (!$questiondata->options->single) {
 243              // Pretty much impossible to compute for _multi questions. Don't try.
 244              return null;
 245          }
 246  
 247          if (empty($questiondata->options->answers)) {
 248              // A multi-choice question with no choices is senseless,
 249              // but, seemingly, it can happen (presumably as a side-effect of bugs).
 250              // Therefore, ensure it does not lead to errors here.
 251              return null;
 252          }
 253  
 254          // Single choice questions - average choice fraction.
 255          $totalfraction = 0;
 256          foreach ($questiondata->options->answers as $answer) {
 257              $totalfraction += $answer->fraction;
 258          }
 259          return $totalfraction / count($questiondata->options->answers);
 260      }
 261  
 262      public function get_possible_responses($questiondata) {
 263          if ($questiondata->options->single) {
 264              $responses = array();
 265  
 266              foreach ($questiondata->options->answers as $aid => $answer) {
 267                  $responses[$aid] = new question_possible_response(
 268                          question_utils::to_plain_text($answer->answer, $answer->answerformat),
 269                          $answer->fraction);
 270              }
 271  
 272              $responses[null] = question_possible_response::no_response();
 273              return array($questiondata->id => $responses);
 274          } else {
 275              $parts = array();
 276  
 277              foreach ($questiondata->options->answers as $aid => $answer) {
 278                  $parts[$aid] = array($aid => new question_possible_response(
 279                          question_utils::to_plain_text($answer->answer, $answer->answerformat),
 280                          $answer->fraction));
 281              }
 282  
 283              return $parts;
 284          }
 285      }
 286  
 287      /**
 288       * @return array of the numbering styles supported. For each one, there
 289       *      should be a lang string answernumberingxxx in teh qtype_multichoice
 290       *      language file, and a case in the switch statement in number_in_style,
 291       *      and it should be listed in the definition of this column in install.xml.
 292       */
 293      public static function get_numbering_styles() {
 294          $styles = array();
 295          foreach (array('abc', 'ABCD', '123', 'iii', 'IIII', 'none') as $numberingoption) {
 296              $styles[$numberingoption] =
 297                      get_string('answernumbering' . $numberingoption, 'qtype_multichoice');
 298          }
 299          return $styles;
 300      }
 301  
 302      public function move_files($questionid, $oldcontextid, $newcontextid) {
 303          parent::move_files($questionid, $oldcontextid, $newcontextid);
 304          $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid, true);
 305          $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid);
 306          $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
 307      }
 308  
 309      protected function delete_files($questionid, $contextid) {
 310          parent::delete_files($questionid, $contextid);
 311          $this->delete_files_in_answers($questionid, $contextid, true);
 312          $this->delete_files_in_combined_feedback($questionid, $contextid);
 313          $this->delete_files_in_hints($questionid, $contextid);
 314      }
 315  }