MDL-14625 more tolerant to missing subquestions
[moodle.git] / question / type / shortanswer / questiontype.php
CommitLineData
516cf3eb 1<?php // $Id$
2
3///////////////////
4/// SHORTANSWER ///
5///////////////////
6
7/// QUESTION TYPE CLASS //////////////////
8
9///
10/// This class contains some special features in order to make the
11/// question type embeddable within a multianswer (cloze) question
12///
41a89a07 13/**
53a4d39f 14 * @package questionbank
15 * @subpackage questiontypes
16 */
22f2f418 17require_once("$CFG->dirroot/question/type/questiontype.php");
516cf3eb 18
af3830ee 19class question_shortanswer_qtype extends default_questiontype {
516cf3eb 20
21 function name() {
22 return 'shortanswer';
23 }
24
25 function get_question_options(&$question) {
f34488b2 26 global $DB;
516cf3eb 27 // Get additional information from database
28 // and attach it to the question object
f34488b2 29 if (!$question->options = $DB->get_record('question_shortanswer', array('question' => $question->id))) {
516cf3eb 30 notify('Error: Missing question options!');
31 return false;
32 }
33
f34488b2 34 if (!$question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) {
df06c4bb 35 notify('Error: Missing question answers for shortanswer question ' . $question->id . '!');
9e8dba79 36 return false;
516cf3eb 37 }
38 return true;
39 }
40
41 function save_question_options($question) {
f34488b2 42 global $DB;
5a14d563 43 $result = new stdClass;
90a36f8c 44
f34488b2 45 if (!$oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) {
516cf3eb 46 $oldanswers = array();
47 }
48
49 $answers = array();
50 $maxfraction = -1;
51
52 // Insert all the new answers
53 foreach ($question->answer as $key => $dataanswer) {
54 if ($dataanswer != "") {
55 if ($oldanswer = array_shift($oldanswers)) { // Existing answer, so reuse it
56 $answer = $oldanswer;
57 $answer->answer = trim($dataanswer);
58 $answer->fraction = $question->fraction[$key];
59 $answer->feedback = $question->feedback[$key];
f34488b2 60 if (!$DB->update_record("question_answers", $answer)) {
516cf3eb 61 $result->error = "Could not update quiz answer! (id=$answer->id)";
62 return $result;
63 }
64 } else { // This is a completely new answer
5a14d563 65 $answer = new stdClass;
516cf3eb 66 $answer->answer = trim($dataanswer);
67 $answer->question = $question->id;
68 $answer->fraction = $question->fraction[$key];
69 $answer->feedback = $question->feedback[$key];
f34488b2 70 if (!$answer->id = $DB->insert_record("question_answers", $answer)) {
516cf3eb 71 $result->error = "Could not insert quiz answer!";
72 return $result;
73 }
74 }
75 $answers[] = $answer->id;
76 if ($question->fraction[$key] > $maxfraction) {
77 $maxfraction = $question->fraction[$key];
78 }
79 }
80 }
81
f34488b2 82 if ($options = $DB->get_record("question_shortanswer", array("question" => $question->id))) {
516cf3eb 83 $options->answers = implode(",",$answers);
84 $options->usecase = $question->usecase;
f34488b2 85 if (!$DB->update_record("question_shortanswer", $options)) {
516cf3eb 86 $result->error = "Could not update quiz shortanswer options! (id=$options->id)";
87 return $result;
88 }
89 } else {
90 unset($options);
91 $options->question = $question->id;
92 $options->answers = implode(",",$answers);
93 $options->usecase = $question->usecase;
f34488b2 94 if (!$DB->insert_record("question_shortanswer", $options)) {
516cf3eb 95 $result->error = "Could not insert quiz shortanswer options!";
96 return $result;
97 }
98 }
99
100 // delete old answer records
101 if (!empty($oldanswers)) {
102 foreach($oldanswers as $oa) {
f34488b2 103 $DB->delete_records('question_answers', array('id' => $oa->id));
516cf3eb 104 }
105 }
106
107 /// Perform sanity checks on fractional grades
108 if ($maxfraction != 1) {
109 $maxfraction = $maxfraction * 100;
110 $result->noticeyesno = get_string("fractionsnomax", "quiz", $maxfraction);
111 return $result;
112 } else {
113 return true;
114 }
115 }
116
117 /**
118 * Deletes question from the question-type specific tables
119 *
120 * @return boolean Success/Failure
121 * @param object $question The question being deleted
122 */
90c3f310 123 function delete_question($questionid) {
f34488b2 124 global $DB;
125 $DB->delete_records("question_shortanswer", array("question" => $questionid));
516cf3eb 126 return true;
127 }
128
129 function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
130 global $CFG;
dfa47f96 131 /// This implementation is also used by question type 'numerical'
516cf3eb 132 $readonly = empty($options->readonly) ? '' : 'readonly="readonly"';
5a14d563 133 $formatoptions = new stdClass;
7347c60b 134 $formatoptions->noclean = true;
135 $formatoptions->para = false;
516cf3eb 136 $nameprefix = $question->name_prefix;
137
138 /// Print question text and media
139
53a4d39f 140 $questiontext = format_text($question->questiontext,
141 $question->questiontextformat,
142 $formatoptions, $cmoptions->course);
9fc3100f 143 $image = get_question_image($question);
516cf3eb 144
145 /// Print input controls
146
34018d81 147 if (isset($state->responses['']) && $state->responses['']!='') {
9e8dba79 148 $value = ' value="'.s($state->responses[''], true).'" ';
516cf3eb 149 } else {
150 $value = ' value="" ';
151 }
152 $inputname = ' name="'.$nameprefix.'" ';
153
154 $feedback = '';
a8404567 155 $class = '';
156 $feedbackimg = '';
e0c25647 157
516cf3eb 158 if ($options->feedback) {
a8404567 159 $class = question_get_feedback_class(0);
160 $feedbackimg = question_get_feedback_image(0);
1a1293ed 161 foreach($question->options->answers as $answer) {
2b087056 162
134f2cc0 163 if ($this->test_response($question, $state, $answer)) {
164 // Answer was correct or partially correct.
2b087056 165 $class = question_get_feedback_class($answer->fraction);
166 $feedbackimg = question_get_feedback_image($answer->fraction);
134f2cc0 167 if ($answer->feedback) {
1a1293ed 168 $feedback = format_text($answer->feedback, true, $formatoptions, $cmoptions->course);
169 }
516cf3eb 170 break;
134f2cc0 171 }
516cf3eb 172 }
173 }
90a36f8c 174
175 /// Removed correct answer, to be displayed later MDL-7496
aaae75b0 176 include("$CFG->dirroot/question/type/shortanswer/display.html");
516cf3eb 177 }
178
179 // ULPGC ecastro
180 function check_response(&$question, &$state) {
181 $answers = &$question->options->answers;
182 $testedstate = clone($state);
183 $teststate = clone($state);
184 foreach($answers as $aid => $answer) {
185 $teststate->responses[''] = trim($answer->answer);
186 if($this->compare_responses($question, $testedstate, $teststate)) {
187 return $aid;
188 }
189 }
190 return false;
191 }
192
5a14d563 193 function compare_responses($question, $state, $teststate) {
194 if (isset($state->responses['']) && isset($teststate->responses[''])) {
195 if ($question->options->usecase) {
c82f76d0 196 return strcmp($state->responses[''], $teststate->responses['']) == 0;
5a14d563 197 } else {
485349dd 198 $textlib = textlib_get_instance();
199 return strcmp($textlib->strtolower($state->responses['']),
200 $textlib->strtolower($teststate->responses[''])) == 0;
516cf3eb 201 }
202 }
5a14d563 203 return false;
516cf3eb 204 }
205
5a14d563 206 function test_response(&$question, $state, $answer) {
90a36f8c 207 // Trim the response before it is saved in the database. See MDL-10709
208 $state->responses[''] = trim($state->responses['']);
294ce987 209 return $this->compare_string_with_wildcard($state->responses[''],
5a14d563 210 $answer->answer, !$question->options->usecase);
516cf3eb 211 }
1a1293ed 212
5a14d563 213 function compare_string_with_wildcard($string, $pattern, $ignorecase) {
214 // Break the string on non-escaped asterisks.
215 $bits = preg_split('/(?<!\\\\)\*/', $pattern);
216 // Escape regexp special characters in the bits.
3270033a 217 $excapedbits = array();
218 foreach ($bits as $bit) {
219 $excapedbits[] = preg_quote(str_replace('\*', '*', $bit));
220 }
5a14d563 221 // Put it back together to make the regexp.
3270033a 222 $regexp = '|^' . implode('.*', $excapedbits) . '$|u';
90a36f8c 223
5a14d563 224 // Make the match insensitive if requested to.
225 if ($ignorecase) {
226 $regexp .= 'i';
227 }
90a36f8c 228
5a14d563 229 return preg_match($regexp, trim($string));
1a1293ed 230 }
231
9b75adc2 232 /*
233 * Override the parent class method, to remove escaping from asterisks.
234 */
235 function get_correct_responses(&$question, &$state) {
236 $response = parent::get_correct_responses($question, $state);
237 if (is_array($response)) {
294ce987 238 $response[''] = str_replace('\*', '*', $response['']);
9b75adc2 239 }
240 return $response;
241 }
242
1a1293ed 243 /// BACKUP FUNCTIONS ////////////////////////////
c5d94c41 244
245 /*
246 * Backup the data in the question
247 *
248 * This is used in question/backuplib.php
249 */
250 function backup($bf,$preferences,$question,$level=6) {
f34488b2 251 global $DB;
c5d94c41 252
253 $status = true;
254
f34488b2 255 $shortanswers = $DB->get_records('question_shortanswer', array('question' => $question), 'id ASC');
c5d94c41 256 //If there are shortanswers
257 if ($shortanswers) {
258 //Iterate over each shortanswer
259 foreach ($shortanswers as $shortanswer) {
260 $status = fwrite ($bf,start_tag("SHORTANSWER",$level,true));
261 //Print shortanswer contents
262 fwrite ($bf,full_tag("ANSWERS",$level+1,false,$shortanswer->answers));
263 fwrite ($bf,full_tag("USECASE",$level+1,false,$shortanswer->usecase));
264 $status = fwrite ($bf,end_tag("SHORTANSWER",$level,true));
265 }
266 //Now print question_answers
267 $status = question_backup_answers($bf,$preferences,$question);
268 }
269 return $status;
270 }
516cf3eb 271
315559d3 272/// RESTORE FUNCTIONS /////////////////
273
274 /*
275 * Restores the data in the question
276 *
277 * This is used in question/restorelib.php
278 */
279 function restore($old_question_id,$new_question_id,$info,$restore) {
9db7dab2 280 global $DB;
315559d3 281
282 $status = true;
283
284 //Get the shortanswers array
285 $shortanswers = $info['#']['SHORTANSWER'];
286
287 //Iterate over shortanswers
288 for($i = 0; $i < sizeof($shortanswers); $i++) {
289 $sho_info = $shortanswers[$i];
290
291 //Now, build the question_shortanswer record structure
5a14d563 292 $shortanswer = new stdClass;
315559d3 293 $shortanswer->question = $new_question_id;
294 $shortanswer->answers = backup_todb($sho_info['#']['ANSWERS']['0']['#']);
295 $shortanswer->usecase = backup_todb($sho_info['#']['USECASE']['0']['#']);
296
297 //We have to recode the answers field (a list of answers id)
298 //Extracts answer id from sequence
299 $answers_field = "";
300 $in_first = true;
301 $tok = strtok($shortanswer->answers,",");
302 while ($tok) {
303 //Get the answer from backup_ids
304 $answer = backup_getid($restore->backup_unique_code,"question_answers",$tok);
305 if ($answer) {
306 if ($in_first) {
307 $answers_field .= $answer->new_id;
308 $in_first = false;
309 } else {
310 $answers_field .= ",".$answer->new_id;
311 }
312 }
313 //check for next
314 $tok = strtok(",");
315 }
316 //We have the answers field recoded to its new ids
317 $shortanswer->answers = $answers_field;
318
319 //The structure is equal to the db, so insert the question_shortanswer
9db7dab2 320 $newid = $DB->insert_record ("question_shortanswer",$shortanswer);
315559d3 321
322 //Do some output
323 if (($i+1) % 50 == 0) {
324 if (!defined('RESTORE_SILENTLY')) {
325 echo ".";
326 if (($i+1) % 1000 == 0) {
327 echo "<br />";
328 }
329 }
330 backup_flush(300);
331 }
332
333 if (!$newid) {
334 $status = false;
335 }
336 }
337
338 return $status;
339 }
90a36f8c 340
341
93a501c1 342 /**
343 * Prints the score obtained and maximum score available plus any penalty
344 * information
345 *
346 * This function prints a summary of the scoring in the most recently
347 * graded state (the question may not have been submitted for marking at
348 * the current state). The default implementation should be suitable for most
349 * question types.
350 * @param object $question The question for which the grading details are
351 * to be rendered. Question type specific information
352 * is included. The maximum possible grade is in
353 * ->maxgrade.
354 * @param object $state The state. In particular the grading information
355 * is in ->grade, ->raw_grade and ->penalty.
356 * @param object $cmoptions
357 * @param object $options An object describing the rendering options.
358 */
359 function print_question_grading_details(&$question, &$state, $cmoptions, $options) {
360 /* The default implementation prints the number of marks if no attempt
361 has been made. Otherwise it displays the grade obtained out of the
362 maximum grade available and a warning if a penalty was applied for the
363 attempt and displays the overall grade obtained counting all previous
364 responses (and penalties) */
90a36f8c 365
20bf2c1a 366 global $QTYPES ;
93a501c1 367 // MDL-7496 show correct answer after "Incorrect"
368 $correctanswer = '';
20bf2c1a 369 if ($correctanswers = $QTYPES[$question->qtype]->get_correct_responses($question, $state)) {
93a501c1 370 if ($options->readonly && $options->correct_responses) {
371 $delimiter = '';
372 if ($correctanswers) {
373 foreach ($correctanswers as $ca) {
374 $correctanswer .= $delimiter.$ca;
375 $delimiter = ', ';
376 }
377 }
90a36f8c 378 }
93a501c1 379 }
90a36f8c 380
93a501c1 381 if (QUESTION_EVENTDUPLICATE == $state->event) {
382 echo ' ';
383 print_string('duplicateresponse', 'quiz');
384 }
385 if (!empty($question->maxgrade) && $options->scores) {
386 if (question_state_is_graded($state->last_graded)) {
387 // Display the grading details from the last graded state
388 $grade = new stdClass;
389 $grade->cur = round($state->last_graded->grade, $cmoptions->decimalpoints);
390 $grade->max = $question->maxgrade;
391 $grade->raw = round($state->last_graded->raw_grade, $cmoptions->decimalpoints);
392
393 // let student know wether the answer was correct
394 echo '<div class="correctness ';
395 if ($state->last_graded->raw_grade >= $question->maxgrade/1.01) { // We divide by 1.01 so that rounding errors dont matter.
396 echo ' correct">';
397 print_string('correct', 'quiz');
398 } else if ($state->last_graded->raw_grade > 0) {
399 echo ' partiallycorrect">';
400 print_string('partiallycorrect', 'quiz');
401 // MDL-7496
402 if ($correctanswer) {
e0c25647 403 echo ('<div class="correctness">');
ec2692a6 404 print_string('correctansweris', 'quiz', s($correctanswer, true));
93a501c1 405 echo ('</div>');
406 }
407 } else {
408 echo ' incorrect">';
409 // MDL-7496
90a36f8c 410 print_string('incorrect', 'quiz');
93a501c1 411 if ($correctanswer) {
e0c25647 412 echo ('<div class="correctness">');
ec2692a6 413 print_string('correctansweris', 'quiz', s($correctanswer, true));
93a501c1 414 echo ('</div>');
415 }
416 }
417 echo '</div>';
418
419 echo '<div class="gradingdetails">';
420 // print grade for this submission
421 print_string('gradingdetails', 'quiz', $grade);
422 if ($cmoptions->penaltyscheme) {
423 // print details of grade adjustment due to penalties
424 if ($state->last_graded->raw_grade > $state->last_graded->grade){
425 echo ' ';
426 print_string('gradingdetailsadjustment', 'quiz', $grade);
427 }
428 // print info about new penalty
429 // penalty is relevant only if the answer is not correct and further attempts are possible
430 if (($state->last_graded->raw_grade < $question->maxgrade) and (QUESTION_EVENTCLOSEANDGRADE !== $state->event)) {
431 if ('' !== $state->last_graded->penalty && ((float)$state->last_graded->penalty) > 0.0) {
432 // A penalty was applied so display it
433 echo ' ';
434 print_string('gradingdetailspenalty', 'quiz', $state->last_graded->penalty);
435 } else {
436 /* No penalty was applied even though the answer was
437 not correct (eg. a syntax error) so tell the student
438 that they were not penalised for the attempt */
439 echo ' ';
440 print_string('gradingdetailszeropenalty', 'quiz');
441 }
442 }
443 }
444 echo '</div>';
445 }
446 }
447 }
b9bd6da4 448
449 /**
450 * Runs all the code required to set up and save an essay question for testing purposes.
451 * Alternate DB table prefix may be used to facilitate data deletion.
452 */
453 function generate_test($name, $courseid = null) {
454 global $DB;
455 list($form, $question) = parent::generate_test($name, $courseid);
456 $question->category = $form->category;
457
458 $form->questiontext = "What is the purpose of life, the universe, and everything";
459 $form->generalfeedback = "Congratulations, you may have solved my biggest problem!";
460 $form->penalty = 0.1;
461 $form->usecase = false;
462 $form->defaultgrade = 1;
463 $form->noanswers = 3;
464 $form->answer = array('42', 'who cares?', 'Be happy');
465 $form->fraction = array(1, 0.6, 0.8);
466 $form->feedback = array('True, but what does that mean?', 'Well you do, dont you?', 'Yes, but thats not funny...');
467 $form->correctfeedback = 'Excellent!';
468 $form->incorrectfeedback = 'Nope!';
469 $form->partiallycorrectfeedback = 'Not bad';
470
471 if ($courseid) {
472 $course = $DB->get_record('course', array('id' => $courseid));
473 }
474
475 return $this->save_question($question, $form, $course);
476 }
516cf3eb 477}
478//// END OF CLASS ////
479
480//////////////////////////////////////////////////////////////////////////
481//// INITIATION - Without this line the question type is not in use... ///
482//////////////////////////////////////////////////////////////////////////
a2156789 483question_register_questiontype(new question_shortanswer_qtype());
516cf3eb 484?>