Merge branch 'MDL-46991-master' of https://github.com/sammarshallou/moodle
[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/multichoice/question.php');
32 /**
33  * The multi-answer question type class.
34  *
35  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
36  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 class qtype_multianswer extends question_type {
40     public function can_analyse_responses() {
41         return false;
42     }
44     public function get_question_options($question) {
45         global $DB, $OUTPUT;
47         // Get relevant data indexed by positionkey from the multianswers table.
48         $sequence = $DB->get_field('question_multianswer', 'sequence',
49                 array('question' => $question->id), '*', MUST_EXIST);
51         $wrappedquestions = $DB->get_records_list('question', 'id',
52                 explode(',', $sequence), 'id ASC');
54         // We want an array with question ids as index and the positions as values.
55         $sequence = array_flip(explode(',', $sequence));
56         array_walk($sequence, create_function('&$val', '$val++;'));
58         // If a question is lost, the corresponding index is null
59         // so this null convention is used to test $question->options->questions
60         // before using the values.
61         // First all possible questions from sequence are nulled
62         // then filled with the data if available in  $wrappedquestions.
63         foreach ($sequence as $seq) {
64             $question->options->questions[$seq] = '';
65         }
67         foreach ($wrappedquestions as $wrapped) {
68             question_bank::get_qtype($wrapped->qtype)->get_question_options($wrapped);
69             // For wrapped questions the maxgrade is always equal to the defaultmark,
70             // there is no entry in the question_instances table for them.
71             $wrapped->maxmark = $wrapped->defaultmark;
72             $question->options->questions[$sequence[$wrapped->id]] = $wrapped;
73         }
75         $question->hints = $DB->get_records('question_hints',
76                 array('questionid' => $question->id), 'id ASC');
78         return true;
79     }
81     public function save_question_options($question) {
82         global $DB;
83         $result = new stdClass();
85         // This function needs to be able to handle the case where the existing set of wrapped
86         // questions does not match the new set of wrapped questions so that some need to be
87         // created, some modified and some deleted.
88         // Unfortunately the code currently simply overwrites existing ones in sequence. This
89         // will make re-marking after a re-ordering of wrapped questions impossible and
90         // will also create difficulties if questiontype specific tables reference the id.
92         // First we get all the existing wrapped questions.
93         if (!$oldwrappedids = $DB->get_field('question_multianswer', 'sequence',
94                 array('question' => $question->id))) {
95             $oldwrappedquestions = array();
96         } else {
97             $oldwrappedquestions = $DB->get_records_list('question', 'id',
98                     explode(',', $oldwrappedids), 'id ASC');
99         }
101         $sequence = array();
102         foreach ($question->options->questions as $wrapped) {
103             if (!empty($wrapped)) {
104                 // If we still have some old wrapped question ids, reuse the next of them.
106                 if (is_array($oldwrappedquestions) &&
107                         $oldwrappedquestion = array_shift($oldwrappedquestions)) {
108                     $wrapped->id = $oldwrappedquestion->id;
109                     if ($oldwrappedquestion->qtype != $wrapped->qtype) {
110                         switch ($oldwrappedquestion->qtype) {
111                             case 'multichoice':
112                                 $DB->delete_records('qtype_multichoice_options',
113                                         array('questionid' => $oldwrappedquestion->id));
114                                 break;
115                             case 'shortanswer':
116                                 $DB->delete_records('qtype_shortanswer_options',
117                                         array('questionid' => $oldwrappedquestion->id));
118                                 break;
119                             case 'numerical':
120                                 $DB->delete_records('question_numerical',
121                                         array('question' => $oldwrappedquestion->id));
122                                 break;
123                             default:
124                                 throw new moodle_exception('qtypenotrecognized',
125                                         'qtype_multianswer', '', $oldwrappedquestion->qtype);
126                                 $wrapped->id = 0;
127                         }
128                     }
129                 } else {
130                     $wrapped->id = 0;
131                 }
132             }
133             $wrapped->name = $question->name;
134             $wrapped->parent = $question->id;
135             $previousid = $wrapped->id;
136             // Save_question strips this extra bit off the category again.
137             $wrapped->category = $question->category . ',1';
138             $wrapped = question_bank::get_qtype($wrapped->qtype)->save_question(
139                     $wrapped, clone($wrapped));
140             $sequence[] = $wrapped->id;
141             if ($previousid != 0 && $previousid != $wrapped->id) {
142                 // For some reasons a new question has been created
143                 // so delete the old one.
144                 question_delete_question($previousid);
145             }
146         }
148         // Delete redundant wrapped questions.
149         if (is_array($oldwrappedquestions) && count($oldwrappedquestions)) {
150             foreach ($oldwrappedquestions as $oldwrappedquestion) {
151                 question_delete_question($oldwrappedquestion->id);
152             }
153         }
155         if (!empty($sequence)) {
156             $multianswer = new stdClass();
157             $multianswer->question = $question->id;
158             $multianswer->sequence = implode(',', $sequence);
159             if ($oldid = $DB->get_field('question_multianswer', 'id',
160                     array('question' => $question->id))) {
161                 $multianswer->id = $oldid;
162                 $DB->update_record('question_multianswer', $multianswer);
163             } else {
164                 $DB->insert_record('question_multianswer', $multianswer);
165             }
166         }
168         $this->save_hints($question, true);
169     }
171     public function save_question($authorizedquestion, $form) {
172         $question = qtype_multianswer_extract_question($form->questiontext);
173         if (isset($authorizedquestion->id)) {
174             $question->id = $authorizedquestion->id;
175         }
177         $question->category = $authorizedquestion->category;
178         $form->defaultmark = $question->defaultmark;
179         $form->questiontext = $question->questiontext;
180         $form->questiontextformat = 0;
181         $form->options = clone($question->options);
182         unset($question->options);
183         return parent::save_question($question, $form);
184     }
186     protected function make_hint($hint) {
187         return question_hint_with_parts::load_from_record($hint);
188     }
190     public function delete_question($questionid, $contextid) {
191         global $DB;
192         $DB->delete_records('question_multianswer', array('question' => $questionid));
194         parent::delete_question($questionid, $contextid);
195     }
197     protected function initialise_question_instance(question_definition $question, $questiondata) {
198         parent::initialise_question_instance($question, $questiondata);
200         $bits = preg_split('/\{#(\d+)\}/', $question->questiontext,
201                 null, PREG_SPLIT_DELIM_CAPTURE);
202         $question->textfragments[0] = array_shift($bits);
203         $i = 1;
204         while (!empty($bits)) {
205             $question->places[$i] = array_shift($bits);
206             $question->textfragments[$i] = array_shift($bits);
207             $i += 1;
208         }
210         foreach ($questiondata->options->questions as $key => $subqdata) {
211             $subqdata->contextid = $questiondata->contextid;
212             $question->subquestions[$key] = question_bank::make_question($subqdata);
213             $question->subquestions[$key]->maxmark = $subqdata->defaultmark;
214             if (isset($subqdata->options->layout)) {
215                 $question->subquestions[$key]->layout = $subqdata->options->layout;
216             }
217         }
218     }
220     public function get_random_guess_score($questiondata) {
221         $fractionsum = 0;
222         $fractionmax = 0;
223         foreach ($questiondata->options->questions as $key => $subqdata) {
224             $fractionmax += $subqdata->defaultmark;
225             $fractionsum += question_bank::get_qtype(
226                     $subqdata->qtype)->get_random_guess_score($subqdata);
227         }
228         return $fractionsum / $fractionmax;
229     }
231     public function move_files($questionid, $oldcontextid, $newcontextid) {
232         parent::move_files($questionid, $oldcontextid, $newcontextid);
233         $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
234     }
236     protected function delete_files($questionid, $contextid) {
237         parent::delete_files($questionid, $contextid);
238         $this->delete_files_in_hints($questionid, $contextid);
239     }
243 // ANSWER_ALTERNATIVE regexes.
244 define('ANSWER_ALTERNATIVE_FRACTION_REGEX',
245        '=|%(-?[0-9]+)%');
246 // For the syntax '(?<!' see http://www.perl.com/doc/manual/html/pod/perlre.html#item_C.
247 define('ANSWER_ALTERNATIVE_ANSWER_REGEX',
248         '.+?(?<!\\\\|&|&amp;)(?=[~#}]|$)');
249 define('ANSWER_ALTERNATIVE_FEEDBACK_REGEX',
250         '.*?(?<!\\\\)(?=[~}]|$)');
251 define('ANSWER_ALTERNATIVE_REGEX',
252        '(' . ANSWER_ALTERNATIVE_FRACTION_REGEX .')?' .
253        '(' . ANSWER_ALTERNATIVE_ANSWER_REGEX . ')' .
254        '(#(' . ANSWER_ALTERNATIVE_FEEDBACK_REGEX .'))?');
256 // Parenthesis positions for ANSWER_ALTERNATIVE_REGEX.
257 define('ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION', 2);
258 define('ANSWER_ALTERNATIVE_REGEX_FRACTION', 1);
259 define('ANSWER_ALTERNATIVE_REGEX_ANSWER', 3);
260 define('ANSWER_ALTERNATIVE_REGEX_FEEDBACK', 5);
262 // NUMBER_FORMATED_ALTERNATIVE_ANSWER_REGEX is used
263 // for identifying numerical answers in ANSWER_ALTERNATIVE_REGEX_ANSWER.
264 define('NUMBER_REGEX',
265         '-?(([0-9]+[.,]?[0-9]*|[.,][0-9]+)([eE][-+]?[0-9]+)?)');
266 define('NUMERICAL_ALTERNATIVE_REGEX',
267         '^(' . NUMBER_REGEX . ')(:' . NUMBER_REGEX . ')?$');
269 // Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX.
270 define('NUMERICAL_CORRECT_ANSWER', 1);
271 define('NUMERICAL_ABS_ERROR_MARGIN', 6);
273 // Remaining ANSWER regexes.
274 define('ANSWER_TYPE_DEF_REGEX',
275         '(NUMERICAL|NM)|(MULTICHOICE|MC)|(MULTICHOICE_V|MCV)|(MULTICHOICE_H|MCH)|' .
276                 '(SHORTANSWER|SA|MW)|(SHORTANSWER_C|SAC|MWC)');
277 define('ANSWER_START_REGEX',
278        '\{([0-9]*):(' . ANSWER_TYPE_DEF_REGEX . '):');
280 define('ANSWER_REGEX',
281         ANSWER_START_REGEX
282         . '(' . ANSWER_ALTERNATIVE_REGEX
283         . '(~'
284         . ANSWER_ALTERNATIVE_REGEX
285         . ')*)\}');
287 // Parenthesis positions for singulars in ANSWER_REGEX.
288 define('ANSWER_REGEX_NORM', 1);
289 define('ANSWER_REGEX_ANSWER_TYPE_NUMERICAL', 3);
290 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE', 4);
291 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR', 5);
292 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL', 6);
293 define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER', 7);
294 define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C', 8);
295 define('ANSWER_REGEX_ALTERNATIVES', 9);
297 function qtype_multianswer_extract_question($text) {
298     // Variable $text is an array [text][format][itemid].
299     $question = new stdClass();
300     $question->qtype = 'multianswer';
301     $question->questiontext = $text;
302     $question->generalfeedback['text'] = '';
303     $question->generalfeedback['format'] = FORMAT_HTML;
304     $question->generalfeedback['itemid'] = '';
306     $question->options = new stdClass();
307     $question->options->questions = array();
308     $question->defaultmark = 0; // Will be increased for each answer norm.
310     for ($positionkey = 1;
311             preg_match('/'.ANSWER_REGEX.'/s', $question->questiontext['text'], $answerregs);
312             ++$positionkey) {
313         $wrapped = new stdClass();
314         $wrapped->generalfeedback['text'] = '';
315         $wrapped->generalfeedback['format'] = FORMAT_HTML;
316         $wrapped->generalfeedback['itemid'] = '';
317         if (isset($answerregs[ANSWER_REGEX_NORM])&& $answerregs[ANSWER_REGEX_NORM]!== '') {
318             $wrapped->defaultmark = $answerregs[ANSWER_REGEX_NORM];
319         } else {
320             $wrapped->defaultmark = '1';
321         }
322         if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])) {
323             $wrapped->qtype = 'numerical';
324             $wrapped->multiplier = array();
325             $wrapped->units      = array();
326             $wrapped->instructions['text'] = '';
327             $wrapped->instructions['format'] = FORMAT_HTML;
328             $wrapped->instructions['itemid'] = '';
329         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER])) {
330             $wrapped->qtype = 'shortanswer';
331             $wrapped->usecase = 0;
332         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C])) {
333             $wrapped->qtype = 'shortanswer';
334             $wrapped->usecase = 1;
335         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE])) {
336             $wrapped->qtype = 'multichoice';
337             $wrapped->single = 1;
338             $wrapped->shuffleanswers = 1;
339             $wrapped->answernumbering = 0;
340             $wrapped->correctfeedback['text'] = '';
341             $wrapped->correctfeedback['format'] = FORMAT_HTML;
342             $wrapped->correctfeedback['itemid'] = '';
343             $wrapped->partiallycorrectfeedback['text'] = '';
344             $wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML;
345             $wrapped->partiallycorrectfeedback['itemid'] = '';
346             $wrapped->incorrectfeedback['text'] = '';
347             $wrapped->incorrectfeedback['format'] = FORMAT_HTML;
348             $wrapped->incorrectfeedback['itemid'] = '';
349             $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
350         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR])) {
351             $wrapped->qtype = 'multichoice';
352             $wrapped->single = 1;
353             $wrapped->shuffleanswers = 0;
354             $wrapped->answernumbering = 0;
355             $wrapped->correctfeedback['text'] = '';
356             $wrapped->correctfeedback['format'] = FORMAT_HTML;
357             $wrapped->correctfeedback['itemid'] = '';
358             $wrapped->partiallycorrectfeedback['text'] = '';
359             $wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML;
360             $wrapped->partiallycorrectfeedback['itemid'] = '';
361             $wrapped->incorrectfeedback['text'] = '';
362             $wrapped->incorrectfeedback['format'] = FORMAT_HTML;
363             $wrapped->incorrectfeedback['itemid'] = '';
364             $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
365         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL])) {
366             $wrapped->qtype = 'multichoice';
367             $wrapped->single = 1;
368             $wrapped->shuffleanswers = 0;
369             $wrapped->answernumbering = 0;
370             $wrapped->correctfeedback['text'] = '';
371             $wrapped->correctfeedback['format'] = FORMAT_HTML;
372             $wrapped->correctfeedback['itemid'] = '';
373             $wrapped->partiallycorrectfeedback['text'] = '';
374             $wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML;
375             $wrapped->partiallycorrectfeedback['itemid'] = '';
376             $wrapped->incorrectfeedback['text'] = '';
377             $wrapped->incorrectfeedback['format'] = FORMAT_HTML;
378             $wrapped->incorrectfeedback['itemid'] = '';
379             $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
380         } else {
381             print_error('unknownquestiontype', 'question', '', $answerregs[2]);
382             return false;
383         }
385         // Each $wrapped simulates a $form that can be processed by the
386         // respective save_question and save_question_options methods of the
387         // wrapped questiontypes.
388         $wrapped->answer   = array();
389         $wrapped->fraction = array();
390         $wrapped->feedback = array();
391         $wrapped->questiontext['text'] = $answerregs[0];
392         $wrapped->questiontext['format'] = FORMAT_HTML;
393         $wrapped->questiontext['itemid'] = '';
394         $answerindex = 0;
396         $remainingalts = $answerregs[ANSWER_REGEX_ALTERNATIVES];
397         while (preg_match('/~?'.ANSWER_ALTERNATIVE_REGEX.'/s', $remainingalts, $altregs)) {
398             if ('=' == $altregs[ANSWER_ALTERNATIVE_REGEX_FRACTION]) {
399                 $wrapped->fraction["{$answerindex}"] = '1';
400             } else if ($percentile = $altregs[ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION]) {
401                 $wrapped->fraction["{$answerindex}"] = .01 * $percentile;
402             } else {
403                 $wrapped->fraction["{$answerindex}"] = '0';
404             }
405             if (isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])) {
406                 $feedback = html_entity_decode(
407                         $altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK], ENT_QUOTES, 'UTF-8');
408                 $feedback = str_replace('\}', '}', $feedback);
409                 $wrapped->feedback["{$answerindex}"]['text'] = str_replace('\#', '#', $feedback);
410                 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
411                 $wrapped->feedback["{$answerindex}"]['itemid'] = '';
412             } else {
413                 $wrapped->feedback["{$answerindex}"]['text'] = '';
414                 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
415                 $wrapped->feedback["{$answerindex}"]['itemid'] = '';
417             }
418             if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])
419                     && preg_match('~'.NUMERICAL_ALTERNATIVE_REGEX.'~s',
420                             $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], $numregs)) {
421                 $wrapped->answer[] = $numregs[NUMERICAL_CORRECT_ANSWER];
422                 if (array_key_exists(NUMERICAL_ABS_ERROR_MARGIN, $numregs)) {
423                     $wrapped->tolerance["{$answerindex}"] =
424                     $numregs[NUMERICAL_ABS_ERROR_MARGIN];
425                 } else {
426                     $wrapped->tolerance["{$answerindex}"] = 0;
427                 }
428             } else { // Tolerance can stay undefined for non numerical questions.
429                 // Undo quoting done by the HTML editor.
430                 $answer = html_entity_decode(
431                         $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], ENT_QUOTES, 'UTF-8');
432                 $answer = str_replace('\}', '}', $answer);
433                 $wrapped->answer["{$answerindex}"] = str_replace('\#', '#', $answer);
434                 if ($wrapped->qtype == 'multichoice') {
435                     $wrapped->answer["{$answerindex}"] = array(
436                             'text' => $wrapped->answer["{$answerindex}"],
437                             'format' => FORMAT_HTML,
438                             'itemid' => '');
439                 }
440             }
441             $tmp = explode($altregs[0], $remainingalts, 2);
442             $remainingalts = $tmp[1];
443             $answerindex++;
444         }
446         $question->defaultmark += $wrapped->defaultmark;
447         $question->options->questions[$positionkey] = clone($wrapped);
448         $question->questiontext['text'] = implode("{#$positionkey}",
449                     explode($answerregs[0], $question->questiontext['text'], 2));
450     }
451     return $question;