qtype multichoice MDL-25208 and another problem with saving Multiple choice questions.
[moodle.git] / question / type / multichoice / questiontype.php
1 <?php
2 /**
3  * The questiontype class for the multiple choice question type.
4  *
5  * Note, This class contains some special features in order to make the
6  * question type embeddable within a multianswer (cloze) question
7  *
8  * @package questionbank
9  * @subpackage questiontypes
10  */
11 class question_multichoice_qtype extends default_questiontype {
13     function name() {
14         return 'multichoice';
15     }
17     function get_question_options(&$question) {
18         global $DB, $OUTPUT;
19         // Get additional information from database
20         // and attach it to the question object
21         if (!$question->options = $DB->get_record('question_multichoice', array('question' => $question->id))) {
22             echo $OUTPUT->notification('Error: Missing question options for multichoice question'.$question->id.'!');
23             return false;
24         }
26         list ($usql, $params) = $DB->get_in_or_equal(explode(',', $question->options->answers));
27         if (!$question->options->answers = $DB->get_records_select('question_answers', "id $usql", $params, 'id')) {
28             echo $OUTPUT->notification('Error: Missing question answers for multichoice question'.$question->id.'!');
29             return false;
30         }
32         return true;
33     }
35     function save_question_options($question) {
36         global $DB;
37         $context = $question->context;
38         $result = new stdClass;
40         $oldanswers = $DB->get_records('question_answers',
41                 array('question' => $question->id), 'id ASC');
43         // following hack to check at least two answers exist
44         $answercount = 0;
45         foreach ($question->answer as $key => $answer) {
46             if ($answer != '') {
47                 $answercount++;
48             }
49         }
50         if ($answercount < 2) { // check there are at lest 2 answers for multiple choice
51             $result->notice = get_string('notenoughanswers', 'qtype_multichoice', '2');
52             return $result;
53         }
55         // Insert all the new answers
56         $totalfraction = 0;
57         $maxfraction = -1;
58         $answers = array();
59         foreach ($question->answer as $key => $answerdata) {
60             if ($answerdata == '') {
61                 continue;
62             }
64             // Update an existing answer if possible.
65             $answer = array_shift($oldanswers);
66             if (!$answer) {
67                 $answer = new stdClass();
68                 $answer->question = $question->id;
69                 $answer->answer = '';
70                 $answer->feedback = '';
71                 $answer->id = $DB->insert_record('question_answers', $answer);
72             }
74             if (is_array($answerdata)) {
75                 // Doing an import
76                 $answer->answer = $this->import_or_save_files($answerdata,
77                         $context, 'question', 'answerfeedback', $answer->id);
78                 $answer->answerformat = $answerdata['format'];
79             } else {
80                 // Saving the form
81                 $answer->answer = $answerdata;
82                 $answer->answerformat = FORMAT_HTML;
83             }
84             $answer->fraction = $question->fraction[$key];
85             $answer->feedback = $this->import_or_save_files($question->feedback[$key],
86                     $context, 'question', 'answerfeedback', $answer->id);
87             $answer->feedbackformat = $question->feedback[$key]['format'];
89             $DB->update_record('question_answers', $answer);
90             $answers[] = $answer->id;
92             if ($question->fraction[$key] > 0) {
93                 $totalfraction += $question->fraction[$key];
94             }
95             if ($question->fraction[$key] > $maxfraction) {
96                 $maxfraction = $question->fraction[$key];
97             }
98         }
100         // Delete any left over old answer records.
101         $fs = get_file_storage();
102         foreach($oldanswers as $oldanswer) {
103             $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
104             $DB->delete_records('question_answers', array('id' => $oldanswer->id));
105         }
107         $options = $DB->get_record('question_multichoice', array('question' => $question->id));
108         if (!$options) {
109             $options = new stdClass;
110             $options->question = $question->id;
111             $options->correctfeedback = '';
112             $options->partiallycorrectfeedback = '';
113             $options->incorrectfeedback = '';
114             $options->id = $DB->insert_record('question_multichoice', $options);
115         }
117         $options->answers = implode(',', $answers);
118         $options->single = $question->single;
119         if (isset($question->layout)) {
120             $options->layout = $question->layout;
121         }
122         $options->answernumbering = $question->answernumbering;
123         $options->shuffleanswers = $question->shuffleanswers;
124         $options->correctfeedback = $this->import_or_save_files($question->correctfeedback,
125                 $context, 'qtype_multichoice', 'correctfeedback', $question->id);
126         $options->correctfeedbackformat = $question->correctfeedback['format'];
127         $options->partiallycorrectfeedback = $this->import_or_save_files($question->partiallycorrectfeedback,
128                 $context, 'qtype_multichoice', 'partiallycorrectfeedback', $question->id);
129         $options->partiallycorrectfeedbackformat = $question->partiallycorrectfeedback['format'];
130         $options->incorrectfeedback = $this->import_or_save_files($question->incorrectfeedback,
131                 $context, 'qtype_multichoice', 'incorrectfeedback', $question->id);
132         $options->incorrectfeedbackformat = $question->incorrectfeedback['format'];
134         $DB->update_record('question_multichoice', $options);
136         /// Perform sanity checks on fractional grades
137         if ($options->single) {
138             if ($maxfraction != 1) {
139                 $result->noticeyesno = get_string('fractionsnomax', 'qtype_multichoice', $maxfraction * 100);
140                 return $result;
141             }
142         } else {
143             $totalfraction = round($totalfraction, 2);
144             if ($totalfraction != 1) {
145                 $result->noticeyesno = get_string('fractionsaddwrong', 'qtype_multichoice', $totalfraction * 100);
146                 return $result;
147             }
148         }
150         return true;
151     }
153     function delete_question($questionid, $contextid) {
154         global $DB;
155         $DB->delete_records('question_multichoice', array('question' => $questionid));
157         parent::delete_question($questionid, $contextid);
158     }
160     function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) {
161         // create an array of answerids ??? why so complicated ???
162         $answerids = array_values(array_map(create_function('$val',
163             'return $val->id;'), $question->options->answers));
164         // Shuffle the answers if required
165         if (!empty($cmoptions->shuffleanswers) and !empty($question->options->shuffleanswers)) {
166             $answerids = swapshuffle($answerids);
167         }
168         $state->options->order = $answerids;
169         // Create empty responses
170         if ($question->options->single) {
171             $state->responses = array('' => '');
172         } else {
173             $state->responses = array();
174         }
175         return true;
176     }
179     function restore_session_and_responses(&$question, &$state) {
180         // The serialized format for multiple choice quetsions
181         // is an optional comma separated list of answer ids (the order of the
182         // answers) followed by a colon, followed by another comma separated
183         // list of answer ids, which are the radio/checkboxes that were
184         // ticked.
185         // E.g. 1,3,2,4:2,4 means that the answers were shown in the order
186         // 1, 3, 2 and then 4 and the answers 2 and 4 were checked.
188         $pos = strpos($state->responses[''], ':');
189         if (false === $pos) { // No order of answers is given, so use the default
190             $state->options->order = array_keys($question->options->answers);
191         } else { // Restore the order of the answers
192             $state->options->order = explode(',', substr($state->responses[''], 0, $pos));
193             $state->responses[''] = substr($state->responses[''], $pos + 1);
194         }
195         // Restore the responses
196         // This is done in different ways if only a single answer is allowed or
197         // if multiple answers are allowed. For single answers the answer id is
198         // saved in $state->responses[''], whereas for the multiple answers case
199         // the $state->responses array is indexed by the answer ids and the
200         // values are also the answer ids (i.e. key = value).
201         if (empty($state->responses[''])) { // No previous responses
202             $state->responses = array('' => '');
203         } else {
204             if ($question->options->single) {
205                 $state->responses = array('' => $state->responses['']);
206             } else {
207                 // Get array of answer ids
208                 $state->responses = explode(',', $state->responses['']);
209                 // Create an array indexed by these answer ids
210                 $state->responses = array_flip($state->responses);
211                 // Set the value of each element to be equal to the index
212                 array_walk($state->responses, create_function('&$a, $b',
213                     '$a = $b;'));
214             }
215         }
216         return true;
217     }
219     function save_session_and_responses(&$question, &$state) {
220         global $DB;
221         // Bundle the answer order and the responses into the legacy answer
222         // field.
223         // The serialized format for multiple choice quetsions
224         // is (optionally) a comma separated list of answer ids
225         // followed by a colon, followed by another comma separated
226         // list of answer ids, which are the radio/checkboxes that were
227         // ticked.
228         // E.g. 1,3,2,4:2,4 means that the answers were shown in the order
229         // 1, 3, 2 and then 4 and the answers 2 and 4 were checked.
230         $responses  = implode(',', $state->options->order) . ':';
231         $responses .= implode(',', $state->responses);
233         // Set the legacy answer field
234         $DB->set_field('question_states', 'answer', $responses, array('id' => $state->id));
235         return true;
236     }
238     function get_correct_responses(&$question, &$state) {
239         if ($question->options->single) {
240             foreach ($question->options->answers as $answer) {
241                 if (((int) $answer->fraction) === 1) {
242                     return array('' => $answer->id);
243                 }
244             }
245             return null;
246         } else {
247             $responses = array();
248             foreach ($question->options->answers as $answer) {
249                 if (((float) $answer->fraction) > 0.0) {
250                     $responses[$answer->id] = (string) $answer->id;
251                 }
252             }
253             return empty($responses) ? null : $responses;
254         }
255     }
257     function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
258         global $CFG;
260         // required by file api
261         $context = $this->get_context_by_category_id($question->category);
262         $component = 'qtype_' . $question->qtype;
264         $answers = &$question->options->answers;
265         $correctanswers = $this->get_correct_responses($question, $state);
266         $readonly = empty($options->readonly) ? '' : 'disabled="disabled"';
268         $formatoptions = new stdClass;
269         $formatoptions->noclean = true;
270         $formatoptions->para = false;
272         // Print formulation
273         $questiontext = format_text($question->questiontext, $question->questiontextformat,
274             $formatoptions, $cmoptions->course);
275         $answerprompt = ($question->options->single) ? get_string('singleanswer', 'quiz') :
276             get_string('multipleanswers', 'quiz');
278         // Print each answer in a separate row
279         foreach ($state->options->order as $key => $aid) {
280             $answer = &$answers[$aid];
281             $checked = '';
282             $chosen = false;
284             if ($question->options->single) {
285                 $type = 'type="radio"';
286                 $name   = "name=\"{$question->name_prefix}\"";
287                 if (isset($state->responses['']) and $aid == $state->responses['']) {
288                     $checked = 'checked="checked"';
289                     $chosen = true;
290                 }
291             } else {
292                 $type = ' type="checkbox" ';
293                 $name   = "name=\"{$question->name_prefix}{$aid}\"";
294                 if (isset($state->responses[$aid])) {
295                     $checked = 'checked="checked"';
296                     $chosen = true;
297                 }
298             }
300             $a = new stdClass;
301             $a->id   = $question->name_prefix . $aid;
302             $a->class = '';
303             $a->feedbackimg = '';
305             // Print the control
306             $a->control = "<input $readonly id=\"$a->id\" $name $checked $type value=\"$aid\" />";
308             if ($options->correct_responses && $answer->fraction > 0) {
309                 $a->class = question_get_feedback_class(1);
310             }
311             if (($options->feedback && $chosen) || $options->correct_responses) {
312                 if ($type == ' type="checkbox" ') {
313                     $a->feedbackimg = question_get_feedback_image($answer->fraction > 0 ? 1 : 0, $chosen && $options->feedback);
314                 } else {
315                     $a->feedbackimg = question_get_feedback_image($answer->fraction, $chosen && $options->feedback);
316                 }
317             }
319             // Print the answer text
320             $a->text = $this->number_in_style($key, $question->options->answernumbering) .
321                 format_text($answer->answer, $answer->answerformat, $formatoptions, $cmoptions->course);
323             // Print feedback if feedback is on
324             if (($options->feedback || $options->correct_responses) && $checked) {
325                 // feedback for each answer
326                 $a->feedback = quiz_rewrite_question_urls($answer->feedback, 'pluginfile.php', $context->id, 'question', 'answerfeedback', array($state->attempt, $state->question), $answer->id);
327                 $a->feedback = format_text($a->feedback, $answer->feedbackformat, $formatoptions, $cmoptions->course);
328             } else {
329                 $a->feedback = '';
330             }
332             $anss[] = clone($a);
333         }
335         $feedback = '';
336         if ($options->feedback) {
337             if ($state->raw_grade >= $question->maxgrade/1.01) {
338                 $feedback = $question->options->correctfeedback;
339                 $feedbacktype = 'correctfeedback';
340             } else if ($state->raw_grade > 0) {
341                 $feedback = $question->options->partiallycorrectfeedback;
342                 $feedbacktype = 'partiallycorrectfeedback';
343             } else {
344                 $feedback = $question->options->incorrectfeedback;
345                 $feedbacktype = 'incorrectfeedback';
346             }
348             $feedback = quiz_rewrite_question_urls($feedback, 'pluginfile.php', $context->id, $component, $feedbacktype, array($state->attempt, $state->question), $question->id);
349             $feedbackformat = $feedbacktype . 'format';
350             $feedback = format_text($feedback, $question->options->$feedbackformat, $formatoptions, $cmoptions->course);
351         }
353         include("$CFG->dirroot/question/type/multichoice/display.html");
354     }
356     function compare_responses($question, $state, $teststate) {
357         if ($question->options->single) {
358             if (!empty($state->responses[''])) {
359                 return $state->responses[''] == $teststate->responses[''];
360             } else {
361                 return empty($teststate->response['']);
362             }
363         } else {
364             foreach ($question->options->answers as $ansid => $notused) {
365                 if (empty($state->responses[$ansid]) != empty($teststate->responses[$ansid])) {
366                     return false;
367                 }
368             }
369             return true;
370         }
371     }
373     function grade_responses(&$question, &$state, $cmoptions) {
374         $state->raw_grade = 0;
375         if($question->options->single) {
376             $response = reset($state->responses);
377             if ($response) {
378                 $state->raw_grade = $question->options->answers[$response]->fraction;
379             }
380         } else {
381             foreach ($state->responses as $response) {
382                 if ($response) {
383                     $state->raw_grade += $question->options->answers[$response]->fraction;
384                 }
385             }
386         }
388         // Make sure we don't assign negative or too high marks
389         $state->raw_grade = min(max((float) $state->raw_grade,
390             0.0), 1.0) * $question->maxgrade;
392         // Apply the penalty for this attempt
393         $state->penalty = $question->penalty * $question->maxgrade;
395         // mark the state as graded
396         $state->event = ($state->event ==  QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE;
398         return true;
399     }
401     // ULPGC ecastro
402     function get_actual_response($question, $state) {
403         $answers = $question->options->answers;
404         $responses = array();
405         if (!empty($state->responses)) {
406             foreach ($state->responses as $aid =>$rid){
407                 if (!empty($answers[$rid])) {
408                     $responses[] = $answers[$rid]->answer;
409                 }
410             }
411         } else {
412             $responses[] = '';
413         }
414         return $responses;
415     }
417     /**
418      * @param object $question
419      * @return mixed either a integer score out of 1 that the average random
420      * guess by a student might give or an empty string which means will not
421      * calculate.
422      */
423     function get_random_guess_score($question) {
424         $totalfraction = 0;
425         foreach ($question->options->answers as $answer){
426             $totalfraction += $answer->fraction;
427         }
428         return $totalfraction / count($question->options->answers);
429     }
431     /**
432      * @return array of the numbering styles supported. For each one, there
433      *      should be a lang string answernumberingxxx in teh qtype_multichoice
434      *      language file, and a case in the switch statement in number_in_style,
435      *      and it should be listed in the definition of this column in install.xml.
436      */
437     function get_numbering_styles() {
438         return array('abc', 'ABCD', '123', 'none');
439     }
441     function number_html($qnum) {
442         return '<span class="anun">' . $qnum . '<span class="anumsep">.</span></span> ';
443     }
445     /**
446      * @param int $num The number, starting at 0.
447      * @param string $style The style to render the number in. One of the ones returned by $numberingoptions.
448      * @return string the number $num in the requested style.
449      */
450     function number_in_style($num, $style) {
451         switch($style) {
452         case 'abc':
453             return $this->number_html(chr(ord('a') + $num));
454         case 'ABCD':
455             return $this->number_html(chr(ord('A') + $num));
456         case '123':
457             return $this->number_html(($num + 1));
458         case 'none':
459             return '';
460         default:
461             return 'ERR';
462         }
463     }
465     /**
466      * Runs all the code required to set up and save an essay question for testing purposes.
467      * Alternate DB table prefix may be used to facilitate data deletion.
468      */
469     function generate_test($name, $courseid = null) {
470         global $DB;
471         list($form, $question) = parent::generate_test($name, $courseid);
472         $question->category = $form->category;
473         $form->questiontext = "How old is the sun?";
474         $form->generalfeedback = "General feedback";
475         $form->penalty = 0.1;
476         $form->single = 1;
477         $form->shuffleanswers = 1;
478         $form->answernumbering = 'abc';
479         $form->noanswers = 3;
480         $form->answer = array('Ancient', '5 billion years old', '4.5 billion years old');
481         $form->fraction = array(0.3, 0.9, 1);
482         $form->feedback = array('True, but lacking in accuracy', 'Close, but no cigar!', 'Yep, that is it!');
483         $form->correctfeedback = 'Excellent!';
484         $form->incorrectfeedback = 'Nope!';
485         $form->partiallycorrectfeedback = 'Not bad';
487         if ($courseid) {
488             $course = $DB->get_record('course', array('id' => $courseid));
489         }
491         return $this->save_question($question, $form, $course);
492     }
494     function move_files($questionid, $oldcontextid, $newcontextid) {
495         $fs = get_file_storage();
497         parent::move_files($questionid, $oldcontextid, $newcontextid);
498         $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid, true);
500         $fs->move_area_files_to_new_context($oldcontextid,
501                 $newcontextid, 'qtype_multichoice', 'correctfeedback', $questionid);
502         $fs->move_area_files_to_new_context($oldcontextid,
503                 $newcontextid, 'qtype_multichoice', 'partiallycorrectfeedback', $questionid);
504         $fs->move_area_files_to_new_context($oldcontextid,
505                 $newcontextid, 'qtype_multichoice', 'incorrectfeedback', $questionid);
506     }
508     protected function delete_files($questionid, $contextid) {
509         $fs = get_file_storage();
511         parent::delete_files($questionid, $contextid);
512         $this->delete_files_in_answers($questionid, $contextid, true);
513         $fs->delete_area_files($contextid, 'qtype_multichoice', 'correctfeedback', $questionid);
514         $fs->delete_area_files($contextid, 'qtype_multichoice', 'partiallycorrectfeedback', $questionid);
515         $fs->delete_area_files($contextid, 'qtype_multichoice', 'incorrectfeedback', $questionid);
516     }
518     function check_file_access($question, $state, $options, $contextid, $component,
519             $filearea, $args) {
520         $itemid = reset($args);
522         if (empty($question->maxgrade)) {
523             $question->maxgrade = $question->defaultgrade;
524         }
526         if (in_array($filearea, array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
527             $result = $options->feedback && ($itemid == $question->id);
528             if (!$result) {
529                 return false;
530             }
531             if ($state->raw_grade >= $question->maxgrade/1.01) {
532                 $feedbacktype = 'correctfeedback';
533             } else if ($state->raw_grade > 0) {
534                 $feedbacktype = 'partiallycorrectfeedback';
535             } else {
536                 $feedbacktype = 'incorrectfeedback';
537             }
538             if ($feedbacktype != $filearea) {
539                 return false;
540             }
541             return true;
542         } else if ($component == 'question' && $filearea == 'answerfeedback') {
543             return $options->feedback && (array_key_exists($itemid, $question->options->answers));
544         } else {
545             return parent::check_file_access($question, $state, $options, $contextid, $component,
546                     $filearea, $args);
547         }
548     }
551 // Register this question type with the question bank.
552 question_register_questiontype(new question_multichoice_qtype());