Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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