3 * The questiontype class for the multiple choice question type.
5 * Note, This class contains some special features in order to make the
6 * question type embeddable within a multianswer (cloze) question
8 * @package questionbank
9 * @subpackage questiontypes
11 class question_multichoice_qtype
extends default_questiontype
{
17 function has_html_answers() {
21 function get_question_options(&$question) {
22 // Get additional information from database
23 // and attach it to the question object
24 if (!$question->options
= get_record('question_multichoice', 'question',
26 notify('Error: Missing question options for multichoice question'.$question->id
.'!');
30 if (!$question->options
->answers
= get_records_select('question_answers', 'id IN ('.$question->options
->answers
.')', 'id')) {
31 notify('Error: Missing question answers for multichoice question'.$question->id
.'!');
38 function save_question_options($question) {
39 $result = new stdClass
;
40 if (!$oldanswers = get_records("question_answers", "question",
41 $question->id
, "id ASC")) {
42 $oldanswers = array();
45 // following hack to check at least two answers exist
47 foreach ($question->answer
as $key=>$dataanswer) {
48 if ($dataanswer != "") {
52 $answercount +
= count($oldanswers);
53 if ($answercount < 2) { // check there are at lest 2 answers for multiple choice
54 $result->notice
= get_string("notenoughanswers", "qtype_multichoice", "2");
58 // Insert all the new answers
65 foreach ($question->answer
as $key => $dataanswer) {
66 if ($dataanswer != "") {
67 if ($answer = array_shift($oldanswers)) { // Existing answer, so reuse it
68 $answer->answer
= $dataanswer;
69 $answer->fraction
= $question->fraction
[$key];
70 $answer->feedback
= $question->feedback
[$key];
71 if (!update_record("question_answers", $answer)) {
72 $result->error
= "Could not update quiz answer! (id=$answer->id)";
77 $answer->answer
= $dataanswer;
78 $answer->question
= $question->id
;
79 $answer->fraction
= $question->fraction
[$key];
80 $answer->feedback
= $question->feedback
[$key];
81 if (!$answer->id
= insert_record("question_answers", $answer)) {
82 $result->error
= "Could not insert quiz answer! ";
86 $answers[] = $answer->id
;
88 if ($question->fraction
[$key] > 0) { // Sanity checks
89 $totalfraction +
= $question->fraction
[$key];
91 if ($question->fraction
[$key] > $maxfraction) {
92 $maxfraction = $question->fraction
[$key];
98 $options = get_record("question_multichoice", "question", $question->id
);
101 $options = new stdClass
;
102 $options->question
= $question->id
;
105 $options->answers
= implode(",",$answers);
106 $options->single
= $question->single
;
107 $options->answernumbering
= $question->answernumbering
;
108 $options->shuffleanswers
= $question->shuffleanswers
;
109 $options->correctfeedback
= trim($question->correctfeedback
);
110 $options->partiallycorrectfeedback
= trim($question->partiallycorrectfeedback
);
111 $options->incorrectfeedback
= trim($question->incorrectfeedback
);
113 if (!update_record("question_multichoice", $options)) {
114 $result->error
= "Could not update quiz multichoice options! (id=$options->id)";
118 if (!insert_record("question_multichoice", $options)) {
119 $result->error
= "Could not insert quiz multichoice options!";
124 // delete old answer records
125 if (!empty($oldanswers)) {
126 foreach($oldanswers as $oa) {
127 delete_records('question_answers', 'id', $oa->id
);
131 /// Perform sanity checks on fractional grades
132 if ($options->single
) {
133 if ($maxfraction != 1) {
134 $maxfraction = $maxfraction * 100;
135 $result->noticeyesno
= get_string("fractionsnomax", "qtype_multichoice", $maxfraction);
139 $totalfraction = round($totalfraction,2);
140 if ($totalfraction != 1) {
141 $totalfraction = $totalfraction * 100;
142 $result->noticeyesno
= get_string("fractionsaddwrong", "qtype_multichoice", $totalfraction);
150 * Deletes question from the question-type specific tables
152 * @return boolean Success/Failure
153 * @param object $question The question being deleted
155 function delete_question($questionid) {
156 delete_records("question_multichoice", "question", $questionid);
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 ($cmoptions->shuffleanswers
and $question->options
->shuffleanswers
) {
166 $answerids = swapshuffle($answerids);
168 $state->options
->order
= $answerids;
169 // Create empty responses
170 if ($question->options
->single
) {
171 $state->responses
= array('' => '');
173 $state->responses
= array();
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
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);
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('' => '');
204 if ($question->options
->single
) {
205 $state->responses
= array('' => $state->responses
['']);
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',
219 function save_session_and_responses(&$question, &$state) {
220 // Bundle the answer order and the responses into the legacy answer
222 // The serialized format for multiple choice quetsions
223 // is (optionally) a comma separated list of answer ids
224 // followed by a colon, followed by another comma separated
225 // list of answer ids, which are the radio/checkboxes that were
227 // E.g. 1,3,2,4:2,4 means that the answers were shown in the order
228 // 1, 3, 2 and then 4 and the answers 2 and 4 were checked.
229 $responses = implode(',', $state->options
->order
) . ':';
230 $responses .= implode(',', $state->responses
);
232 // Set the legacy answer field
233 if (!set_field('question_states', 'answer', $responses, 'id',
240 function get_correct_responses(&$question, &$state) {
241 if ($question->options
->single
) {
242 foreach ($question->options
->answers
as $answer) {
243 if (((int) $answer->fraction
) === 1) {
244 return array('' => $answer->id
);
249 $responses = array();
250 foreach ($question->options
->answers
as $answer) {
251 if (((float) $answer->fraction
) > 0.0) {
252 $responses[$answer->id
] = (string) $answer->id
;
255 return empty($responses) ?
null : $responses;
259 function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
262 $answers = &$question->options
->answers
;
263 $correctanswers = $this->get_correct_responses($question, $state);
264 $readonly = empty($options->readonly
) ?
'' : 'disabled="disabled"';
266 $formatoptions = new stdClass
;
267 $formatoptions->noclean
= true;
268 $formatoptions->para
= false;
271 $questiontext = format_text($question->questiontext
,
272 $question->questiontextformat
,
273 $formatoptions, $cmoptions->course
);
274 $image = get_question_image($question);
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];
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"';
292 $type = ' type="checkbox" ';
293 $name = "name=\"{$question->name_prefix}{$aid}\"";
294 if (isset($state->responses
[$aid])) {
295 $checked = 'checked="checked"';
301 $a->id
= $question->name_prefix
. $aid;
303 $a->feedbackimg
= '';
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);
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
);
315 $a->feedbackimg
= question_get_feedback_image($answer->fraction
, $chosen && $options->feedback
);
319 // Print the answer text
320 $a->text
= $this->number_in_style($key, $question->options
->answernumbering
) .
321 format_text($answer->answer
, FORMAT_MOODLE
, $formatoptions, $cmoptions->course
);
323 // Print feedback if feedback is on
324 if (($options->feedback ||
$options->correct_responses
) && $checked) {
325 $a->feedback
= format_text($answer->feedback
, true, $formatoptions, $cmoptions->course
);
334 if ($options->feedback
) {
335 if ($state->raw_grade
>= $question->maxgrade
/1.01) {
336 $feedback = $question->options
->correctfeedback
;
337 } else if ($state->raw_grade
> 0) {
338 $feedback = $question->options
->partiallycorrectfeedback
;
340 $feedback = $question->options
->incorrectfeedback
;
342 $feedback = format_text($feedback,
343 $question->questiontextformat
,
344 $formatoptions, $cmoptions->course
);
347 include("$CFG->dirroot/question/type/multichoice/display.html");
350 function grade_responses(&$question, &$state, $cmoptions) {
351 $state->raw_grade
= 0;
352 if($question->options
->single
) {
353 $response = reset($state->responses
);
355 $state->raw_grade
= $question->options
->answers
[$response]->fraction
;
358 foreach ($state->responses
as $response) {
360 $state->raw_grade +
= $question->options
->answers
[$response]->fraction
;
365 // Make sure we don't assign negative or too high marks
366 $state->raw_grade
= min(max((float) $state->raw_grade
,
367 0.0), 1.0) * $question->maxgrade
;
369 // Apply the penalty for this attempt
370 $state->penalty
= $question->penalty
* $question->maxgrade
;
372 // mark the state as graded
373 $state->event
= ($state->event
== QUESTION_EVENTCLOSE
) ? QUESTION_EVENTCLOSEANDGRADE
: QUESTION_EVENTGRADE
;
379 function get_actual_response($question, $state) {
380 $answers = $question->options
->answers
;
381 $responses = array();
382 if (!empty($state->responses
)) {
383 foreach ($state->responses
as $aid =>$rid){
384 if (!empty($answers[$rid])) {
385 $responses[] = $this->format_text($answers[$rid]->answer
, $question->questiontextformat
);
394 function response_summary($question, $state, $length = 80) {
395 return implode(',', $this->get_actual_response($question, $state));
398 /// BACKUP FUNCTIONS ////////////////////////////
401 * Backup the data in the question
403 * This is used in question/backuplib.php
405 function backup($bf,$preferences,$question,$level=6) {
409 $multichoices = get_records("question_multichoice","question",$question,"id");
410 //If there are multichoices
412 //Iterate over each multichoice
413 foreach ($multichoices as $multichoice) {
414 $status = fwrite ($bf,start_tag("MULTICHOICE",$level,true));
415 //Print multichoice contents
416 fwrite ($bf,full_tag("LAYOUT",$level+
1,false,$multichoice->layout
));
417 fwrite ($bf,full_tag("ANSWERS",$level+
1,false,$multichoice->answers
));
418 fwrite ($bf,full_tag("SINGLE",$level+
1,false,$multichoice->single
));
419 fwrite ($bf,full_tag("SHUFFLEANSWERS",$level+
1,false,$multichoice->shuffleanswers
));
420 fwrite ($bf,full_tag("CORRECTFEEDBACK",$level+
1,false,$multichoice->correctfeedback
));
421 fwrite ($bf,full_tag("PARTIALLYCORRECTFEEDBACK",$level+
1,false,$multichoice->partiallycorrectfeedback
));
422 fwrite ($bf,full_tag("INCORRECTFEEDBACK",$level+
1,false,$multichoice->incorrectfeedback
));
423 $status = fwrite ($bf,end_tag("MULTICHOICE",$level,true));
426 //Now print question_answers
427 $status = question_backup_answers($bf,$preferences,$question);
432 /// RESTORE FUNCTIONS /////////////////
435 * Restores the data in the question
437 * This is used in question/restorelib.php
439 function restore($old_question_id,$new_question_id,$info,$restore) {
443 //Get the multichoices array
444 $multichoices = $info['#']['MULTICHOICE'];
446 //Iterate over multichoices
447 for($i = 0; $i < sizeof($multichoices); $i++
) {
448 $mul_info = $multichoices[$i];
450 //Now, build the question_multichoice record structure
451 $multichoice = new stdClass
;
452 $multichoice->question
= $new_question_id;
453 $multichoice->layout
= backup_todb($mul_info['#']['LAYOUT']['0']['#']);
454 $multichoice->answers
= backup_todb($mul_info['#']['ANSWERS']['0']['#']);
455 $multichoice->single
= backup_todb($mul_info['#']['SINGLE']['0']['#']);
456 $multichoice->shuffleanswers
= isset($mul_info['#']['SHUFFLEANSWERS']['0']['#'])?
backup_todb($mul_info['#']['SHUFFLEANSWERS']['0']['#']):'';
457 if (array_key_exists("CORRECTFEEDBACK", $mul_info['#'])) {
458 $multichoice->correctfeedback
= backup_todb($mul_info['#']['CORRECTFEEDBACK']['0']['#']);
460 $multichoice->correctfeedback
= '';
462 if (array_key_exists("PARTIALLYCORRECTFEEDBACK", $mul_info['#'])) {
463 $multichoice->partiallycorrectfeedback
= backup_todb($mul_info['#']['PARTIALLYCORRECTFEEDBACK']['0']['#']);
465 $multichoice->partiallycorrectfeedback
= '';
467 if (array_key_exists("INCORRECTFEEDBACK", $mul_info['#'])) {
468 $multichoice->incorrectfeedback
= backup_todb($mul_info['#']['INCORRECTFEEDBACK']['0']['#']);
470 $multichoice->incorrectfeedback
= '';
473 //We have to recode the answers field (a list of answers id)
474 //Extracts answer id from sequence
477 $tok = strtok($multichoice->answers
,",");
479 //Get the answer from backup_ids
480 $answer = backup_getid($restore->backup_unique_code
,"question_answers",$tok);
483 $answers_field .= $answer->new_id
;
486 $answers_field .= ",".$answer->new_id
;
492 //We have the answers field recoded to its new ids
493 $multichoice->answers
= $answers_field;
495 //The structure is equal to the db, so insert the question_shortanswer
496 $newid = insert_record ("question_multichoice",$multichoice);
499 if (($i+
1) %
50 == 0) {
500 if (!defined('RESTORE_SILENTLY')) {
502 if (($i+
1) %
1000 == 0) {
517 function restore_recode_answer($state, $restore) {
518 $pos = strpos($state->answer
, ':');
520 $responses = array();
521 if (false === $pos) { // No order of answers is given, so use the default
522 if ($state->answer
) {
523 $responses = explode(',', $state->answer
);
526 $order = explode(',', substr($state->answer
, 0, $pos));
527 if ($responsestring = substr($state->answer
, $pos +
1)) {
528 $responses = explode(',', $responsestring);
532 foreach ($order as $key => $oldansid) {
533 $answer = backup_getid($restore->backup_unique_code
,"question_answers",$oldansid);
535 $order[$key] = $answer->new_id
;
537 echo 'Could not recode multichoice answer id '.$oldansid.' for state '.$state->oldid
.'<br />';
542 foreach ($responses as $key => $oldansid) {
543 $answer = backup_getid($restore->backup_unique_code
,"question_answers",$oldansid);
545 $responses[$key] = $answer->new_id
;
547 echo 'Could not recode multichoice response answer id '.$oldansid.' for state '.$state->oldid
.'<br />';
551 return implode(',', $order).':'.implode(',', $responses);
555 * Decode links in question type specific tables.
556 * @return bool success or failure.
558 function decode_content_links_caller($questionids, $restore, &$i) {
561 // Decode links in the question_multichoice table.
562 if ($multichoices = get_records_list('question_multichoice', 'question',
563 implode(',', $questionids), '', 'id, correctfeedback, partiallycorrectfeedback, incorrectfeedback')) {
565 foreach ($multichoices as $multichoice) {
566 $correctfeedback = restore_decode_content_links_worker($multichoice->correctfeedback
, $restore);
567 $partiallycorrectfeedback = restore_decode_content_links_worker($multichoice->partiallycorrectfeedback
, $restore);
568 $incorrectfeedback = restore_decode_content_links_worker($multichoice->incorrectfeedback
, $restore);
569 if ($correctfeedback != $multichoice->correctfeedback ||
570 $partiallycorrectfeedback != $multichoice->partiallycorrectfeedback ||
571 $incorrectfeedback != $multichoice->incorrectfeedback
) {
572 $subquestion->correctfeedback
= addslashes($correctfeedback);
573 $subquestion->partiallycorrectfeedback
= addslashes($partiallycorrectfeedback);
574 $subquestion->incorrectfeedback
= addslashes($incorrectfeedback);
575 if (!update_record('question_multichoice', $multichoice)) {
581 if (++
$i %
5 == 0 && !defined('RESTORE_SILENTLY')) {
595 * @return array of the numbering styles supported. For each one, there
596 * should be a lang string answernumberingxxx in teh qtype_multichoice
597 * language file, and a case in the switch statement in number_in_style,
598 * and it should be listed in the definition of this column in install.xml.
600 function get_numbering_styles() {
601 return array('abc', 'ABCD', '123', 'none');
604 function number_html($qnum) {
605 return '<span class="anun">' . $qnum . '<span class="anumsep">.</span></span> ';
609 * @param int $num The number, starting at 0.
610 * @param string $style The style to render the number in. One of the ones returned by $numberingoptions.
611 * @return string the number $num in the requested style.
613 function number_in_style($num, $style) {
616 return $this->number_html(chr(ord('a') +
$num));
618 return $this->number_html(chr(ord('A') +
$num));
620 return $this->number_html(($num +
1));
628 function find_file_links($question, $courseid){
630 // find links in the answers table.
631 $urls +
= question_find_file_links_from_html($question->options
->correctfeedback
, $courseid);
632 $urls +
= question_find_file_links_from_html($question->options
->partiallycorrectfeedback
, $courseid);
633 $urls +
= question_find_file_links_from_html($question->options
->incorrectfeedback
, $courseid);
634 foreach ($question->options
->answers
as $answer) {
635 $urls +
= question_find_file_links_from_html($answer->answer
, $courseid);
637 //set all the values of the array to the question id
639 $urls = array_combine(array_keys($urls), array_fill(0, count($urls), array($question->id
)));
641 $urls = array_merge_recursive($urls, parent
::find_file_links($question, $courseid));
645 function replace_file_links($question, $fromcourseid, $tocourseid, $url, $destination){
646 parent
::replace_file_links($question, $fromcourseid, $tocourseid, $url, $destination);
647 // replace links in the question_match_sub table.
648 // We need to use a separate object, because in load_question_options, $question->options->answers
649 // is changed from a comma-separated list of ids to an array, so calling update_record on
650 // $question->options stores 'Array' in that column, breaking the question.
651 $optionschanged = false;
652 $newoptions = new stdClass
;
653 $newoptions->id
= $question->options
->id
;
654 $newoptions->correctfeedback
= question_replace_file_links_in_html($question->options
->correctfeedback
, $fromcourseid, $tocourseid, $url, $destination, $optionschanged);
655 $newoptions->partiallycorrectfeedback
= question_replace_file_links_in_html($question->options
->partiallycorrectfeedback
, $fromcourseid, $tocourseid, $url, $destination, $optionschanged);
656 $newoptions->incorrectfeedback
= question_replace_file_links_in_html($question->options
->incorrectfeedback
, $fromcourseid, $tocourseid, $url, $destination, $optionschanged);
657 if ($optionschanged){
658 if (!update_record('question_multichoice', addslashes_recursive($newoptions))) {
659 error('Couldn\'t update \'question_multichoice\' record '.$newoptions->id
);
662 $answerchanged = false;
663 foreach ($question->options
->answers
as $answer) {
664 $answer->answer
= question_replace_file_links_in_html($answer->answer
, $fromcourseid, $tocourseid, $url, $destination, $answerchanged);
666 if (!update_record('question_answers', addslashes_recursive($answer))){
667 error('Couldn\'t update \'question_answers\' record '.$answer->id
);
674 * Runs all the code required to set up and save an essay question for testing purposes.
675 * Alternate DB table prefix may be used to facilitate data deletion.
677 function generate_test($name, $courseid = null) {
678 list($form, $question) = parent
::generate_test($name, $courseid);
679 $question->category
= $form->category
;
680 $form->questiontext
= "How old is the sun?";
681 $form->generalfeedback
= "General feedback";
682 $form->penalty
= 0.1;
684 $form->shuffleanswers
= 1;
685 $form->answernumbering
= 'abc';
686 $form->noanswers
= 3;
687 $form->answer
= array('Ancient', '5 billion years old', '4.5 billion years old');
688 $form->fraction
= array(0.3, 0.9, 1);
689 $form->feedback
= array('True, but lacking in accuracy', 'Close, but no cigar!', 'Yep, that is it!');
690 $form->correctfeedback
= 'Excellent!';
691 $form->incorrectfeedback
= 'Nope!';
692 $form->partiallycorrectfeedback
= 'Not bad';
695 $course = get_record('course', 'id', $courseid);
698 return $this->save_question($question, $form, $course);
702 // Register this question type with the question bank.
703 question_register_questiontype(new question_multichoice_qtype());