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