MDL-20636 html_writer MDL-25971 Support disabled => boolean, as well as disabled...
[moodle.git] / question / type / match / questiontype.php
CommitLineData
aeb15530 1<?php
516cf3eb 2
fe6ce234
DC
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
516cf3eb 18/////////////
19/// MATCH ///
20/////////////
21
22/// QUESTION TYPE CLASS //////////////////
1976496e 23/**
24 * @package questionbank
25 * @subpackage questiontypes
7375c542 26 */
af3830ee 27class question_match_qtype extends default_questiontype {
516cf3eb 28
29 function name() {
30 return 'match';
31 }
32
33 function get_question_options(&$question) {
f34488b2 34 global $DB;
35 $question->options = $DB->get_record('question_match', array('question' => $question->id));
36 $question->options->subquestions = $DB->get_records('question_match_sub', array('question' => $question->id), 'id ASC');
516cf3eb 37 return true;
38 }
39
40 function save_question_options($question) {
f34488b2 41 global $DB;
fe6ce234 42 $context = $question->context;
a58ffe3f 43 $result = new stdClass;
069a820a 44
69988ed4
TH
45 $oldsubquestions = $DB->get_records('question_match_sub',
46 array('question' => $question->id), 'id ASC');
516cf3eb 47
516cf3eb 48 // $subquestions will be an array with subquestion ids
49 $subquestions = array();
50
51 // Insert all the new question+answer pairs
52 foreach ($question->subquestions as $key => $questiontext) {
69988ed4
TH
53 if ($questiontext['text'] == '' && trim($question->subanswers[$key]) == '') {
54 continue;
55 }
56 if ($questiontext['text'] != '' && trim($question->subanswers[$key]) == '') {
57 $result->notice = get_string('nomatchinganswer', 'quiz', $questiontext);
cde2709a 58 }
69988ed4
TH
59
60 // Update an existing subquestion if possible.
61 $subquestion = array_shift($oldsubquestions);
62 if (!$subquestion) {
63 $subquestion = new stdClass;
64 // Determine a unique random code
65 $subquestion->code = rand(1, 999999999);
66 while ($DB->record_exists('question_match_sub', array('code' => $subquestion->code, 'question' => $question->id))) {
fe6ce234 67 $subquestion->code = rand(1, 999999999);
516cf3eb 68 }
69988ed4
TH
69 $subquestion->question = $question->id;
70 $subquestion->questiontext = '';
71 $subquestion->answertext = '';
72 $subquestion->id = $DB->insert_record('question_match_sub', $subquestion);
a58ffe3f 73 }
69988ed4
TH
74
75 $subquestion->questiontext = $this->import_or_save_files($questiontext,
76 $context, 'qtype_match', 'subquestion', $subquestion->id);
77 $subquestion->questiontextformat = $questiontext['format'];
78 $subquestion->answertext = trim($question->subanswers[$key]);
79
80 $DB->update_record('question_match_sub', $subquestion);
81
82 $subquestions[] = $subquestion->id;
516cf3eb 83 }
84
69988ed4
TH
85 // Delete old subquestions records
86 $fs = get_file_storage();
87 foreach($oldsubquestions as $oldsub) {
88 $fs->delete_area_files($context->id, 'qtype_match', 'subquestion', $oldsub->id);
89 $DB->delete_records('question_match_sub', array('id' => $oldsub->id));
516cf3eb 90 }
91
69988ed4
TH
92 if ($options = $DB->get_record('question_match', array('question' => $question->id))) {
93 $options->subquestions = implode(',', $subquestions);
516cf3eb 94 $options->shuffleanswers = $question->shuffleanswers;
69988ed4 95 $DB->update_record('question_match', $options);
516cf3eb 96 } else {
97 unset($options);
98 $options->question = $question->id;
69988ed4 99 $options->subquestions = implode(',', $subquestions);
516cf3eb 100 $options->shuffleanswers = $question->shuffleanswers;
69988ed4 101 $DB->insert_record('question_match', $options);
516cf3eb 102 }
a58ffe3f 103
104 if (!empty($result->notice)) {
105 return $result;
106 }
107
108 if (count($subquestions) < 3) {
109 $result->notice = get_string('notenoughanswers', 'quiz', 3);
110 return $result;
111 }
112
516cf3eb 113 return true;
114 }
115
9203b705 116 function delete_question($questionid, $contextid) {
f34488b2 117 global $DB;
9203b705
TH
118 $DB->delete_records('question_match', array('question' => $questionid));
119 $DB->delete_records('question_match_sub', array('question' => $questionid));
120
121 parent::delete_question($questionid, $contextid);
516cf3eb 122 }
123
124 function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) {
fef8f84e 125 global $DB, $OUTPUT;
f34488b2 126 if (!$state->options->subquestions = $DB->get_records('question_match_sub', array('question' => $question->id), 'id ASC')) {
fef8f84e 127 echo $OUTPUT->notification('Error: Missing subquestions!');
00c30069 128 return false;
516cf3eb 129 }
130
131 foreach ($state->options->subquestions as $key => $subquestion) {
132 // This seems rather over complicated, but it is useful for the
133 // randomsamatch questiontype, which can then inherit the print
134 // and grading functions. This way it is possible to define multiple
135 // answers per question, each with different marks and feedback.
136 $answer = new stdClass();
137 $answer->id = $subquestion->code;
138 $answer->answer = $subquestion->answertext;
139 $answer->fraction = 1.0;
f8394114 140 $state->options->subquestions[$key]->options->answers[$subquestion->code] = clone($answer);
516cf3eb 141
142 $state->responses[$key] = '';
143 }
144
145 // Shuffle the answers if required
146 if ($cmoptions->shuffleanswers and $question->options->shuffleanswers) {
147 $state->options->subquestions = swapshuffle_assoc($state->options->subquestions);
148 }
149
150 return true;
151 }
152
153 function restore_session_and_responses(&$question, &$state) {
fef8f84e 154 global $DB, $OUTPUT;
869309b8 155 static $subquestions = array();
156 if (!isset($subquestions[$question->id])){
157 if (!$subquestions[$question->id] = $DB->get_records('question_match_sub', array('question' => $question->id), 'id ASC')) {
fef8f84e 158 echo $OUTPUT->notification('Error: Missing subquestions!');
869309b8 159 return false;
160 }
161 }
f8394114 162
516cf3eb 163 // The serialized format for matching questions is a comma separated
164 // list of question answer pairs (e.g. 1-1,2-3,3-2), where the ids of
32a189d6 165 // both refer to the id in the table question_match_sub.
516cf3eb 166 $responses = explode(',', $state->responses['']);
f8394114 167 $responses = array_map(create_function('$val', 'return explode("-", $val);'), $responses);
516cf3eb 168
169 // Restore the previous responses and place the questions into the state options
170 $state->responses = array();
171 $state->options->subquestions = array();
172 foreach ($responses as $response) {
173 $state->responses[$response[0]] = $response[1];
869309b8 174 $state->options->subquestions[$response[0]] = clone($subquestions[$question->id][$response[0]]);
516cf3eb 175 }
176
177 foreach ($state->options->subquestions as $key => $subquestion) {
178 // This seems rather over complicated, but it is useful for the
179 // randomsamatch questiontype, which can then inherit the print
180 // and grading functions. This way it is possible to define multiple
181 // answers per question, each with different marks and feedback.
182 $answer = new stdClass();
183 $answer->id = $subquestion->code;
299d9fb8 184 $answer->answer = $subquestion->answertext;
516cf3eb 185 $answer->fraction = 1.0;
f8394114 186 $state->options->subquestions[$key]->options->answers[$subquestion->code] = clone($answer);
516cf3eb 187 }
188
189 return true;
190 }
191
192 function save_session_and_responses(&$question, &$state) {
f34488b2 193 global $DB;
87ee4968 194 $subquestions = &$state->options->subquestions;
195
196 // Prepare an array to help when disambiguating equal answers.
197 $answertexts = array();
198 foreach ($subquestions as $subquestion) {
199 $ans = reset($subquestion->options->answers);
200 $answertexts[$ans->id] = $ans->answer;
201 }
069a820a 202
516cf3eb 203 // Serialize responses
204 $responses = array();
87ee4968 205 foreach ($subquestions as $key => $subquestion) {
7d6af8ca 206 $response = 0;
bcda9611 207 if ($subquestion->questiontext !== '' && !is_null($subquestion->questiontext)) {
87ee4968 208 if ($state->responses[$key]) {
209 $response = $state->responses[$key];
210 if (!array_key_exists($response, $subquestion->options->answers)) {
bcda9611 211 // If student's answer did not match by id, but there may be
87ee4968 212 // two answers with the same text, but different ids,
213 // so we need to try matching the answer text.
214 $expected_answer = reset($subquestion->options->answers);
215 if ($answertexts[$response] == $expected_answer->answer) {
216 $response = $expected_answer->id;
217 $state->responses[$key] = $response;
218 }
219 }
87ee4968 220 }
0c24ee0f 221 }
7d6af8ca 222 $responses[] = $key.'-'.$response;
516cf3eb 223 }
224 $responses = implode(',', $responses);
225
226 // Set the legacy answer field
f685e830 227 $DB->set_field('question_states', 'answer', $responses, array('id' => $state->id));
516cf3eb 228 return true;
229 }
230
231 function get_correct_responses(&$question, &$state) {
232 $responses = array();
233 foreach ($state->options->subquestions as $sub) {
234 foreach ($sub->options->answers as $answer) {
bcda9611 235 if (1 == $answer->fraction && $sub->questiontext != '' && !is_null($sub->questiontext)) {
516cf3eb 236 $responses[$sub->id] = $answer->id;
237 }
238 }
239 }
240 return empty($responses) ? null : $responses;
241 }
242
243 function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) {
44c64ad8 244 global $CFG, $OUTPUT;
fe6ce234 245 $context = $this->get_context_by_category_id($question->category);
516cf3eb 246 $subquestions = $state->options->subquestions;
247 $correctanswers = $this->get_correct_responses($question, $state);
248 $nameprefix = $question->name_prefix;
299d9fb8 249 $answers = array(); // Answer choices formatted ready for output.
250 $allanswers = array(); // This and the next used to detect identical answers
251 $answerids = array(); // and adjust ids.
516cf3eb 252 $responses = &$state->responses;
253
069a820a 254 // Prepare a list of answers, removing duplicates.
516cf3eb 255 foreach ($subquestions as $subquestion) {
256 foreach ($subquestion->options->answers as $ans) {
0b4ce29d 257 $allanswers[$ans->id] = $ans->answer;
87ee4968 258 if (!in_array($ans->answer, $answers)) {
299d9fb8 259 $answers[$ans->id] = strip_tags(format_string($ans->answer, false));
87ee4968 260 $answerids[$ans->answer] = $ans->id;
87ee4968 261 }
516cf3eb 262 }
263 }
069a820a 264
0b4ce29d 265 // Fix up the ids of any responses that point the the eliminated duplicates.
266 foreach ($responses as $subquestionid => $ignored) {
267 if ($responses[$subquestionid]) {
268 $responses[$subquestionid] = $answerids[$allanswers[$responses[$subquestionid]]];
269 }
270 }
271 foreach ($correctanswers as $subquestionid => $ignored) {
272 $correctanswers[$subquestionid] = $answerids[$allanswers[$correctanswers[$subquestionid]]];
273 }
516cf3eb 274
275 // Shuffle the answers
276 $answers = draw_rand_array($answers, count($answers));
277
37a12367 278 // Print formulation
1b8a7434 279 $questiontext = $this->format_text($question->questiontext,
280 $question->questiontextformat, $cmoptions);
516cf3eb 281
069a820a 282 // Print the input controls
516cf3eb 283 foreach ($subquestions as $key => $subquestion) {
bcda9611 284 if ($subquestion->questiontext !== '' && !is_null($subquestion->questiontext)) {
069a820a 285 // Subquestion text:
a58ffe3f 286 $a = new stdClass;
fe6ce234
DC
287 $text = quiz_rewrite_question_urls($subquestion->questiontext, 'pluginfile.php', $context->id, 'qtype_match', 'subquestion', array($state->attempt, $state->question), $subquestion->id);
288 $a->text = $this->format_text($text, $subquestion->questiontextformat, $cmoptions);
2b087056 289
069a820a 290 // Drop-down list:
a58ffe3f 291 $menuname = $nameprefix.$subquestion->id;
292 $response = isset($state->responses[$subquestion->id])
293 ? $state->responses[$subquestion->id] : '0';
2b087056 294
295 $a->class = ' ';
296 $a->feedbackimg = ' ';
297
069a820a 298 if ($options->readonly and $options->correct_responses) {
299 if (isset($correctanswers[$subquestion->id])
2b087056 300 and ($correctanswers[$subquestion->id] == $response)) {
069a820a 301 $correctresponse = 1;
302 } else {
303 $correctresponse = 0;
304 }
2b087056 305
1b16ecd1 306 if ($options->feedback && $response) {
069a820a 307 $a->class = question_get_feedback_class($correctresponse);
308 $a->feedbackimg = question_get_feedback_image($correctresponse);
309 }
2b087056 310 }
aeb15530 311
c5dbaba9
PS
312 $attributes = array();
313 $attributes['disabled'] = $options->readonly ? 'disabled' : null;
314 $a->control = html_writer::select($answers, $menuname, $response, array(''=>'choosedots'), $attributes);
069a820a 315
a58ffe3f 316 // Neither the editing interface or the database allow to provide
317 // fedback for this question type.
318 // However (as was pointed out in bug bug 3294) the randomsamatch
319 // type which reuses this method can have feedback defined for
320 // the wrapped shortanswer questions.
321 //if ($options->feedback
322 // && !empty($subquestion->options->answers[$responses[$key]]->feedback)) {
323 // print_comment($subquestion->options->answers[$responses[$key]]->feedback);
324 //}
2b087056 325
a58ffe3f 326 $anss[] = $a;
516cf3eb 327 }
516cf3eb 328 }
aaae75b0 329 include("$CFG->dirroot/question/type/match/display.html");
516cf3eb 330 }
331
332 function grade_responses(&$question, &$state, $cmoptions) {
333 $subquestions = &$state->options->subquestions;
334 $responses = &$state->responses;
335
87ee4968 336 // Prepare an array to help when disambiguating equal answers.
337 $answertexts = array();
338 foreach ($subquestions as $subquestion) {
339 $ans = reset($subquestion->options->answers);
340 $answertexts[$ans->id] = $ans->answer;
341 }
069a820a 342
87ee4968 343 // Add up the grades from each subquestion.
516cf3eb 344 $sumgrade = 0;
a58ffe3f 345 $totalgrade = 0;
516cf3eb 346 foreach ($subquestions as $key => $sub) {
bcda9611 347 if ($sub->questiontext !== '' && !is_null($sub->questiontext)) {
a58ffe3f 348 $totalgrade += 1;
87ee4968 349 $response = $responses[$key];
350 if ($response && !array_key_exists($response, $sub->options->answers)) {
351 // If studen's answer did not match by id, but there may be
352 // two answers with the same text, but different ids,
353 // so we need to try matching the answer text.
354 $expected_answer = reset($sub->options->answers);
355 if ($answertexts[$response] == $expected_answer->answer) {
356 $response = $expected_answer->id;
357 }
358 }
359 if (array_key_exists($response, $sub->options->answers)) {
360 $sumgrade += $sub->options->answers[$response]->fraction;
a58ffe3f 361 }
516cf3eb 362 }
363 }
364
a58ffe3f 365 $state->raw_grade = $sumgrade/$totalgrade;
516cf3eb 366 if (empty($state->raw_grade)) {
367 $state->raw_grade = 0;
368 }
369
370 // Make sure we don't assign negative or too high marks
371 $state->raw_grade = min(max((float) $state->raw_grade,
372 0.0), 1.0) * $question->maxgrade;
373 $state->penalty = $question->penalty * $question->maxgrade;
374
f30bbcaf 375 // mark the state as graded
376 $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE;
377
516cf3eb 378 return true;
379 }
380
b25486fc 381 function compare_responses($question, $state, $teststate) {
2c89cfb5 382 foreach ($state->responses as $i=>$sr) {
383 if (empty($teststate->responses[$i])) {
384 if (!empty($state->responses[$i])) {
385 return false;
386 }
387 } else if ($state->responses[$i] != $teststate->responses[$i]) {
b25486fc 388 return false;
389 }
390 }
391 return true;
392 }
393
516cf3eb 394 // ULPGC ecastro for stats report
395 function get_all_responses($question, $state) {
974383f9 396 $answers = array();
516cf3eb 397 if (is_array($question->options->subquestions)) {
645d7610 398 foreach ($question->options->subquestions as $aid => $answer) {
bcda9611 399 if ($answer->questiontext !== '' && !is_null($answer->questiontext)) {
645d7610 400 $r = new stdClass;
401 $r->answer = $answer->questiontext . ": " . $answer->answertext;
402 $r->credit = 1;
403 $answers[$aid] = $r;
404 }
516cf3eb 405 }
516cf3eb 406 }
a58ffe3f 407 $result = new stdClass;
516cf3eb 408 $result->id = $question->id;
409 $result->responses = $answers;
410 return $result;
411 }
412
869309b8 413 function get_possible_responses(&$question) {
414 $answers = array();
415 if (is_array($question->options->subquestions)) {
416 foreach ($question->options->subquestions as $subqid => $answer) {
417 if ($answer->questiontext) {
418 $r = new stdClass;
419 $r->answer = $answer->questiontext . ": " . $answer->answertext;
420 $r->credit = 1;
421 $answers[$subqid] = array($answer->id =>$r);
422 }
423 }
424 }
425 return $answers;
426 }
aeb15530 427
516cf3eb 428 // ULPGC ecastro
429 function get_actual_response($question, $state) {
6d6b454a
TH
430 $subquestions = &$state->options->subquestions;
431 $responses = &$state->responses;
432 $results=array();
433 foreach ($responses as $ind => $code) {
434 foreach ($subquestions as $key => $sub) {
435 if (isset($sub->options->answers[$code])) {
436 $results[$ind] = $subquestions[$ind]->questiontext . ": " . $sub->options->answers[$code]->answer;
437 }
438 }
439 }
440 return $results;
441 }
aeb15530 442
869309b8 443 function get_actual_response_details($question, $state) {
444 $responses = $this->get_actual_response($question, $state);
445 $teacherresponses = $this->get_possible_responses($question, $state);
446 //only one response
447 $responsedetails =array();
448 foreach ($responses as $tsubqid => $response){
7f389342 449 $responsedetail = new stdClass();
869309b8 450 $responsedetail->subqid = $tsubqid;
451 $responsedetail->response = $response;
452 foreach ($teacherresponses[$tsubqid] as $aid => $tresponse){
453 if ($tresponse->answer == $response){
454 $responsedetail->aid = $aid;
455 break;
456 }
457 }
458 if (isset($responsedetail->aid)){
459 $responsedetail->credit = $teacherresponses[$tsubqid][$aid]->credit;
460 } else {
461 $responsedetail->aid = 0;
462 $responsedetail->credit = 0;
463 }
464 $responsedetails[] = $responsedetail;
465 }
466 return $responsedetails;
467 }
2280e147 468
a3b70fa9 469
6f51ed72 470 /**
471 * @param object $question
455c3efa 472 * @return mixed either a integer score out of 1 that the average random
473 * guess by a student might give or an empty string which means will not
474 * calculate.
6f51ed72 475 */
476 function get_random_guess_score($question) {
477 return 1 / count($question->options->subquestions);
478 }
069a820a 479
b9bd6da4 480 /**
481 * Runs all the code required to set up and save an essay question for testing purposes.
482 * Alternate DB table prefix may be used to facilitate data deletion.
483 */
484 function generate_test($name, $courseid = null) {
485 global $DB;
486 list($form, $question) = parent::generate_test($name, $courseid);
487 $form->shuffleanswers = 1;
488 $form->noanswers = 3;
489 $form->subquestions = array('cat', 'dog', 'cow');
490 $form->subanswers = array('feline', 'canine', 'bovine');
491
492 if ($courseid) {
493 $course = $DB->get_record('course', array('id' => $courseid));
494 }
495
94dbfb3a 496 return $this->save_question($question, $form);
b9bd6da4 497 }
fe6ce234 498
5d548d3e 499 function move_files($questionid, $oldcontextid, $newcontextid) {
fe6ce234 500 global $DB;
fe6ce234 501 $fs = get_file_storage();
fe6ce234 502
5d548d3e
TH
503 parent::move_files($questionid, $oldcontextid, $newcontextid);
504
505 $subquestionids = $DB->get_records_menu('question_match_sub',
506 array('question' => $questionid), 'id', 'id,1');
507 foreach ($subquestionids as $subquestionid => $notused) {
508 $fs->move_area_files_to_new_context($oldcontextid,
509 $newcontextid, 'qtype_match', 'subquestion', $subquestionid);
fe6ce234
DC
510 }
511 }
5d548d3e 512
9203b705
TH
513 protected function delete_files($questionid, $contextid) {
514 global $DB;
515 $fs = get_file_storage();
516
517 parent::delete_files($questionid, $contextid);
518
519 $subquestionids = $DB->get_records_menu('question_match_sub',
520 array('question' => $questionid), 'id', 'id,1');
521 foreach ($subquestionids as $subquestionid => $notused) {
522 $fs->delete_area_files($contextid, 'qtype_match', 'subquestion', $subquestionid);
523 }
524 }
525
fe6ce234
DC
526 function check_file_access($question, $state, $options, $contextid, $component,
527 $filearea, $args) {
528
529 $itemid = reset($args);
530 if ($filearea == 'subquestion') {
fe6ce234 531 // itemid is sub question id
b6664815 532 if (!array_key_exists($itemid, $question->options->subquestions)) {
fe6ce234
DC
533 return false;
534 }
b6664815 535
fe6ce234
DC
536 return true;
537 } else {
538 return parent::check_file_access($question, $state, $options, $contextid, $component,
539 $filearea, $args);
540 }
541 }
516cf3eb 542}
543//// END OF CLASS ////
544
545//////////////////////////////////////////////////////////////////////////
546//// INITIATION - Without this line the question type is not in use... ///
547//////////////////////////////////////////////////////////////////////////
a2156789 548question_register_questiontype(new question_match_qtype());