Merge branch 'MDL-70119-310' of git://github.com/ferranrecio/moodle into MOODLE_310_S...
[moodle.git] / question / type / multianswer / questiontype.php
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/>.
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  */
27 defined('MOODLE_INTERNAL') || die();
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');
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 {
41     public function can_analyse_responses() {
42         return false;
43     }
45     public function get_question_options($question) {
46         global $DB, $OUTPUT;
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);
53         $wrappedquestions = $DB->get_records_list('question', 'id',
54                 explode(',', $sequence), 'id ASC');
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         });
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         }
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');
81         return true;
82     }
84     public function save_question_options($question) {
85         global $DB;
86         $result = new stdClass();
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.
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);
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         }
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.
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         }
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         }
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         }
177         $this->save_hints($question, true);
178     }
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         }
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     }
195     protected function make_hint($hint) {
196         return question_hint_with_parts::load_from_record($hint);
197     }
199     public function delete_question($questionid, $contextid) {
200         global $DB;
201         $DB->delete_records('question_multianswer', array('question' => $questionid));
203         parent::delete_question($questionid, $contextid);
204     }
206     protected function initialise_question_instance(question_definition $question, $questiondata) {
207         parent::initialise_question_instance($question, $questiondata);
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     }
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     }
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     }
251     protected function delete_files($questionid, $contextid) {
252         parent::delete_files($questionid, $contextid);
253         $this->delete_files_in_hints($questionid, $contextid);
254     }
258 // ANSWER_ALTERNATIVE regexes.
259 define('ANSWER_ALTERNATIVE_FRACTION_REGEX',
260        '=|%(-?[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 .'))?');
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);
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 . ')?$');
284 // Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX.
285 define('NUMERICAL_CORRECT_ANSWER', 1);
286 define('NUMERICAL_ABS_ERROR_MARGIN', 6);
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 . '):');
297 define('ANSWER_REGEX',
298         ANSWER_START_REGEX
299         . '(' . ANSWER_ALTERNATIVE_REGEX
300         . '(~'
301         . ANSWER_ALTERNATIVE_REGEX
302         . ')*)\}');
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);
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'] = '';
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'] = '';
352     $question->options = new stdClass();
353     $question->options->questions = array();
354     $question->defaultmark = 0; // Will be increased for each answer norm.
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         }
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;
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                 $wrapped->fraction["{$answerindex}"] = .01 * $percentile;
448                 $hasspecificfraction = true;
449             } else {
450                 $wrapped->fraction["{$answerindex}"] = '0';
451             }
452             if (isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])) {
453                 $feedback = html_entity_decode(
454                         $altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK], ENT_QUOTES, 'UTF-8');
455                 $feedback = str_replace('\}', '}', $feedback);
456                 $wrapped->feedback["{$answerindex}"]['text'] = str_replace('\#', '#', $feedback);
457                 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
458                 $wrapped->feedback["{$answerindex}"]['itemid'] = '';
459             } else {
460                 $wrapped->feedback["{$answerindex}"]['text'] = '';
461                 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
462                 $wrapped->feedback["{$answerindex}"]['itemid'] = '';
464             }
465             if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])
466                     && preg_match('~'.NUMERICAL_ALTERNATIVE_REGEX.'~s',
467                             $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], $numregs)) {
468                 $wrapped->answer[] = $numregs[NUMERICAL_CORRECT_ANSWER];
469                 if (array_key_exists(NUMERICAL_ABS_ERROR_MARGIN, $numregs)) {
470                     $wrapped->tolerance["{$answerindex}"] =
471                     $numregs[NUMERICAL_ABS_ERROR_MARGIN];
472                 } else {
473                     $wrapped->tolerance["{$answerindex}"] = 0;
474                 }
475             } else { // Tolerance can stay undefined for non numerical questions.
476                 // Undo quoting done by the HTML editor.
477                 $answer = html_entity_decode(
478                         $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], ENT_QUOTES, 'UTF-8');
479                 $answer = str_replace('\}', '}', $answer);
480                 $wrapped->answer["{$answerindex}"] = str_replace('\#', '#', $answer);
481                 if ($wrapped->qtype == 'multichoice') {
482                     $wrapped->answer["{$answerindex}"] = array(
483                             'text' => $wrapped->answer["{$answerindex}"],
484                             'format' => FORMAT_HTML,
485                             'itemid' => '');
486                 }
487             }
488             $tmp = explode($altregs[0], $remainingalts, 2);
489             $remainingalts = $tmp[1];
490             $answerindex++;
491         }
493         // Fix the score for multichoice_multi questions (as positive scores should add up to 1, not have a maximum of 1).
494         if (isset($wrapped->single) && $wrapped->single == 0) {
495             $total = 0;
496             foreach ($wrapped->fraction as $idx => $fraction) {
497                 if ($fraction > 0) {
498                     $total += $fraction;
499                 }
500             }
501             if ($total) {
502                 foreach ($wrapped->fraction as $idx => $fraction) {
503                     if ($fraction > 0) {
504                         $wrapped->fraction[$idx] = $fraction / $total;
505                     } else if (!$hasspecificfraction) {
506                         // If no specific fractions are given, set incorrect answers to each cancel out one correct answer.
507                         $wrapped->fraction[$idx] = -(1.0 / $total);
508                     }
509                 }
510             }
511         }
513         $question->defaultmark += $wrapped->defaultmark;
514         $question->options->questions[$positionkey] = clone($wrapped);
515         $question->questiontext['text'] = implode("{#$positionkey}",
516                     explode($answerregs[0], $question->questiontext['text'], 2));
517     }
518     return $question;
521 /**
522  * Validate a multianswer question.
523  *
524  * @param object $question  The multianswer question to validate as returned by qtype_multianswer_extract_question
525  * @return array Array of error messages with questions field names as keys.
526  */
527 function qtype_multianswer_validate_question(stdClass $question) : array {
528     $errors = array();
529     if (!isset($question->options->questions)) {
530         $errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer');
531     } else {
532         $subquestions = fullclone($question->options->questions);
533         if (count($subquestions)) {
534             $sub = 1;
535             foreach ($subquestions as $subquestion) {
536                 $prefix = 'sub_'.$sub.'_';
537                 $answercount = 0;
538                 $maxgrade = false;
539                 $maxfraction = -1;
541                 foreach ($subquestion->answer as $key => $answer) {
542                     if (is_array($answer)) {
543                         $answer = $answer['text'];
544                     }
545                     $trimmedanswer = trim($answer);
546                     if ($trimmedanswer !== '') {
547                         $answercount++;
548                         if ($subquestion->qtype == 'numerical' &&
549                                 !(qtype_numerical::is_valid_number($trimmedanswer) || $trimmedanswer == '*')) {
550                             $errors[$prefix.'answer['.$key.']'] =
551                                     get_string('answermustbenumberorstar', 'qtype_numerical');
552                         }
553                         if ($subquestion->fraction[$key] == 1) {
554                             $maxgrade = true;
555                         }
556                         if ($subquestion->fraction[$key] > $maxfraction) {
557                             $maxfraction = $subquestion->fraction[$key];
558                         }
559                         // For 'multiresponse' we are OK if there is at least one fraction > 0.
560                         if ($subquestion->qtype == 'multichoice' && $subquestion->single == 0 &&
561                             $subquestion->fraction[$key] > 0) {
562                             $maxgrade = true;
563                         }
564                     }
565                 }
566                 if ($subquestion->qtype == 'multichoice' && $answercount < 2) {
567                     $errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'qtype_multichoice', 2);
568                 } else if ($answercount == 0) {
569                     $errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'question', 1);
570                 }
571                 if ($maxgrade == false) {
572                     $errors[$prefix.'fraction[0]'] = get_string('fractionsnomax', 'question');
573                 }
574                 $sub++;
575             }
576         } else {
577             $errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer');
578         }
579     }
580     return $errors;