qtype multichoice MDL-25208 and another problem with saving Multiple choice questions.
[moodle.git] / question / type / multichoice / questiontype.php
CommitLineData
aeb15530 1<?php
1976496e 2/**
3e0647ff 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 *
1976496e 8 * @package questionbank
9 * @subpackage questiontypes
3e0647ff 10 */
af3830ee 11class question_multichoice_qtype extends default_questiontype {
516cf3eb 12
13 function name() {
14 return 'multichoice';
15 }
16
17 function get_question_options(&$question) {
fef8f84e 18 global $DB, $OUTPUT;
516cf3eb 19 // Get additional information from database
20 // and attach it to the question object
f34488b2 21 if (!$question->options = $DB->get_record('question_multichoice', array('question' => $question->id))) {
fef8f84e 22 echo $OUTPUT->notification('Error: Missing question options for multichoice question'.$question->id.'!');
516cf3eb 23 return false;
24 }
25
f34488b2 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')) {
fe6ce234
DC
28 echo $OUTPUT->notification('Error: Missing question answers for multichoice question'.$question->id.'!');
29 return false;
516cf3eb 30 }
31
32 return true;
33 }
34
35 function save_question_options($question) {
f34488b2 36 global $DB;
fe6ce234 37 $context = $question->context;
307f045f 38 $result = new stdClass;
69988ed4
TH
39
40 $oldanswers = $DB->get_records('question_answers',
41 array('question' => $question->id), 'id ASC');
516cf3eb 42
43 // following hack to check at least two answers exist
44 $answercount = 0;
69988ed4
TH
45 foreach ($question->answer as $key => $answer) {
46 if ($answer != '') {
516cf3eb 47 $answercount++;
48 }
49 }
516cf3eb 50 if ($answercount < 2) { // check there are at lest 2 answers for multiple choice
69988ed4 51 $result->notice = get_string('notenoughanswers', 'qtype_multichoice', '2');
516cf3eb 52 return $result;
53 }
54
55 // Insert all the new answers
516cf3eb 56 $totalfraction = 0;
57 $maxfraction = -1;
516cf3eb 58 $answers = array();
69988ed4
TH
59 foreach ($question->answer as $key => $answerdata) {
60 if ($answerdata == '') {
61 continue;
62 }
63
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 }
73
49da759b
TH
74 if (is_array($answerdata)) {
75 // Doing an import
679f7963 76 $answer->answer = $this->import_or_save_files($answerdata,
49da759b 77 $context, 'question', 'answerfeedback', $answer->id);
679f7963 78 $answer->answerformat = $answerdata['format'];
49da759b
TH
79 } else {
80 // Saving the form
81 $answer->answer = $answerdata;
82 $answer->answerformat = FORMAT_HTML;
83 }
69988ed4
TH
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'];
88
89 $DB->update_record('question_answers', $answer);
90 $answers[] = $answer->id;
516cf3eb 91
69988ed4
TH
92 if ($question->fraction[$key] > 0) {
93 $totalfraction += $question->fraction[$key];
516cf3eb 94 }
69988ed4
TH
95 if ($question->fraction[$key] > $maxfraction) {
96 $maxfraction = $question->fraction[$key];
97 }
98 }
99
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));
516cf3eb 105 }
106
69988ed4 107 $options = $DB->get_record('question_multichoice', array('question' => $question->id));
307f045f 108 if (!$options) {
307f045f 109 $options = new stdClass;
110 $options->question = $question->id;
69988ed4
TH
111 $options->correctfeedback = '';
112 $options->partiallycorrectfeedback = '';
113 $options->incorrectfeedback = '';
114 $options->id = $DB->insert_record('question_multichoice', $options);
307f045f 115 }
69988ed4
TH
116
117 $options->answers = implode(',', $answers);
307f045f 118 $options->single = $question->single;
69988ed4 119 if (isset($question->layout)) {
fe6ce234 120 $options->layout = $question->layout;
e5ebbd53 121 }
3e0647ff 122 $options->answernumbering = $question->answernumbering;
307f045f 123 $options->shuffleanswers = $question->shuffleanswers;
69988ed4
TH
124 $options->correctfeedback = $this->import_or_save_files($question->correctfeedback,
125 $context, 'qtype_multichoice', 'correctfeedback', $question->id);
126 $options->correctfeedbackformat = $question->correctfeedback['format'];
26f09e62 127 $options->partiallycorrectfeedback = $this->import_or_save_files($question->partiallycorrectfeedback,
69988ed4
TH
128 $context, 'qtype_multichoice', 'partiallycorrectfeedback', $question->id);
129 $options->partiallycorrectfeedbackformat = $question->partiallycorrectfeedback['format'];
26f09e62 130 $options->incorrectfeedback = $this->import_or_save_files($question->incorrectfeedback,
69988ed4
TH
131 $context, 'qtype_multichoice', 'incorrectfeedback', $question->id);
132 $options->incorrectfeedbackformat = $question->incorrectfeedback['format'];
133
134 $DB->update_record('question_multichoice', $options);
516cf3eb 135
136 /// Perform sanity checks on fractional grades
137 if ($options->single) {
138 if ($maxfraction != 1) {
69988ed4 139 $result->noticeyesno = get_string('fractionsnomax', 'qtype_multichoice', $maxfraction * 100);
516cf3eb 140 return $result;
141 }
142 } else {
69988ed4 143 $totalfraction = round($totalfraction, 2);
516cf3eb 144 if ($totalfraction != 1) {
69988ed4 145 $result->noticeyesno = get_string('fractionsaddwrong', 'qtype_multichoice', $totalfraction * 100);
516cf3eb 146 return $result;
147 }
148 }
69988ed4 149
516cf3eb 150 return true;
151 }
152
9203b705 153 function delete_question($questionid, $contextid) {
f34488b2 154 global $DB;
9203b705
TH
155 $DB->delete_records('question_multichoice', array('question' => $questionid));
156
157 parent::delete_question($questionid, $contextid);
516cf3eb 158 }
159
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',
fe6ce234 163 'return $val->id;'), $question->options->answers));
516cf3eb 164 // Shuffle the answers if required
16590517 165 if (!empty($cmoptions->shuffleanswers) and !empty($question->options->shuffleanswers)) {
fe6ce234 166 $answerids = swapshuffle($answerids);
516cf3eb 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 }
177
178
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.
187
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
ceeae340 192 $state->options->order = explode(',', substr($state->responses[''], 0, $pos));
516cf3eb 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
ceeae340 202 $state->responses = array('' => '');
516cf3eb 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',
fe6ce234 213 '$a = $b;'));
516cf3eb 214 }
215 }
216 return true;
217 }
218
219 function save_session_and_responses(&$question, &$state) {
f34488b2 220 global $DB;
516cf3eb 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);
232
233 // Set the legacy answer field
f685e830 234 $DB->set_field('question_states', 'answer', $responses, array('id' => $state->id));
516cf3eb 235 return true;
236 }
237
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 }
256
257 function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
258 global $CFG;
259
fe6ce234
DC
260 // required by file api
261 $context = $this->get_context_by_category_id($question->category);
262 $component = 'qtype_' . $question->qtype;
263
516cf3eb 264 $answers = &$question->options->answers;
265 $correctanswers = $this->get_correct_responses($question, $state);
1cc1fa24 266 $readonly = empty($options->readonly) ? '' : 'disabled="disabled"';
516cf3eb 267
268 $formatoptions = new stdClass;
7347c60b 269 $formatoptions->noclean = true;
516cf3eb 270 $formatoptions->para = false;
271
272 // Print formulation
fe6ce234
DC
273 $questiontext = format_text($question->questiontext, $question->questiontextformat,
274 $formatoptions, $cmoptions->course);
516cf3eb 275 $answerprompt = ($question->options->single) ? get_string('singleanswer', 'quiz') :
276 get_string('multipleanswers', 'quiz');
277
278 // Print each answer in a separate row
279 foreach ($state->options->order as $key => $aid) {
280 $answer = &$answers[$aid];
516cf3eb 281 $checked = '';
2b087056 282 $chosen = false;
7b41a4a9 283
516cf3eb 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"';
2b087056 289 $chosen = true;
516cf3eb 290 }
291 } else {
292 $type = ' type="checkbox" ';
293 $name = "name=\"{$question->name_prefix}{$aid}\"";
e0c25647 294 if (isset($state->responses[$aid])) {
2b087056 295 $checked = 'checked="checked"';
296 $chosen = true;
297 }
516cf3eb 298 }
299
307f045f 300 $a = new stdClass;
516cf3eb 301 $a->id = $question->name_prefix . $aid;
2b087056 302 $a->class = '';
303 $a->feedbackimg = '';
516cf3eb 304
305 // Print the control
b60af635 306 $a->control = "<input $readonly id=\"$a->id\" $name $checked $type value=\"$aid\" />";
516cf3eb 307
1b16ecd1 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) {
a656e0de 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 }
2b087056 317 }
516cf3eb 318
319 // Print the answer text
b60af635 320 $a->text = $this->number_in_style($key, $question->options->answernumbering) .
fe6ce234 321 format_text($answer->answer, $answer->answerformat, $formatoptions, $cmoptions->course);
516cf3eb 322
323 // Print feedback if feedback is on
b3efa707 324 if (($options->feedback || $options->correct_responses) && $checked) {
fe6ce234
DC
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);
1b16ecd1 328 } else {
329 $a->feedback = '';
330 }
516cf3eb 331
332 $anss[] = clone($a);
333 }
7b41a4a9 334
307f045f 335 $feedback = '';
336 if ($options->feedback) {
337 if ($state->raw_grade >= $question->maxgrade/1.01) {
7b41a4a9 338 $feedback = $question->options->correctfeedback;
fe6ce234 339 $feedbacktype = 'correctfeedback';
307f045f 340 } else if ($state->raw_grade > 0) {
7b41a4a9 341 $feedback = $question->options->partiallycorrectfeedback;
fe6ce234 342 $feedbacktype = 'partiallycorrectfeedback';
307f045f 343 } else {
7b41a4a9 344 $feedback = $question->options->incorrectfeedback;
fe6ce234 345 $feedbacktype = 'incorrectfeedback';
307f045f 346 }
fe6ce234
DC
347
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);
307f045f 351 }
7b41a4a9 352
aaae75b0 353 include("$CFG->dirroot/question/type/multichoice/display.html");
516cf3eb 354 }
355
f2ca88b0
TH
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 }
372
516cf3eb 373 function grade_responses(&$question, &$state, $cmoptions) {
f38ceee6 374 $state->raw_grade = 0;
d3b92b4b 375 if($question->options->single) {
ef95c65e 376 $response = reset($state->responses);
377 if ($response) {
378 $state->raw_grade = $question->options->answers[$response]->fraction;
ef95c65e 379 }
d3b92b4b 380 } else {
d3b92b4b 381 foreach ($state->responses as $response) {
ef95c65e 382 if ($response) {
383 $state->raw_grade += $question->options->answers[$response]->fraction;
384 }
d3b92b4b 385 }
516cf3eb 386 }
f38ceee6 387
516cf3eb 388 // Make sure we don't assign negative or too high marks
389 $state->raw_grade = min(max((float) $state->raw_grade,
fe6ce234 390 0.0), 1.0) * $question->maxgrade;
f38ceee6 391
516cf3eb 392 // Apply the penalty for this attempt
393 $state->penalty = $question->penalty * $question->maxgrade;
394
f30bbcaf 395 // mark the state as graded
396 $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE;
397
516cf3eb 398 return true;
399 }
400
401 // ULPGC ecastro
402 function get_actual_response($question, $state) {
403 $answers = $question->options->answers;
08eef20d 404 $responses = array();
516cf3eb 405 if (!empty($state->responses)) {
406 foreach ($state->responses as $aid =>$rid){
08eef20d 407 if (!empty($answers[$rid])) {
2280e147 408 $responses[] = $answers[$rid]->answer;
08eef20d 409 }
516cf3eb 410 }
411 } else {
412 $responses[] = '';
413 }
414 return $responses;
415 }
7b41a4a9 416
6f51ed72 417 /**
418 * @param object $question
455c3efa 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.
6f51ed72 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 }
c5d94c41 430
3e0647ff 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() {
c389b504 438 return array('abc', 'ABCD', '123', 'none');
3e0647ff 439 }
271e6dec 440
b60af635 441 function number_html($qnum) {
442 return '<span class="anun">' . $qnum . '<span class="anumsep">.</span></span> ';
443 }
3e0647ff 444
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) {
fe6ce234
DC
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';
3e0647ff 462 }
463 }
271e6dec 464
b9bd6da4 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';
486
487 if ($courseid) {
488 $course = $DB->get_record('course', array('id' => $courseid));
489 }
490
491 return $this->save_question($question, $form, $course);
492 }
fe6ce234 493
5d548d3e 494 function move_files($questionid, $oldcontextid, $newcontextid) {
fe6ce234 495 $fs = get_file_storage();
fe6ce234 496
5d548d3e
TH
497 parent::move_files($questionid, $oldcontextid, $newcontextid);
498 $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid, true);
499
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);
fe6ce234
DC
506 }
507
9203b705
TH
508 protected function delete_files($questionid, $contextid) {
509 $fs = get_file_storage();
510
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 }
517
fe6ce234
DC
518 function check_file_access($question, $state, $options, $contextid, $component,
519 $filearea, $args) {
520 $itemid = reset($args);
521
522 if (empty($question->maxgrade)) {
523 $question->maxgrade = $question->defaultgrade;
524 }
525
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 }
516cf3eb 549}
516cf3eb 550
3e0647ff 551// Register this question type with the question bank.
a2156789 552question_register_questiontype(new question_multichoice_qtype());