Moving quiz-independent question scripts to their new location. In a following commit...
[moodle.git] / question / questiontypes / multianswer / questiontype.php
CommitLineData
516cf3eb 1<?php // $Id$
2
3///////////////////
4/// MULTIANSWER /// (Embedded - cloze)
5///////////////////
6
7///
8/// The multianswer question type is special in that it
9/// depends on a few other question types, i.e.
10/// MULTICHOICE, SHORTANSWER and NUMERICAL.
11/// These question types have got a few special features that
12/// makes them useable by the MULTIANSWER question type
13///
14
15/// QUESTION TYPE CLASS //////////////////
16class quiz_embedded_cloze_qtype extends quiz_default_questiontype {
17
18 function name() {
19 return 'multianswer';
20 }
21
22 function get_question_options(&$question) {
23 global $QUIZ_QTYPES;
24
25 // Get relevant data indexed by positionkey from the multianswers table
26 if (!$sequence = get_field('quiz_multianswers', 'sequence', 'question', $question->id)) {
27 notify('Error: Missing question options!');
28 return false;
29 }
30
31 $wrappedquestions = get_records_list('quiz_questions', 'id', $sequence);
32
33 // We want an array with question ids as index and the positions as values
34 $sequence = array_flip(explode(',', $sequence));
35 array_walk($sequence, create_function('&$val', '$val++;'));
36
37 foreach ($wrappedquestions as $wrapped) {
38 if (!$QUIZ_QTYPES[$wrapped->qtype]->get_question_options($wrapped)) {
39 notify("Unable to get options for questiontype {$wrapped->qtype} (id={$wrapped->id})");
40 }
41 // for wrapped questions the maxgrade is always equal to the defaultgrade,
42 // there is no entry in the quiz_question_instances table for them
43 $wrapped->maxgrade = $wrapped->defaultgrade;
44
45 $question->options->questions[$sequence[$wrapped->id]] = clone($wrapped); // ??? Why do we need a clone here?
46 }
47
48 return true;
49 }
50
51 function save_question_options($question) {
52 global $QUIZ_QTYPES;
53
54 // This function needs to be able to handle the case where the existing set of wrapped
55 // questions does not match the new set of wrapped questions so that some need to be
56 // created, some modified and some deleted
57 // Unfortunately the code currently simply overwrites existing ones in sequence. This
58 // will make re-marking after a re-ordering of wrapped questions impossible and
59 // will also create difficulties if questiontype specific tables reference the id.
60
61 // First we get all the existing wrapped questions
62 if (!$oldwrappedids = get_records('quiz_questions', 'parent', $question->id, '', 'id, id')) {
63 // We need to select 'id, id' because the first one is consumed by
64 // get_records.
65 $oldwrappedids = array();
66 }
67 $oldwrappedids = array_keys($oldwrappedids);
68 $sequence = array();
69 foreach($question->options->questions as $wrapped) {
70 // if we still have some old wrapped question ids, reuse the next of them
71 if ($oldwrappedid = array_shift($oldwrappedids)) {
72 $wrapped->id = $oldwrappedid;
73 }
74 $wrapped->name = $question->name;
75 $wrapped->parent = $question->id;
76 $wrapped->category = $question->category;
77 $wrapped->version = $question->version;
78 $wrapped = $QUIZ_QTYPES[$wrapped->qtype]->save_question($wrapped,
79 $wrapped, $question->course);
80 $sequence[] = $wrapped->id;
81 }
82
83 // Delete redundant wrapped questions
84 $oldwrappedids = implode(',', $oldwrappedids);
85 delete_records_select('quiz_questions', "id IN ($oldwrappedids)");
86
87 if (!empty($sequence)) {
88 $multianswer = new stdClass;
89 $multianswer->question = $question->id;
90 $multianswer->sequence = implode(',', $sequence);
91 if ($oldid =
92 get_field('quiz_multianswers', 'id', 'question', $question->id)) {
93 $multianswer->id = $oldid;
94 if (!update_record("quiz_multianswers", $multianswer)) {
95 $result->error = "Could not update quiz multianswer! " .
96 "(id=$multianswer->id)";
97 return $result;
98 }
99 } else {
100 if (!insert_record("quiz_multianswers", $multianswer)) {
101 $result->error = "Could not insert quiz multianswer!";
102 return $result;
103 }
104 }
105 }
106 }
107
108 function save_question($authorizedquestion, $form, $course) {
109 $question = quiz_qtype_multianswer_extract_question ($form->questiontext);
110 if (isset($authorizedquestion->id)) {
111 $question->id = $authorizedquestion->id;
112 $question->version = $form->version = $authorizedquestion->version;
113 } else {
114 $question->version = $form->version = 1;
115 }
116
117
118 $question->category = $authorizedquestion->category;
119 $form->course = $course; // To pass the course object to
120 // save_question_options, where it is
121 // needed to call type specific
122 // save_question methods.
123 $form->defaultgrade = $question->defaultgrade;
124 $form->questiontext = $question->questiontext;
125 $form->questiontextformat = 0;
126 $form->options = clone($question->options);
127 unset($question->options);
128 return parent::save_question($question, $form, $course);
129 }
130
131 function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) {
132 $state->responses = array();
133 foreach ($question->options->questions as $key => $wrapped) {
134 $state->responses[$key] = '';
135 }
136 return true;
137 }
138
139 function restore_session_and_responses(&$question, &$state) {
140 $responses = explode(',', $state->responses['']);
141 $state->responses = array();
142 foreach ($responses as $response) {
143 $tmp = explode("-", $response);
144 // restore encoded characters
145 $state->responses[$tmp[0]] =
146 str_replace(array("&#0044;", "&#0045;"), array(",", "-"), $tmp[1]);
147 }
148 return true;
149 }
150
151 function save_session_and_responses(&$question, &$state) {
152 $responses = $state->responses;
153 array_walk($responses, create_function('&$val, $key',
154 // encode - (hyphen) and , (comma) to &#0045; because they are used as
155 // delimiters
156 '$val = str_replace(array(",", "-"), array("&#0044;", "&#0045;"), $val);
157 $val = "$key-$val";'));
158 $responses = implode(',', $responses);
159
160 // Set the legacy answer field
161 if (!set_field('quiz_states', 'answer', $responses, 'id',
162 $state->id)) {
163 return false;
164 }
165 return true;
166 }
167
168 /**
169 * Deletes question from the question-type specific tables
170 *
171 * @return boolean Success/Failure
172 * @param object $question The question being deleted
173 */
174 function delete_question($question) {
175 delete_records("quiz_multianswers", "question", $question->id);
176 return true;
177 }
178
179 function get_correct_responses(&$question, &$state) {
180 global $QUIZ_QTYPES;
181 $responses = array();
182 foreach($question->options->questions as $key => $wrapped) {
183 if ($correct = $QUIZ_QTYPES[$wrapped->qtype]->get_correct_responses($wrapped, $state)) {
184 $responses[$key] = $correct[''];
185 } else {
186 // if there is no correct answer to this subquestion then there
187 // can not be a correct answer to the whole question either, so
188 // we have to return null.
189 return null;
190 }
191 }
192 return $responses;
193 }
194
195 function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
196 global $QUIZ_QTYPES;
197 $readonly = empty($options->readonly) ? '' : 'readonly="readonly"';
198 $nameprefix = $question->name_prefix;
199
200 // For this question type, we better print the image on top:
201 quiz_print_possible_question_image($question, $cmoptions->course);
202
203 $qtextremaining = format_text($question->questiontext,
204 $question->questiontextformat,
205 NULL, $cmoptions->course);
206
207 $strfeedback = get_string('feedback', 'quiz');
208
209 // The regex will recognize text snippets of type {#X}
210 // where the X can be any text not containg } or white-space characters.
211
212 while (ereg('\{#([^[:space:]}]*)}', $qtextremaining, $regs)) {
213 $qtextsplits = explode($regs[0], $qtextremaining, 2);
214 echo $qtextsplits[0];
215 $qtextremaining = $qtextsplits[1];
216
217 $positionkey = $regs[1];
218 $wrapped = &$question->options->questions[$positionkey];
219 $answers = &$wrapped->options->answers;
220 $correctanswers = $QUIZ_QTYPES[$wrapped->qtype]
221 ->get_correct_responses($wrapped, $state);
222
223 $inputname = $nameprefix.$positionkey;
224 $response = isset($state->responses[$positionkey])
225 ? $state->responses[$positionkey] : null;
226
227
228 // Determine feedback popup if any
229 $popup = '';
230 $style = '';
231 if ($options->feedback) {
232 $chosenanswer = null;
233 switch ($wrapped->qtype) {
234 case NUMERICAL:
235 $testedstate = clone($state);
236 $testedstate->responses[''] = $response;
237 $raw_grade = 0;
238 foreach ($answers as $answer) {
239 if($QUIZ_QTYPES[$wrapped->qtype]
240 ->test_response($wrapped, $testedstate, $answer)) {
241 if (empty($raw_grade) || $raw_grade < $answer->fraction) {
242 $chosenanswer = clone($answer);
243 $raw_grade = $answer->fraction;
244 }
245 }
246 }
247 break;
248 case SHORTANSWER:
249 $testedstate = clone($state);
250 $testedstate->responses[''] = $response;
251 $teststate = clone($state);
252 $raw_grade = 0;
253 foreach ($answers as $answer) {
254 $teststate->responses[''] = trim($answer->answer);
255 if($QUIZ_QTYPES[$wrapped->qtype]
256 ->compare_responses($wrapped, $testedstate, $teststate)) {
257 if (empty($raw_grade) || $raw_grade < $answer->fraction) {
258 $chosenanswer = clone($answer);
259 $raw_grade = $answer->fraction;
260 }
261 }
262 }
263 break;
264 case MULTICHOICE:
265 if (isset($answers[$response])) {
266 $chosenanswer = clone($answers[$response]);
267 }
268 break;
269 default:
270 break;
271 }
272
273 // Set up a default chosenanswer so that all non-empty wrong
274 // answers are highlighted red
275 if (empty($chosenanswer) && !empty($response)) {
276 $chosenanswer = new stdClass;
277 $chosenanswer->fraction = 0.0;
278 }
279
280 if (!empty($chosenanswer->feedback)) {
281 $feedback = str_replace("'", "\\'", $chosenanswer->feedback);
282 $popup = " onmouseover=\"return overlib('$feedback', STICKY, MOUSEOFF, CAPTION, '$strfeedback', FGCOLOR, '#FFFFFF');\" ".
283 " onmouseout=\"return nd();\" ";
284 }
285
286 /// Determine style
287 if (!empty($chosenanswer) && $options->correct_responses) {
288 if (!isset($chosenanswer->fraction)
289 || $chosenanswer->fraction <= 0.0) {
290 // The response must have been totally wrong:
291 $style = 'style="background-color:red"';
292
293 } else if ($chosenanswer->fraction >= 1.0) {
294 // The response was correct!!
295 $style = 'style="background-color:lime"';
296
297 } else {
298 // This response did at least give some credit:
299 $style = 'style="background-color:yellow"';
300 }
301 } else {
302 $style = '';
303 }
304 }
305
306 // Print the input control
307 switch ($wrapped->qtype) {
308 case SHORTANSWER:
309 case NUMERICAL:
310 echo " <input $style $readonly $popup name=\"$inputname\"
311 type=\"text\" value=\"$response\" size=\"12\" /> ";
312 break;
313 case MULTICHOICE:
314 $outputoptions = '<option></option>'; // Default empty option
315 foreach ($answers as $mcanswer) {
316 $selected = $response == $mcanswer->id
317 ? ' selected="selected" ' : '';
318 $outputoptions .= "<option value=\"$mcanswer->id\" $selected>$mcanswer->answer</option>";
319 }
320 echo "<select $popup $readonly $style name=\"$inputname\">";
321 echo $outputoptions;
322 echo '</select>';
323 break;
324 default:
325 error("Unable to recognized questiontype ($wrapped->qtype) of
326 question part $positionkey.");
327 break;
328 }
329 }
330
331 // Print the final piece of question text:
332 echo $qtextremaining;
333 }
334
335 function grade_responses(&$question, &$state, $cmoptions) {
336 global $QUIZ_QTYPES;
337 $teststate = clone($state);
338 $state->raw_grade = 0;
339 foreach($question->options->questions as $key => $wrapped) {
340 $state->responses[$key] = html_entity_decode($state->responses[$key]);
341 $teststate->responses = array('' => $state->responses[$key]);
342 $teststate->raw_grade = 0;
343 if (false === $QUIZ_QTYPES[$wrapped->qtype]
344 ->grade_responses($wrapped, $teststate, $cmoptions)) {
345 return false;
346 }
347 $state->raw_grade += $teststate->raw_grade;
348 }
349 $state->raw_grade /= $question->defaultgrade;
350 $state->raw_grade = min(max((float) $state->raw_grade, 0.0), 1.0)
351 * $question->maxgrade;
352
353 if (empty($state->raw_grade)) {
354 $state->raw_grade = 0.0;
355 }
356 $state->penalty = $question->penalty * $question->maxgrade;
357
358 return true;
359 }
360
361 function get_actual_response($question, $state) {
362 global $QUIZ_QTYPES;
363 $teststate = clone($state);
364 foreach($question->options->questions as $key => $wrapped) {
365 $state->responses[$key] = html_entity_decode($state->responses[$key]);
366 $teststate->responses = array('' => $state->responses[$key]);
367 $correct = $QUIZ_QTYPES[$wrapped->qtype]
368 ->get_actual_response($wrapped, $teststate);
369 // change separator here if you want
370 $responsesseparator = ',';
371 $responses[$key] = implode($responsesseparator, $correct);
372 }
373 return $responses;
374 }
375}
376//// END OF CLASS ////
377
378
379//////////////////////////////////////////////////////////////////////////
380//// INITIATION - Without this line the question type is not in use... ///
381//////////////////////////////////////////////////////////////////////////
382$QUIZ_QTYPES[MULTIANSWER]= new quiz_embedded_cloze_qtype();
383
384
385/////////////////////////////////////////////////////////////
386//// ADDITIONAL FUNCTIONS
387//// The functions below deal exclusivly with editing
388//// of questions with question type MULTIANSWER.
389//// Therefore they are kept in this file.
390//// They are not in the class as they are not
391//// likely to be subject for overriding.
392/////////////////////////////////////////////////////////////
393
394
395function quiz_qtype_multianswer_extract_question($text) {
396
397////////////////////////////////////////////////
398//// Define some constants first. It is not the
399//// pattern commonly used in quiz/questiontypes.
400//// The reason is that it has been moved here from
401//// quiz/format/multianswer/format.php
402////////////////////////////////////////////////
403
404
405 // Undo the automatic addslashes, because we want to analyze the text - we need to remember this later and addslashes again!
406 $text = stripslashes($text);
407
408 // We need to allow entities (e.g. &#1085;) in answers. This is difficulty,
409 // because the '#' character is used as delimiter between answer and
410 // feedback as well as inside entities. The HTML editor automatically
411 // replaces '&' with '&amp;', so we undo this to get back the entities we
412 // originally wanted. However, this code leaves all &amp; alone, if they
413 // are not followed by 2 to 9 characters and a final semicolon. This allows
414 // to have an answer end on '&' with the feedback (e.g. answer&amp;#feedback).
415 // When the plain text editor is used, the &amp; needs to be typed out
416 // explicitly in this case.
417 $text = preg_replace('/&amp;(#[0-9a-fx]{2,6}?);/', '&$1;', $text);
418
419 // REGULAR EXPRESSION CONSTANTS
420 // I do not know any way to make this easier
421 // Regexes are always awkard when defined but more comprehensible
422 // when used as constants in the executive code
423
424 // Handle the entity encoded ampersand in entities (e.g. &amp;lt; -> &lt;)
425 $text = preg_replace('/&amp;(.{2,9}?;)/', '&${1}', $text);
426 $text = stripslashes($text);
427
428 // ANSWER_ALTERNATIVE regexes
429 define("ANSWER_ALTERNATIVE_FRACTION_REGEX",
430 '=|%(-?[0-9]+)%');
431 // for the syntax '(?<!' see http://www.perl.com/doc/manual/html/pod/perlre.html#item_C
432 define("ANSWER_ALTERNATIVE_ANSWER_REGEX",
433 '.+?(?<!\\\\|&)(?=[~#}]|$)');
434 //'[^~#}]+');
435 define("ANSWER_ALTERNATIVE_FEEDBACK_REGEX",
436 '.*?(?<!\\\\)(?=[~}]|$)');
437 //'[//^~}]*');
438 define("ANSWER_ALTERNATIVE_REGEX",
439 '(' . ANSWER_ALTERNATIVE_FRACTION_REGEX .')?'
440 . '(' . ANSWER_ALTERNATIVE_ANSWER_REGEX . ')'
441 . '(#(' . ANSWER_ALTERNATIVE_FEEDBACK_REGEX .'))?');
442
443 // Parenthesis positions for ANSWER_ALTERNATIVE_REGEX
444 define("ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION", 2);
445 define("ANSWER_ALTERNATIVE_REGEX_FRACTION", 1);
446 define("ANSWER_ALTERNATIVE_REGEX_ANSWER", 3);
447 define("ANSWER_ALTERNATIVE_REGEX_FEEDBACK", 5);
448
449 // NUMBER_FORMATED_ALTERNATIVE_ANSWER_REGEX is used
450 // for identifying numerical answers in ANSWER_ALTERNATIVE_REGEX_ANSWER
451 define("NUMBER_REGEX",
452 '-?(([0-9]+[.,]?[0-9]*|[.,][0-9]+)([eE][-+]?[0-9]+)?)');
453 define("NUMERICAL_ALTERNATIVE_REGEX",
454 '^(' . NUMBER_REGEX . ')(:' . NUMBER_REGEX . ')?$');
455
456 // Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX
457 define("NUMERICAL_CORRECT_ANSWER", 1);
458 define("NUMERICAL_ABS_ERROR_MARGIN", 6);
459
460 // Remaining ANSWER regexes
461 define("ANSWER_TYPE_DEF_REGEX",
462 '(NUMERICAL|NM)|(MULTICHOICE|MC)|(SHORTANSWER|SA|MW)');
463 define("ANSWER_START_REGEX",
464 '\{([0-9]*):(' . ANSWER_TYPE_DEF_REGEX . '):');
465
466 define("ANSWER_REGEX",
467 ANSWER_START_REGEX
468 . '(' . ANSWER_ALTERNATIVE_REGEX
469 . '(~'
470 . ANSWER_ALTERNATIVE_REGEX
471 . ')*)\}' );
472
473 // Parenthesis positions for singulars in ANSWER_REGEX
474 define("ANSWER_REGEX_NORM", 1);
475 define("ANSWER_REGEX_ANSWER_TYPE_NUMERICAL", 3);
476 define("ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE", 4);
477 define("ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER", 5);
478 define("ANSWER_REGEX_ALTERNATIVES", 6);
479
480////////////////////////////////////////
481//// Start of the actual function
482////////////////////////////////////////
483
484 $question = new stdClass;
485 $question->qtype = MULTIANSWER;
486 $question->questiontext = $text;
487 $question->options->questions = array();
488 $question->defaultgrade = 0; // Will be increased for each answer norm
489
490 for ($positionkey=1
491 ; preg_match('/'.ANSWER_REGEX.'/', $question->questiontext, $answerregs)
492 ; ++$positionkey ) {
493 $wrapped = new stdClass;
494 $wrapped->defaultgrade = $answerregs[ANSWER_REGEX_NORM]
495 or $wrapped->defaultgrade = '1';
496 if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])) {
497 $wrapped->qtype = NUMERICAL;
498 $wrapped->multiplier = array();
499 $wrapped->units = array();
500 } else if(!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER])) {
501 $wrapped->qtype = SHORTANSWER;
502 $wrapped->usecase = 0;
503 } else if(!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE])) {
504 $wrapped->qtype = MULTICHOICE;
505 $wrapped->single = 1;
506 } else {
507 error("Cannot identify qtype $answerregs[2]");
508 return false;
509 }
510
511 // Each $wrapped simulates a $form that can be processed by the
512 // respective save_question and save_question_options methods of the
513 // wrapped questiontypes
514 $wrapped->answer = array();
515 $wrapped->fraction = array();
516 $wrapped->feedback = array();
517 $wrapped->shuffleanswers = 1;
518 $wrapped->questiontext = addslashes($answerregs[0]); // here we don't want multianswer_escape, because this is editing time information
519 $wrapped->questiontextformat = 0;
520
521 $remainingalts = $answerregs[ANSWER_REGEX_ALTERNATIVES];
522 while (preg_match('/~?'.ANSWER_ALTERNATIVE_REGEX.'/', $remainingalts, $altregs)) {
523 if ('=' == $altregs[ANSWER_ALTERNATIVE_REGEX_FRACTION]) {
524 $wrapped->fraction[] = '1';
525 } else if ($percentile =
526 $altregs[ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION]){
527 $wrapped->fraction[] = .01 * $percentile;
528 } else {
529 $wrapped->fraction[] = '0';
530 }
531 $wrapped->feedback[] = multianswer_escape(
532 isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])
533 ? $altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK] : '');
534 if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])
535 && ereg(NUMERICAL_ALTERNATIVE_REGEX,
536 $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER],
537 $numregs) )
538 {
539 $wrapped->answer[] =
540 multianswer_escape($numregs[NUMERICAL_CORRECT_ANSWER]);
541 if ($numregs[NUMERICAL_ABS_ERROR_MARGIN]) {
542 $wrapped->tolerance[] =
543 $numregs[NUMERICAL_ABS_ERROR_MARGIN];
544 } else {
545 $wrapped->tolerance[] = 0;
546 }
547 } else { // Tolerance can stay undefined for non numerical questions
548 $wrapped->answer[] = multianswer_escape(
549 $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER]);
550 }
551 $tmp = explode($altregs[0], $remainingalts, 2);
552 $remainingalts = $tmp[1];
553 }
554
555 $question->defaultgrade += $wrapped->defaultgrade;
556 $question->options->questions[$positionkey] = clone($wrapped);
557 $question->questiontext = implode("{#$positionkey}",
558 explode($answerregs[0], $question->questiontext, 2));
559 }
560 $question->questiontext = multianswer_escape($question->questiontext);
561 return $question;
562}
563
564function multianswer_escape($text) {
565 $text = str_replace("&amp;", "&", $text);
566 $text = str_replace('\#', '#', $text);
567 $text = html_entity_decode($text);
568 return addslashes($text);
569}
570
571?>