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