Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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