e1acd618ad6dc6d52337ebb07fa6559a12ac27b2
[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');
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         }
74         $question->hints = $DB->get_records('question_hints',
75                 array('questionid' => $question->id), 'id ASC');
77         return true;
78     }
80     public function save_question_options($question) {
81         global $DB;
82         $result = new stdClass();
84         // This function needs to be able to handle the case where the existing set of wrapped
85         // questions does not match the new set of wrapped questions so that some need to be
86         // created, some modified and some deleted.
87         // Unfortunately the code currently simply overwrites existing ones in sequence. This
88         // will make re-marking after a re-ordering of wrapped questions impossible and
89         // will also create difficulties if questiontype specific tables reference the id.
91         // First we get all the existing wrapped questions.
92         if (!$oldwrappedids = $DB->get_field('question_multianswer', 'sequence',
93                 array('question' => $question->id))) {
94             $oldwrappedquestions = array();
95         } else {
96             $oldwrappedquestions = $DB->get_records_list('question', 'id',
97                     explode(',', $oldwrappedids), 'id ASC');
98         }
100         $sequence = array();
101         foreach ($question->options->questions as $wrapped) {
102             if (!empty($wrapped)) {
103                 // If we still have some old wrapped question ids, reuse the next of them.
105                 if (is_array($oldwrappedquestions) &&
106                         $oldwrappedquestion = array_shift($oldwrappedquestions)) {
107                     $wrapped->id = $oldwrappedquestion->id;
108                     if ($oldwrappedquestion->qtype != $wrapped->qtype) {
109                         switch ($oldwrappedquestion->qtype) {
110                             case 'multichoice':
111                                 $DB->delete_records('qtype_multichoice_options',
112                                         array('questionid' => $oldwrappedquestion->id));
113                                 break;
114                             case 'shortanswer':
115                                 $DB->delete_records('qtype_shortanswer_options',
116                                         array('questionid' => $oldwrappedquestion->id));
117                                 break;
118                             case 'numerical':
119                                 $DB->delete_records('question_numerical',
120                                         array('question' => $oldwrappedquestion->id));
121                                 break;
122                             default:
123                                 throw new moodle_exception('qtypenotrecognized',
124                                         'qtype_multianswer', '', $oldwrappedquestion->qtype);
125                                 $wrapped->id = 0;
126                         }
127                     }
128                 } else {
129                     $wrapped->id = 0;
130                 }
131             }
132             $wrapped->name = $question->name;
133             $wrapped->parent = $question->id;
134             $previousid = $wrapped->id;
135             // Save_question strips this extra bit off the category again.
136             $wrapped->category = $question->category . ',1';
137             $wrapped = question_bank::get_qtype($wrapped->qtype)->save_question(
138                     $wrapped, clone($wrapped));
139             $sequence[] = $wrapped->id;
140             if ($previousid != 0 && $previousid != $wrapped->id) {
141                 // For some reasons a new question has been created
142                 // so delete the old one.
143                 question_delete_question($previousid);
144             }
145         }
147         // Delete redundant wrapped questions.
148         if (is_array($oldwrappedquestions) && count($oldwrappedquestions)) {
149             foreach ($oldwrappedquestions as $oldwrappedquestion) {
150                 question_delete_question($oldwrappedquestion->id);
151             }
152         }
154         if (!empty($sequence)) {
155             $multianswer = new stdClass();
156             $multianswer->question = $question->id;
157             $multianswer->sequence = implode(',', $sequence);
158             if ($oldid = $DB->get_field('question_multianswer', 'id',
159                     array('question' => $question->id))) {
160                 $multianswer->id = $oldid;
161                 $DB->update_record('question_multianswer', $multianswer);
162             } else {
163                 $DB->insert_record('question_multianswer', $multianswer);
164             }
165         }
167         $this->save_hints($question, true);
168     }
170     public function save_question($authorizedquestion, $form) {
171         $question = qtype_multianswer_extract_question($form->questiontext);
172         if (isset($authorizedquestion->id)) {
173             $question->id = $authorizedquestion->id;
174         }
176         $question->category = $authorizedquestion->category;
177         $form->defaultmark = $question->defaultmark;
178         $form->questiontext = $question->questiontext;
179         $form->questiontextformat = 0;
180         $form->options = clone($question->options);
181         unset($question->options);
182         return parent::save_question($question, $form);
183     }
185     protected function make_hint($hint) {
186         return question_hint_with_parts::load_from_record($hint);
187     }
189     public function delete_question($questionid, $contextid) {
190         global $DB;
191         $DB->delete_records('question_multianswer', array('question' => $questionid));
193         parent::delete_question($questionid, $contextid);
194     }
196     protected function initialise_question_instance(question_definition $question, $questiondata) {
197         parent::initialise_question_instance($question, $questiondata);
199         $bits = preg_split('/\{#(\d+)\}/', $question->questiontext,
200                 null, PREG_SPLIT_DELIM_CAPTURE);
201         $question->textfragments[0] = array_shift($bits);
202         $i = 1;
203         while (!empty($bits)) {
204             $question->places[$i] = array_shift($bits);
205             $question->textfragments[$i] = array_shift($bits);
206             $i += 1;
207         }
208         foreach ($questiondata->options->questions as $key => $subqdata) {
209             $subqdata->contextid = $questiondata->contextid;
210             if ($subqdata->qtype == 'multichoice') {
211                 $answerregs = array();
212                 if ($subqdata->options->shuffleanswers == 1 &&  isset($questiondata->options->shuffleanswers)
213                     && $questiondata->options->shuffleanswers == 0 ) {
214                     $subqdata->options->shuffleanswers = 0;
215                 }
216             }
217             $question->subquestions[$key] = question_bank::make_question($subqdata);
218             $question->subquestions[$key]->maxmark = $subqdata->defaultmark;
219             if (isset($subqdata->options->layout)) {
220                 $question->subquestions[$key]->layout = $subqdata->options->layout;
221             }
222         }
223     }
225     public function get_random_guess_score($questiondata) {
226         $fractionsum = 0;
227         $fractionmax = 0;
228         foreach ($questiondata->options->questions as $key => $subqdata) {
229             $fractionmax += $subqdata->defaultmark;
230             $fractionsum += question_bank::get_qtype(
231                     $subqdata->qtype)->get_random_guess_score($subqdata);
232         }
233         return $fractionsum / $fractionmax;
234     }
236     public function move_files($questionid, $oldcontextid, $newcontextid) {
237         parent::move_files($questionid, $oldcontextid, $newcontextid);
238         $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
239     }
241     protected function delete_files($questionid, $contextid) {
242         parent::delete_files($questionid, $contextid);
243         $this->delete_files_in_hints($questionid, $contextid);
244     }
248 // ANSWER_ALTERNATIVE regexes.
249 define('ANSWER_ALTERNATIVE_FRACTION_REGEX',
250        '=|%(-?[0-9]+)%');
251 // For the syntax '(?<!' see http://www.perl.com/doc/manual/html/pod/perlre.html#item_C.
252 define('ANSWER_ALTERNATIVE_ANSWER_REGEX',
253         '.+?(?<!\\\\|&|&amp;)(?=[~#}]|$)');
254 define('ANSWER_ALTERNATIVE_FEEDBACK_REGEX',
255         '.*?(?<!\\\\)(?=[~}]|$)');
256 define('ANSWER_ALTERNATIVE_REGEX',
257        '(' . ANSWER_ALTERNATIVE_FRACTION_REGEX .')?' .
258        '(' . ANSWER_ALTERNATIVE_ANSWER_REGEX . ')' .
259        '(#(' . ANSWER_ALTERNATIVE_FEEDBACK_REGEX .'))?');
261 // Parenthesis positions for ANSWER_ALTERNATIVE_REGEX.
262 define('ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION', 2);
263 define('ANSWER_ALTERNATIVE_REGEX_FRACTION', 1);
264 define('ANSWER_ALTERNATIVE_REGEX_ANSWER', 3);
265 define('ANSWER_ALTERNATIVE_REGEX_FEEDBACK', 5);
267 // NUMBER_FORMATED_ALTERNATIVE_ANSWER_REGEX is used
268 // for identifying numerical answers in ANSWER_ALTERNATIVE_REGEX_ANSWER.
269 define('NUMBER_REGEX',
270         '-?(([0-9]+[.,]?[0-9]*|[.,][0-9]+)([eE][-+]?[0-9]+)?)');
271 define('NUMERICAL_ALTERNATIVE_REGEX',
272         '^(' . NUMBER_REGEX . ')(:' . NUMBER_REGEX . ')?$');
274 // Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX.
275 define('NUMERICAL_CORRECT_ANSWER', 1);
276 define('NUMERICAL_ABS_ERROR_MARGIN', 6);
278 // Remaining ANSWER regexes.
279 define('ANSWER_TYPE_DEF_REGEX',
280         '(NUMERICAL|NM)|(MULTICHOICE|MC)|(MULTICHOICE_V|MCV)|(MULTICHOICE_H|MCH)|' .
281         '(SHORTANSWER|SA|MW)|(SHORTANSWER_C|SAC|MWC)|' .
282         '(MULTICHOICE_S|MCS)|(MULTICHOICE_VS|MCVS)|(MULTICHOICE_HS|MCHS)');
283 define('ANSWER_START_REGEX',
284        '\{([0-9]*):(' . ANSWER_TYPE_DEF_REGEX . '):');
286 define('ANSWER_REGEX',
287         ANSWER_START_REGEX
288         . '(' . ANSWER_ALTERNATIVE_REGEX
289         . '(~'
290         . ANSWER_ALTERNATIVE_REGEX
291         . ')*)\}');
293 // Parenthesis positions for singulars in ANSWER_REGEX.
294 define('ANSWER_REGEX_NORM', 1);
295 define('ANSWER_REGEX_ANSWER_TYPE_NUMERICAL', 3);
296 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE', 4);
297 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR', 5);
298 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL', 6);
299 define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER', 7);
300 define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C', 8);
301 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED', 9);
302 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED', 10);
303 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED', 11);
304 define('ANSWER_REGEX_ALTERNATIVES', 12);
306 /**
307  * Initialise subquestion fields that are constant across all MULTICHOICE
308  * types.
309  *
310  * @param objet $wrapped  The subquestion to initialise
311  *
312  */
313 function qtype_multianswer_initialise_multichoice_subquestion($wrapped) {
314     $wrapped->qtype = 'multichoice';
315     $wrapped->single = 1;
316     $wrapped->answernumbering = 0;
317     $wrapped->correctfeedback['text'] = '';
318     $wrapped->correctfeedback['format'] = FORMAT_HTML;
319     $wrapped->correctfeedback['itemid'] = '';
320     $wrapped->partiallycorrectfeedback['text'] = '';
321     $wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML;
322     $wrapped->partiallycorrectfeedback['itemid'] = '';
323     $wrapped->incorrectfeedback['text'] = '';
324     $wrapped->incorrectfeedback['format'] = FORMAT_HTML;
325     $wrapped->incorrectfeedback['itemid'] = '';
328 function qtype_multianswer_extract_question($text) {
329     // Variable $text is an array [text][format][itemid].
330     $question = new stdClass();
331     $question->qtype = 'multianswer';
332     $question->questiontext = $text;
333     $question->generalfeedback['text'] = '';
334     $question->generalfeedback['format'] = FORMAT_HTML;
335     $question->generalfeedback['itemid'] = '';
337     $question->options = new stdClass();
338     $question->options->questions = array();
339     $question->defaultmark = 0; // Will be increased for each answer norm.
341     for ($positionkey = 1;
342             preg_match('/'.ANSWER_REGEX.'/s', $question->questiontext['text'], $answerregs);
343             ++$positionkey) {
344         $wrapped = new stdClass();
345         $wrapped->generalfeedback['text'] = '';
346         $wrapped->generalfeedback['format'] = FORMAT_HTML;
347         $wrapped->generalfeedback['itemid'] = '';
348         if (isset($answerregs[ANSWER_REGEX_NORM]) && $answerregs[ANSWER_REGEX_NORM] !== '') {
349             $wrapped->defaultmark = $answerregs[ANSWER_REGEX_NORM];
350         } else {
351             $wrapped->defaultmark = '1';
352         }
353         if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])) {
354             $wrapped->qtype = 'numerical';
355             $wrapped->multiplier = array();
356             $wrapped->units      = array();
357             $wrapped->instructions['text'] = '';
358             $wrapped->instructions['format'] = FORMAT_HTML;
359             $wrapped->instructions['itemid'] = '';
360         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER])) {
361             $wrapped->qtype = 'shortanswer';
362             $wrapped->usecase = 0;
363         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C])) {
364             $wrapped->qtype = 'shortanswer';
365             $wrapped->usecase = 1;
366         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE])) {
367             qtype_multianswer_initialise_multichoice_subquestion($wrapped);
368             $wrapped->shuffleanswers = 0;
369             $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
370         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED])) {
371             qtype_multianswer_initialise_multichoice_subquestion($wrapped);
372             $wrapped->shuffleanswers = 1;
373             $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
374         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR])) {
375             qtype_multianswer_initialise_multichoice_subquestion($wrapped);
376             $wrapped->shuffleanswers = 0;
377             $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
378         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED])) {
379             qtype_multianswer_initialise_multichoice_subquestion($wrapped);
380             $wrapped->shuffleanswers = 1;
381             $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
382         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL])) {
383             qtype_multianswer_initialise_multichoice_subquestion($wrapped);
384             $wrapped->shuffleanswers = 0;
385             $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
386         } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED])) {
387             qtype_multianswer_initialise_multichoice_subquestion($wrapped);
388             $wrapped->shuffleanswers = 1;
389             $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
390         } else {
391             print_error('unknownquestiontype', 'question', '', $answerregs[2]);
392             return false;
393         }
395         // Each $wrapped simulates a $form that can be processed by the
396         // respective save_question and save_question_options methods of the
397         // wrapped questiontypes.
398         $wrapped->answer   = array();
399         $wrapped->fraction = array();
400         $wrapped->feedback = array();
401         $wrapped->questiontext['text'] = $answerregs[0];
402         $wrapped->questiontext['format'] = FORMAT_HTML;
403         $wrapped->questiontext['itemid'] = '';
404         $answerindex = 0;
406         $remainingalts = $answerregs[ANSWER_REGEX_ALTERNATIVES];
407         while (preg_match('/~?'.ANSWER_ALTERNATIVE_REGEX.'/s', $remainingalts, $altregs)) {
408             if ('=' == $altregs[ANSWER_ALTERNATIVE_REGEX_FRACTION]) {
409                 $wrapped->fraction["{$answerindex}"] = '1';
410             } else if ($percentile = $altregs[ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION]) {
411                 $wrapped->fraction["{$answerindex}"] = .01 * $percentile;
412             } else {
413                 $wrapped->fraction["{$answerindex}"] = '0';
414             }
415             if (isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])) {
416                 $feedback = html_entity_decode(
417                         $altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK], ENT_QUOTES, 'UTF-8');
418                 $feedback = str_replace('\}', '}', $feedback);
419                 $wrapped->feedback["{$answerindex}"]['text'] = str_replace('\#', '#', $feedback);
420                 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
421                 $wrapped->feedback["{$answerindex}"]['itemid'] = '';
422             } else {
423                 $wrapped->feedback["{$answerindex}"]['text'] = '';
424                 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
425                 $wrapped->feedback["{$answerindex}"]['itemid'] = '';
427             }
428             if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])
429                     && preg_match('~'.NUMERICAL_ALTERNATIVE_REGEX.'~s',
430                             $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], $numregs)) {
431                 $wrapped->answer[] = $numregs[NUMERICAL_CORRECT_ANSWER];
432                 if (array_key_exists(NUMERICAL_ABS_ERROR_MARGIN, $numregs)) {
433                     $wrapped->tolerance["{$answerindex}"] =
434                     $numregs[NUMERICAL_ABS_ERROR_MARGIN];
435                 } else {
436                     $wrapped->tolerance["{$answerindex}"] = 0;
437                 }
438             } else { // Tolerance can stay undefined for non numerical questions.
439                 // Undo quoting done by the HTML editor.
440                 $answer = html_entity_decode(
441                         $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], ENT_QUOTES, 'UTF-8');
442                 $answer = str_replace('\}', '}', $answer);
443                 $wrapped->answer["{$answerindex}"] = str_replace('\#', '#', $answer);
444                 if ($wrapped->qtype == 'multichoice') {
445                     $wrapped->answer["{$answerindex}"] = array(
446                             'text' => $wrapped->answer["{$answerindex}"],
447                             'format' => FORMAT_HTML,
448                             'itemid' => '');
449                 }
450             }
451             $tmp = explode($altregs[0], $remainingalts, 2);
452             $remainingalts = $tmp[1];
453             $answerindex++;
454         }
456         $question->defaultmark += $wrapped->defaultmark;
457         $question->options->questions[$positionkey] = clone($wrapped);
458         $question->questiontext['text'] = implode("{#$positionkey}",
459                     explode($answerregs[0], $question->questiontext['text'], 2));
460     }
461     return $question;