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] [Versions 401 and 402] [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   * Question type class for the multi-answer question type.
  19   *
  20   * @package    qtype
  21   * @subpackage multianswer
  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  require_once($CFG->dirroot . '/question/type/multichoice/question.php');
  31  require_once($CFG->dirroot . '/question/type/numerical/questiontype.php');
  32  
  33  /**
  34   * The multi-answer question type class.
  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_multianswer extends question_type {
  40  
  41      /**
  42       * Generate a subquestion replacement question class.
  43       *
  44       * Due to a bug, subquestions can be lost (see MDL-54724). This class exists to take
  45       * the place of those lost questions so that the system can keep working and inform
  46       * the user of the corrupted data.
  47       *
  48       * @return question_automatically_gradable The replacement question class.
  49       */
  50      public static function deleted_subquestion_replacement(): question_automatically_gradable {
  51          return new class implements question_automatically_gradable {
  52              public $qtype;
  53  
  54              public function __construct() {
  55                  $this->qtype = new class() {
  56                      public function name() {
  57                          return 'subquestion_replacement';
  58                      }
  59                  };
  60              }
  61  
  62              public function is_gradable_response(array $response) {
  63                  return false;
  64              }
  65  
  66              public function is_complete_response(array $response) {
  67                  return false;
  68              }
  69  
  70              public function is_same_response(array $prevresponse, array $newresponse) {
  71                  return false;
  72              }
  73  
  74              public function summarise_response(array $response) {
  75                  return '';
  76              }
  77  
  78              public function un_summarise_response(string $summary) {
  79                  return [];
  80              }
  81  
  82              public function classify_response(array $response) {
  83                  return [];
  84              }
  85  
  86              public function get_validation_error(array $response) {
  87                  return '';
  88              }
  89  
  90              public function grade_response(array $response) {
  91                  return [];
  92              }
  93  
  94              public function get_hint($hintnumber, question_attempt $qa) {
  95                  return;
  96              }
  97  
  98              public function get_right_answer_summary() {
  99                  return null;
 100              }
 101          };
 102      }
 103  
 104      public function can_analyse_responses() {
 105          return false;
 106      }
 107  
 108      public function get_question_options($question) {
 109          global $DB;
 110  
 111          parent::get_question_options($question);
 112          // Get relevant data indexed by positionkey from the multianswers table.
 113          $sequence = $DB->get_field('question_multianswer', 'sequence',
 114                  array('question' => $question->id), MUST_EXIST);
 115  
 116          if (empty($sequence)) {
 117              $question->options->questions = [];
 118              return true;
 119          }
 120  
 121          $wrappedquestions = $DB->get_records_list('question', 'id',
 122                  explode(',', $sequence), 'id ASC');
 123  
 124          // We want an array with question ids as index and the positions as values.
 125          $sequence = array_flip(explode(',', $sequence));
 126          array_walk($sequence, function(&$val) {
 127              $val++;
 128          });
 129  
 130          // Due to a bug, questions can be lost (see MDL-54724). So we first fill the question
 131          // options with this dummy "replacement" type. These are overridden in the loop below
 132          // leaving behind only those questions which no longer exist. The renderer then looks
 133          // for this deleted type to display information to the user about the corrupted question
 134          // data.
 135          foreach ($sequence as $seq) {
 136              $question->options->questions[$seq] = (object)[
 137                  'qtype' => 'subquestion_replacement',
 138                  'defaultmark' => 1,
 139                  'options' => (object)[
 140                      'answers' => []
 141                  ]
 142              ];
 143          }
 144  
 145          foreach ($wrappedquestions as $wrapped) {
 146              question_bank::get_qtype($wrapped->qtype)->get_question_options($wrapped);
 147              // For wrapped questions the maxgrade is always equal to the defaultmark,
 148              // there is no entry in the question_instances table for them.
 149              $wrapped->maxmark = $wrapped->defaultmark;
 150              $wrapped->category = $question->categoryobject->id;
 151              $question->options->questions[$sequence[$wrapped->id]] = $wrapped;
 152          }
 153          $question->hints = $DB->get_records('question_hints',
 154                  array('questionid' => $question->id), 'id ASC');
 155  
 156          return true;
 157      }
 158  
 159      public function save_question_options($question) {
 160          global $DB;
 161          $result = new stdClass();
 162  
 163          // This function needs to be able to handle the case where the existing set of wrapped
 164          // questions does not match the new set of wrapped questions so that some need to be
 165          // created, some modified and some deleted.
 166          // Unfortunately the code currently simply overwrites existing ones in sequence. This
 167          // will make re-marking after a re-ordering of wrapped questions impossible and
 168          // will also create difficulties if questiontype specific tables reference the id.
 169  
 170          // First we get all the existing wrapped questions.
 171          $oldwrappedquestions = [];
 172          if (isset($question->oldparent)) {
 173              if ($oldwrappedids = $DB->get_field('question_multianswer', 'sequence',
 174                  ['question' => $question->oldparent])) {
 175                  $oldwrappedidsarray = explode(',', $oldwrappedids);
 176                  $unorderedquestions = $DB->get_records_list('question', 'id', $oldwrappedidsarray);
 177  
 178                  // Keep the order as given in the sequence field.
 179                  foreach ($oldwrappedidsarray as $questionid) {
 180                      if (isset($unorderedquestions[$questionid])) {
 181                          $oldwrappedquestions[] = $unorderedquestions[$questionid];
 182                      }
 183                  }
 184              }
 185          }
 186  
 187          $sequence = array();
 188          foreach ($question->options->questions as $wrapped) {
 189              if (!empty($wrapped)) {
 190                  // If we still have some old wrapped question ids, reuse the next of them.
 191                  $wrapped->id = 0;
 192                  if (is_array($oldwrappedquestions) &&
 193                          $oldwrappedquestion = array_shift($oldwrappedquestions)) {
 194                      $wrapped->oldid = $oldwrappedquestion->id;
 195                      if ($oldwrappedquestion->qtype != $wrapped->qtype) {
 196                          switch ($oldwrappedquestion->qtype) {
 197                              case 'multichoice':
 198                                  $DB->delete_records('qtype_multichoice_options',
 199                                          array('questionid' => $oldwrappedquestion->id));
 200                                  break;
 201                              case 'shortanswer':
 202                                  $DB->delete_records('qtype_shortanswer_options',
 203                                          array('questionid' => $oldwrappedquestion->id));
 204                                  break;
 205                              case 'numerical':
 206                                  $DB->delete_records('question_numerical',
 207                                          array('question' => $oldwrappedquestion->id));
 208                                  break;
 209                              default:
 210                                  throw new moodle_exception('qtypenotrecognized',
 211                                          'qtype_multianswer', '', $oldwrappedquestion->qtype);
 212                          }
 213                      }
 214                  }
 215              }
 216              $wrapped->name = $question->name;
 217              $wrapped->parent = $question->id;
 218              $previousid = $wrapped->id;
 219              // Save_question strips this extra bit off the category again.
 220              $wrapped->category = $question->category . ',1';
 221              $wrapped = question_bank::get_qtype($wrapped->qtype)->save_question(
 222                      $wrapped, clone($wrapped));
 223              $sequence[] = $wrapped->id;
 224              if ($previousid != 0 && $previousid != $wrapped->id) {
 225                  // For some reasons a new question has been created
 226                  // so delete the old one.
 227                  question_delete_question($previousid);
 228              }
 229          }
 230  
 231          // Delete redundant wrapped questions.
 232          if (is_array($oldwrappedquestions) && count($oldwrappedquestions)) {
 233              foreach ($oldwrappedquestions as $oldwrappedquestion) {
 234                  question_delete_question($oldwrappedquestion->id);
 235              }
 236          }
 237  
 238          if (!empty($sequence)) {
 239              $multianswer = new stdClass();
 240              $multianswer->question = $question->id;
 241              $multianswer->sequence = implode(',', $sequence);
 242              if ($oldid = $DB->get_field('question_multianswer', 'id',
 243                      array('question' => $question->id))) {
 244                  $multianswer->id = $oldid;
 245                  $DB->update_record('question_multianswer', $multianswer);
 246              } else {
 247                  $DB->insert_record('question_multianswer', $multianswer);
 248              }
 249          }
 250  
 251          $this->save_hints($question, true);
 252      }
 253  
 254      public function save_question($authorizedquestion, $form) {
 255          $question = qtype_multianswer_extract_question($form->questiontext);
 256          if (isset($authorizedquestion->id)) {
 257              $question->id = $authorizedquestion->id;
 258          }
 259  
 260          $question->category = $form->category;
 261          $form->defaultmark = $question->defaultmark;
 262          $form->questiontext = $question->questiontext;
 263          $form->questiontextformat = 0;
 264          $form->options = clone($question->options);
 265          unset($question->options);
 266          return parent::save_question($question, $form);
 267      }
 268  
 269      protected function make_hint($hint) {
 270          return question_hint_with_parts::load_from_record($hint);
 271      }
 272  
 273      public function delete_question($questionid, $contextid) {
 274          global $DB;
 275          $DB->delete_records('question_multianswer', array('question' => $questionid));
 276  
 277          parent::delete_question($questionid, $contextid);
 278      }
 279  
 280      protected function initialise_question_instance(question_definition $question, $questiondata) {
 281          parent::initialise_question_instance($question, $questiondata);
 282  
 283          $bits = preg_split('/\{#(\d+)\}/', $question->questiontext,
 284                  -1, PREG_SPLIT_DELIM_CAPTURE);
 285          $question->textfragments[0] = array_shift($bits);
 286          $i = 1;
 287          while (!empty($bits)) {
 288              $question->places[$i] = array_shift($bits);
 289              $question->textfragments[$i] = array_shift($bits);
 290              $i += 1;
 291          }
 292          foreach ($questiondata->options->questions as $key => $subqdata) {
 293              if ($subqdata->qtype == 'subquestion_replacement') {
 294                  continue;
 295              }
 296  
 297              $subqdata->contextid = $questiondata->contextid;
 298              if ($subqdata->qtype == 'multichoice') {
 299                  $answerregs = array();
 300                  if ($subqdata->options->shuffleanswers == 1 &&  isset($questiondata->options->shuffleanswers)
 301                      && $questiondata->options->shuffleanswers == 0 ) {
 302                      $subqdata->options->shuffleanswers = 0;
 303                  }
 304              }
 305              $question->subquestions[$key] = question_bank::make_question($subqdata);
 306              $question->subquestions[$key]->maxmark = $subqdata->defaultmark;
 307              if (isset($subqdata->options->layout)) {
 308                  $question->subquestions[$key]->layout = $subqdata->options->layout;
 309              }
 310          }
 311      }
 312  
 313      public function get_random_guess_score($questiondata) {
 314          $fractionsum = 0;
 315          $fractionmax = 0;
 316          foreach ($questiondata->options->questions as $key => $subqdata) {
 317              if ($subqdata->qtype == 'subquestion_replacement') {
 318                  continue;
 319              }
 320              $fractionmax += $subqdata->defaultmark;
 321              $fractionsum += question_bank::get_qtype(
 322                      $subqdata->qtype)->get_random_guess_score($subqdata);
 323          }
 324          if ($fractionmax > question_utils::MARK_TOLERANCE) {
 325              return $fractionsum / $fractionmax;
 326          } else {
 327              return null;
 328          }
 329      }
 330  
 331      public function move_files($questionid, $oldcontextid, $newcontextid) {
 332          parent::move_files($questionid, $oldcontextid, $newcontextid);
 333          $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
 334      }
 335  
 336      protected function delete_files($questionid, $contextid) {
 337          parent::delete_files($questionid, $contextid);
 338          $this->delete_files_in_hints($questionid, $contextid);
 339      }
 340  }
 341  
 342  
 343  // ANSWER_ALTERNATIVE regexes.
 344  define('ANSWER_ALTERNATIVE_FRACTION_REGEX',
 345         '=|%(-?[0-9]+(?:[.,][0-9]*)?)%');
 346  // For the syntax '(?<!' see http://www.perl.com/doc/manual/html/pod/perlre.html#item_C.
 347  define('ANSWER_ALTERNATIVE_ANSWER_REGEX',
 348          '.+?(?<!\\\\|&|&amp;)(?=[~#}]|$)');
 349  define('ANSWER_ALTERNATIVE_FEEDBACK_REGEX',
 350          '.*?(?<!\\\\)(?=[~}]|$)');
 351  define('ANSWER_ALTERNATIVE_REGEX',
 352         '(' . ANSWER_ALTERNATIVE_FRACTION_REGEX .')?' .
 353         '(' . ANSWER_ALTERNATIVE_ANSWER_REGEX . ')' .
 354         '(#(' . ANSWER_ALTERNATIVE_FEEDBACK_REGEX .'))?');
 355  
 356  // Parenthesis positions for ANSWER_ALTERNATIVE_REGEX.
 357  define('ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION', 2);
 358  define('ANSWER_ALTERNATIVE_REGEX_FRACTION', 1);
 359  define('ANSWER_ALTERNATIVE_REGEX_ANSWER', 3);
 360  define('ANSWER_ALTERNATIVE_REGEX_FEEDBACK', 5);
 361  
 362  // NUMBER_FORMATED_ALTERNATIVE_ANSWER_REGEX is used
 363  // for identifying numerical answers in ANSWER_ALTERNATIVE_REGEX_ANSWER.
 364  define('NUMBER_REGEX',
 365          '-?(([0-9]+[.,]?[0-9]*|[.,][0-9]+)([eE][-+]?[0-9]+)?)');
 366  define('NUMERICAL_ALTERNATIVE_REGEX',
 367          '^(' . NUMBER_REGEX . ')(:' . NUMBER_REGEX . ')?$');
 368  
 369  // Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX.
 370  define('NUMERICAL_CORRECT_ANSWER', 1);
 371  define('NUMERICAL_ABS_ERROR_MARGIN', 6);
 372  
 373  // Remaining ANSWER regexes.
 374  define('ANSWER_TYPE_DEF_REGEX',
 375          '(NUMERICAL|NM)|(MULTICHOICE|MC)|(MULTICHOICE_V|MCV)|(MULTICHOICE_H|MCH)|' .
 376          '(SHORTANSWER|SA|MW)|(SHORTANSWER_C|SAC|MWC)|' .
 377          '(MULTICHOICE_S|MCS)|(MULTICHOICE_VS|MCVS)|(MULTICHOICE_HS|MCHS)|'.
 378          '(MULTIRESPONSE|MR)|(MULTIRESPONSE_H|MRH)|(MULTIRESPONSE_S|MRS)|(MULTIRESPONSE_HS|MRHS)');
 379  define('ANSWER_START_REGEX',
 380         '\{([0-9]*):(' . ANSWER_TYPE_DEF_REGEX . '):');
 381  
 382  define('ANSWER_REGEX',
 383          ANSWER_START_REGEX
 384          . '(' . ANSWER_ALTERNATIVE_REGEX
 385          . '(~'
 386          . ANSWER_ALTERNATIVE_REGEX
 387          . ')*)\}');
 388  
 389  // Parenthesis positions for singulars in ANSWER_REGEX.
 390  define('ANSWER_REGEX_NORM', 1);
 391  define('ANSWER_REGEX_ANSWER_TYPE_NUMERICAL', 3);
 392  define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE', 4);
 393  define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR', 5);
 394  define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL', 6);
 395  define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER', 7);
 396  define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C', 8);
 397  define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED', 9);
 398  define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED', 10);
 399  define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED', 11);
 400  define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE', 12);
 401  define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL', 13);
 402  define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_SHUFFLED', 14);
 403  define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL_SHUFFLED', 15);
 404  define('ANSWER_REGEX_ALTERNATIVES', 16);
 405  
 406  /**
 407   * Initialise subquestion fields that are constant across all MULTICHOICE
 408   * types.
 409   *
 410   * @param objet $wrapped  The subquestion to initialise
 411   *
 412   */
 413  function qtype_multianswer_initialise_multichoice_subquestion($wrapped) {
 414      $wrapped->qtype = 'multichoice';
 415      $wrapped->single = 1;
 416      $wrapped->answernumbering = 0;
 417      $wrapped->correctfeedback['text'] = '';
 418      $wrapped->correctfeedback['format'] = FORMAT_HTML;
 419      $wrapped->correctfeedback['itemid'] = '';
 420      $wrapped->partiallycorrectfeedback['text'] = '';
 421      $wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML;
 422      $wrapped->partiallycorrectfeedback['itemid'] = '';
 423      $wrapped->incorrectfeedback['text'] = '';
 424      $wrapped->incorrectfeedback['format'] = FORMAT_HTML;
 425      $wrapped->incorrectfeedback['itemid'] = '';
 426  }
 427  
 428  function qtype_multianswer_extract_question($text) {
 429      // Variable $text is an array [text][format][itemid].
 430      $question = new stdClass();
 431      $question->qtype = 'multianswer';
 432      $question->questiontext = $text;
 433      $question->generalfeedback['text'] = '';
 434      $question->generalfeedback['format'] = FORMAT_HTML;
 435      $question->generalfeedback['itemid'] = '';
 436  
 437      $question->options = new stdClass();
 438      $question->options->questions = array();
 439      $question->defaultmark = 0; // Will be increased for each answer norm.
 440  
 441      for ($positionkey = 1;
 442              preg_match('/'.ANSWER_REGEX.'/s', $question->questiontext['text'], $answerregs);
 443              ++$positionkey) {
 444          $wrapped = new stdClass();
 445          $wrapped->generalfeedback['text'] = '';
 446          $wrapped->generalfeedback['format'] = FORMAT_HTML;
 447          $wrapped->generalfeedback['itemid'] = '';
 448          if (isset($answerregs[ANSWER_REGEX_NORM]) && $answerregs[ANSWER_REGEX_NORM] !== '') {
 449              $wrapped->defaultmark = $answerregs[ANSWER_REGEX_NORM];
 450          } else {
 451              $wrapped->defaultmark = '1';
 452          }
 453          if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])) {
 454              $wrapped->qtype = 'numerical';
 455              $wrapped->multiplier = array();
 456              $wrapped->units      = array();
 457              $wrapped->instructions['text'] = '';
 458              $wrapped->instructions['format'] = FORMAT_HTML;
 459              $wrapped->instructions['itemid'] = '';
 460          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER])) {
 461              $wrapped->qtype = 'shortanswer';
 462              $wrapped->usecase = 0;
 463          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C])) {
 464              $wrapped->qtype = 'shortanswer';
 465              $wrapped->usecase = 1;
 466          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE])) {
 467              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 468              $wrapped->shuffleanswers = 0;
 469              $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
 470          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED])) {
 471              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 472              $wrapped->shuffleanswers = 1;
 473              $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
 474          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR])) {
 475              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 476              $wrapped->shuffleanswers = 0;
 477              $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
 478          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED])) {
 479              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 480              $wrapped->shuffleanswers = 1;
 481              $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
 482          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL])) {
 483              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 484              $wrapped->shuffleanswers = 0;
 485              $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
 486          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED])) {
 487              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 488              $wrapped->shuffleanswers = 1;
 489              $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
 490          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE])) {
 491              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 492              $wrapped->single = 0;
 493              $wrapped->shuffleanswers = 0;
 494              $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
 495          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL])) {
 496              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 497              $wrapped->single = 0;
 498              $wrapped->shuffleanswers = 0;
 499              $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
 500          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_SHUFFLED])) {
 501              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 502              $wrapped->single = 0;
 503              $wrapped->shuffleanswers = 1;
 504              $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
 505          } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL_SHUFFLED])) {
 506              qtype_multianswer_initialise_multichoice_subquestion($wrapped);
 507              $wrapped->single = 0;
 508              $wrapped->shuffleanswers = 1;
 509              $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
 510          } else {
 511              throw new \moodle_exception('unknownquestiontype', 'question', '', $answerregs[2]);
 512              return false;
 513          }
 514  
 515          // Each $wrapped simulates a $form that can be processed by the
 516          // respective save_question and save_question_options methods of the
 517          // wrapped questiontypes.
 518          $wrapped->answer   = array();
 519          $wrapped->fraction = array();
 520          $wrapped->feedback = array();
 521          $wrapped->questiontext['text'] = $answerregs[0];
 522          $wrapped->questiontext['format'] = FORMAT_HTML;
 523          $wrapped->questiontext['itemid'] = '';
 524          $answerindex = 0;
 525  
 526          $hasspecificfraction = false;
 527          $remainingalts = $answerregs[ANSWER_REGEX_ALTERNATIVES];
 528          while (preg_match('/~?'.ANSWER_ALTERNATIVE_REGEX.'/s', $remainingalts, $altregs)) {
 529              if ('=' == $altregs[ANSWER_ALTERNATIVE_REGEX_FRACTION]) {
 530                  $wrapped->fraction["{$answerindex}"] = '1';
 531              } else if ($percentile = $altregs[ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION]) {
 532                  // Accept either decimal place character.
 533                  $wrapped->fraction["{$answerindex}"] = .01 * str_replace(',', '.', $percentile);
 534                  $hasspecificfraction = true;
 535              } else {
 536                  $wrapped->fraction["{$answerindex}"] = '0';
 537              }
 538              if (isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])) {
 539                  $feedback = html_entity_decode(
 540                          $altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK], ENT_QUOTES, 'UTF-8');
 541                  $feedback = str_replace('\}', '}', $feedback);
 542                  $wrapped->feedback["{$answerindex}"]['text'] = str_replace('\#', '#', $feedback);
 543                  $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
 544                  $wrapped->feedback["{$answerindex}"]['itemid'] = '';
 545              } else {
 546                  $wrapped->feedback["{$answerindex}"]['text'] = '';
 547                  $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
 548                  $wrapped->feedback["{$answerindex}"]['itemid'] = '';
 549  
 550              }
 551              if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])
 552                      && preg_match('~'.NUMERICAL_ALTERNATIVE_REGEX.'~s',
 553                              $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], $numregs)) {
 554                  $wrapped->answer[] = $numregs[NUMERICAL_CORRECT_ANSWER];
 555                  if (array_key_exists(NUMERICAL_ABS_ERROR_MARGIN, $numregs)) {
 556                      $wrapped->tolerance["{$answerindex}"] =
 557                      $numregs[NUMERICAL_ABS_ERROR_MARGIN];
 558                  } else {
 559                      $wrapped->tolerance["{$answerindex}"] = 0;
 560                  }
 561              } else { // Tolerance can stay undefined for non numerical questions.
 562                  // Undo quoting done by the HTML editor.
 563                  $answer = html_entity_decode(
 564                          $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], ENT_QUOTES, 'UTF-8');
 565                  $answer = str_replace('\}', '}', $answer);
 566                  $wrapped->answer["{$answerindex}"] = str_replace('\#', '#', $answer);
 567                  if ($wrapped->qtype == 'multichoice') {
 568                      $wrapped->answer["{$answerindex}"] = array(
 569                              'text' => $wrapped->answer["{$answerindex}"],
 570                              'format' => FORMAT_HTML,
 571                              'itemid' => '');
 572                  }
 573              }
 574              $tmp = explode($altregs[0], $remainingalts, 2);
 575              $remainingalts = $tmp[1];
 576              $answerindex++;
 577          }
 578  
 579          // Fix the score for multichoice_multi questions (as positive scores should add up to 1, not have a maximum of 1).
 580          if (isset($wrapped->single) && $wrapped->single == 0) {
 581              $total = 0;
 582              foreach ($wrapped->fraction as $idx => $fraction) {
 583                  if ($fraction > 0) {
 584                      $total += $fraction;
 585                  }
 586              }
 587              if ($total) {
 588                  foreach ($wrapped->fraction as $idx => $fraction) {
 589                      if ($fraction > 0) {
 590                          $wrapped->fraction[$idx] = $fraction / $total;
 591                      } else if (!$hasspecificfraction) {
 592                          // If no specific fractions are given, set incorrect answers to each cancel out one correct answer.
 593                          $wrapped->fraction[$idx] = -(1.0 / $total);
 594                      }
 595                  }
 596              }
 597          }
 598  
 599          $question->defaultmark += $wrapped->defaultmark;
 600          $question->options->questions[$positionkey] = clone($wrapped);
 601          $question->questiontext['text'] = implode("{#$positionkey}",
 602                      explode($answerregs[0], $question->questiontext['text'], 2));
 603      }
 604      return $question;
 605  }
 606  
 607  /**
 608   * Validate a multianswer question.
 609   *
 610   * @param object $question  The multianswer question to validate as returned by qtype_multianswer_extract_question
 611   * @return array Array of error messages with questions field names as keys.
 612   */
 613  function qtype_multianswer_validate_question(stdClass $question) : array {
 614      $errors = array();
 615      if (!isset($question->options->questions)) {
 616          $errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer');
 617      } else {
 618          $subquestions = fullclone($question->options->questions);
 619          if (count($subquestions)) {
 620              $sub = 1;
 621              foreach ($subquestions as $subquestion) {
 622                  $prefix = 'sub_'.$sub.'_';
 623                  $answercount = 0;
 624                  $maxgrade = false;
 625                  $maxfraction = -1;
 626  
 627                  foreach ($subquestion->answer as $key => $answer) {
 628                      if (is_array($answer)) {
 629                          $answer = $answer['text'];
 630                      }
 631                      $trimmedanswer = trim($answer);
 632                      if ($trimmedanswer !== '') {
 633                          $answercount++;
 634                          if ($subquestion->qtype == 'numerical' &&
 635                                  !(qtype_numerical::is_valid_number($trimmedanswer) || $trimmedanswer == '*')) {
 636                              $errors[$prefix.'answer['.$key.']'] =
 637                                      get_string('answermustbenumberorstar', 'qtype_numerical');
 638                          }
 639                          if ($subquestion->fraction[$key] == 1) {
 640                              $maxgrade = true;
 641                          }
 642                          if ($subquestion->fraction[$key] > $maxfraction) {
 643                              $maxfraction = $subquestion->fraction[$key];
 644                          }
 645                          // For 'multiresponse' we are OK if there is at least one fraction > 0.
 646                          if ($subquestion->qtype == 'multichoice' && $subquestion->single == 0 &&
 647                              $subquestion->fraction[$key] > 0) {
 648                              $maxgrade = true;
 649                          }
 650                      }
 651                  }
 652                  if ($subquestion->qtype == 'multichoice' && $answercount < 2) {
 653                      $errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'qtype_multichoice', 2);
 654                  } else if ($answercount == 0) {
 655                      $errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'question', 1);
 656                  }
 657                  if ($maxgrade == false) {
 658                      $errors[$prefix.'fraction[0]'] = get_string('fractionsnomax', 'question');
 659                  }
 660                  $sub++;
 661              }
 662          } else {
 663              $errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer');
 664          }
 665      }
 666      return $errors;
 667  }