2 /****************************************************************/
4 /****************************************************************/
5 /* Copyright (c) 2002-2007 by Greg Gay & Joel Kronenberg */
6 /* Adaptive Technology Resource Centre / University of Toronto */
9 /* This program is free software. You can redistribute it and/or*/
10 /* modify it under the terms of the GNU General Public License */
11 /* as published by the Free Software Foundation. */
12 /****************************************************************/
16 * Steps to follow when adding a new question type:
18 * 1 - Create a class extending AbstractQuestion or extend an
19 * existing question class.
20 * Define $sPrefix and $sNameVar appropriately.
21 * Implement the following methods, which set template variables:
23 * assignQTIVariables()
24 * assignDisplayResultVariables()
25 * assignDisplayVariables()
26 * assignDisplayStatisticsVariables()
28 * And implement mark() which is used for marking the result.
30 * 2 - Add the new class name to $question_classes in test_question_factory()
32 * 3 - Add $sNameVar to the language database.
34 * 4 - Create the following files for creating and editing the question,
35 * where "{PREFIX}" is the value defined by $sPrefix:
37 * /tools/tests/create_question_{PREFIX}.php
38 * /tools/tests/edit_question_{PREFIX}.php
40 * 5 - Add those two newly created pages to
41 * /mods/_standard/tests/module.php
43 * 6 - Create the following template files:
45 * /themes/default/test_questions/{PREFIX}.tmpl.php
46 * /themes/default/test_questions/{PREFIX}_qti_2p1.tmpl.php
47 * /themes/default/test_questions/{PREFIX}_result.tmpl.php
48 * /themes/default/test_questions/{PREFIX}_stats.tmpl.php
54 // returns array of prefix => name, sorted!
55 /*static */function getQuestionPrefixNames() {
56 $question_prefix_names = array(); // prefix => name
57 $questions = TestQuestions::getQuestionClasses();
58 foreach ($questions as $type => $question) {
59 $o = TestQuestions::getQuestion($type);
60 $question_prefix_names[$o->getPrefix()] = $o->getName();
62 asort($question_prefix_names);
63 return $question_prefix_names;
66 /*static */function getQuestionClasses() {
67 /** NOTE: The indices are CONSTANTS. Do NOT change!! **/
68 $question_classes = array(); // type ID => class name
69 $question_classes[1] = 'MultichoiceQuestion';
70 $question_classes[2] = 'TruefalseQuestion';
71 $question_classes[3] = 'LongQuestion';
72 $question_classes[4] = 'LikertQuestion';
73 $question_classes[5] = 'MatchingQuestion';
74 $question_classes[6] = 'OrderingQuestion';
75 $question_classes[7] = 'MultianswerQuestion';
76 $question_classes[8] = 'MatchingddQuestion';
78 return $question_classes;
82 * Used to create question objects based on $question_type.
83 * A singleton that creates one obj per question since
84 * questions are all stateless.
85 * Returns a reference to the question object.
87 /*static */function & getQuestion($question_type) {
88 static $objs, $question_classes;
90 if (isset($objs[$question_type])) {
91 return $objs[$question_type];
94 $question_classes = TestQuestions::getQuestionClasses();
96 if (isset($question_classes[$question_type])) {
98 $objs[$question_type] =& new $question_classes[$question_type]($savant);
103 return $objs[$question_type];
107 function test_question_qti_export(/* array */ $question_ids) {
108 require(AT_INCLUDE_PATH.'classes/zipfile.class.php'); // for zipfile
109 require(AT_INCLUDE_PATH.'lib/html_resource_parser.inc.php'); // for get_html_resources()
110 require(AT_INCLUDE_PATH.'classes/XML/XML_HTMLSax/XML_HTMLSax.php'); // for XML_HTMLSax
112 global $savant, $db, $system_courses, $languageManager;
114 $course_language = $system_courses[$_SESSION['course_id']]['primary_language'];
115 $courseLanguage =& $languageManager->getLanguage($course_language);
116 $course_language_charset = $courseLanguage->getCharacterSet();
118 $zipfile = new zipfile();
119 $zipfile->create_dir('resources/'); // for all the dependency files
120 $resources = array();
121 $dependencies = array();
123 asort($question_ids);
125 $question_ids_delim = implode(',',$question_ids);
126 $sql = "SELECT * FROM ".TABLE_PREFIX."tests_questions WHERE course_id=$_SESSION[course_id] AND question_id IN($question_ids_delim)";
127 $result = mysql_query($sql, $db);
129 while ($row = mysql_fetch_assoc($result)) {
130 $obj = test_question_factory($row['type']);
131 $xml = $obj->exportQTI($row, $course_language_charset);
132 $local_dependencies = array();
134 $text_blob = implode(' ', $row);
135 $local_dependencies = get_html_resources($text_blob);
136 $dependencies = array_merge($dependencies, $local_dependencies);
138 $resources[] = array('href' => 'question_'.$row['question_id'].'.xml',
139 'dependencies' => array_keys($local_dependencies));
141 $zipfile->add_file($xml, 'question_'.$row['question_id'].'.xml');
144 // add any dependency files:
145 foreach ($dependencies as $resource => $resource_server_path) {
146 $zipfile->add_file(@file_get_contents($resource_server_path), 'resources/' . $resource, filemtime($resource_server_path));
149 // construct the manifest xml
150 $savant->assign('resources', $resources);
151 $savant->assign('dependencies', array_keys($dependencies));
152 $savant->assign('encoding', $course_language_charset);
153 $manifest_xml = $savant->fetch('test_questions/manifest_qti_2p1.tmpl.php');
155 $zipfile->add_file($manifest_xml, 'imsmanifest.xml');
159 $filename = str_replace(array(' ', ':'), '_', $_SESSION['course_title'].'-'._AT('question_database').'-'.date('Ymd'));
160 $zipfile->send_file($filename);
165 * keeps count of the question number (when displaying the question)
166 * need this function because PHP 4 doesn't support static members
168 function TestQuestionCounter($increment = FALSE) {
171 if (!isset($count)) {
185 * Note that all PHP 5 OO declarations and signatures are commented out to be
186 * backwards compatible with PHP 4.
189 /*abstract */ class AbstractTestQuestion {
191 * Savant2 $savant - refrence to the savant obj
193 /*protected */ var $savant;
196 * Constructor method. Initialises variables.
198 function AbstractTestQuestion(&$savant) { $this->savant =& $savant; }
203 /*final public */function seed($salt) {
205 * by controlling the seed before calling array_rand() we insure that
206 * we can un-randomize the order for marking.
207 * used with ordering type questions only.
209 srand($salt + ord(DB_PASSWORD) + $_SESSION['member_id']);
214 * Prints the name of this question
216 /*final public */function printName() { echo $this->getName(); }
220 * Prints the name of this question
222 /*final public */function getName() { return _AT($this->sNameVar); }
226 * Returns the prefix string (used for file names)
228 /*final public */function getPrefix() { return $this->sPrefix; }
231 * Display the current question (for taking or previewing a test/question)
233 /*final public */function display($row) {
234 // print the generic question header
235 $this->displayHeader($row['weight']);
237 // print the question specific template
238 $this->assignDisplayVariables($row);
239 $this->savant->display('test_questions/' . $this->sPrefix . '.tmpl.php');
241 // print the generic question footer
242 $this->displayFooter();
246 * Display the result for the current question
248 /*final public */function displayResult($row, $answer_row, $editable = FALSE) {
249 // print the generic question header
250 $this->displayHeader($row['weight'], (int) $answer_row['score'], $editable ? $row['question_id'] : FALSE);
252 // print the question specific template
253 $this->assignDisplayResultVariables($row, $answer_row);
254 $this->savant->display('test_questions/' . $this->sPrefix . '_result.tmpl.php');
256 // print the generic question footer
257 $this->displayFooter();
262 * print the question template header
264 /*final public */function displayResultStatistics($row, $answers) {
265 TestQuestionCounter(TRUE);
267 $this->assignDisplayStatisticsVariables($row, $answers);
268 $this->savant->display('test_questions/' . $this->sPrefix . '_stats.tmpl.php');
271 /*final public */function exportQTI($row, $encoding) {
272 $this->savant->assign('encoding', $encoding);
273 $this->assignQTIVariables($row);
274 $xml = $this->savant->fetch('test_questions/'. $this->sPrefix . '_qti_2p1.tmpl.php');
280 * print the question template header
282 /*final private */function displayHeader($weight, $score = FALSE, $question_id = FALSE) {
283 TestQuestionCounter(TRUE);
285 $this->savant->assign('question_id', $question_id);
286 $this->savant->assign('score', $score);
287 $this->savant->assign('weight', $weight);
288 $this->savant->assign('type', _AT($this->sNameVar));
289 $this->savant->assign('number', TestQuestionCounter());
290 $this->savant->display('test_questions/header.tmpl.php');
294 * print the question template footer
296 /*final private */function displayFooter() {
297 $this->savant->display('test_questions/footer.tmpl.php');
301 * return only the non-empty choices from $row.
302 * assumes choices are sequential.
304 /*protected */function getChoices($row) {
306 for ($i=0; $i < 10; $i++) {
307 if ($row['choice_'.$i] != '') {
309 $choices[] = $row['choice_'.$i];
322 class OrderingQuestion extends AbstractTestQuestion {
323 /*protected */ var $sNameVar = 'test_ordering';
324 /*protected */ var $sPrefix = 'ordering';
326 /*protected */function assignDisplayResultVariables($row, $answer_row) {
327 $answers = explode('|', $answer_row['answer']);
329 $num_choices = count($this->getChoices($row));
331 $this->savant->assign('base_href', AT_BASE_HREF);
332 $this->savant->assign('num_choices', $num_choices);
333 $this->savant->assign('answers', $answers);
334 $this->savant->assign('row', $row);
337 /*protected */function assignQTIVariables($row) {
338 $choices = $this->getChoices($row);
339 $num_choices = count($choices);
341 $this->savant->assign('num_choices', $num_choices);
342 $this->savant->assign('row', $row);
345 /*protected */function assignDisplayVariables($row) {
346 // determine the number of choices this question has
347 // and save those choices to be re-assigned back to $row
348 // in the randomized order.
349 $choices = $this->getChoices($row);
350 $num_choices = count($choices);
352 // randomize the order of choices and re-assign to $row
353 $this->seed($row['question_id']);
354 $rand = array_rand($choices, $num_choices);
355 for ($i=0; $i < 10; $i++) {
356 $row['choice_'.$i] = $choices[$rand[$i]];
359 $this->savant->assign('num_choices', $num_choices);
360 $this->savant->assign('row', $row);
363 /*protected */function assignDisplayStatisticsVariables($row, $answers) {
365 foreach ($answers as $answer) {
366 $num_results += $answer['count'];
369 $choices = $this->getChoices($row);
370 $num_choices = count($choices);
372 $final_answers = array(); // assoc array of # of times that key was used correctly 0, 1, ... $num -1
373 foreach ($answers as $key => $value) {
374 $values = explode('|', $key);
375 // we assume $values is never empty and contains $num number of answers
376 for ($i=0; $i<=$num_choices; $i++) {
377 if ($values[$i] == $i) {
378 $final_answers[$i] += $answers[$key]['count'];
383 $this->savant->assign('num_results', $num_results);
384 $this->savant->assign('num_choices', $num_choices);
385 $this->savant->assign('answers', $final_answers);
386 $this->savant->assign('row', $row);
389 /*public */function mark($row) {
390 $this->seed($row['question_id']);
391 $num_choices = count($_POST['answers'][$row['question_id']]);
392 $answers = range(0, $num_choices-1);
393 $answers = array_rand($answers, $num_choices);
395 $num_answer_correct = 0;
397 $ordered_answers = array();
399 for ($i = 0; $i < $num_choices ; $i++) {
400 $_POST['answers'][$row['question_id']][$i] = intval($_POST['answers'][$row['question_id']][$i]);
402 if ($_POST['answers'][$row['question_id']][$i] == -1) {
403 // nothing to do. it was left blank
404 } else if ($_POST['answers'][$row['question_id']][$i] == $answers[$i]) {
405 $num_answer_correct++;
407 $ordered_answers[$answers[$i]] = $_POST['answers'][$row['question_id']][$i];
409 ksort($ordered_answers);
413 // to avoid roundoff errors:
414 if ($num_answer_correct == $num_choices) {
415 $score = $row['weight'];
416 } else if ($num_answer_correct > 0) {
417 $score = number_format($row['weight'] / $num_choices * $num_answer_correct, 2);
418 if ( (float) (int) $score == $score) {
419 $score = (int) $score; // a whole number with decimals, eg. "2.00"
421 $score = trim($score, '0'); // remove trailing zeros, if any, eg. "2.50"
425 $_POST['answers'][$row['question_id']] = implode('|', $ordered_answers);
435 class TruefalseQuestion extends AbstracttestQuestion {
436 /*protected */ var $sPrefix = 'truefalse';
437 /*protected */ var $sNameVar = 'test_tf';
439 /*protected */function assignQTIVariables($row) {
440 $this->savant->assign('row', $row);
443 /*protected */function assignDisplayResultVariables($row, $answer_row) {
445 $this->savant->assign('base_href', AT_BASE_HREF);
446 $this->savant->assign('answers', $answer_row['answer']);
447 $this->savant->assign('row', $row);
450 /*protected */function assignDisplayVariables($row) {
451 $this->savant->assign('row', $row);
454 /*protected */function assignDisplayStatisticsVariables($row, $answers) {
456 foreach ($answers as $answer) {
457 $num_results += $answer['count'];
460 $this->savant->assign('num_results', $num_results);
461 $this->savant->assign('num_blanks', (int) $answers['-1']['count']);
462 $this->savant->assign('num_true', (int) $answers['1']['count']);
463 $this->savant->assign('num_false', (int) $answers['2']['count']);
464 $this->savant->assign('row', $row);
467 /*public */function mark($row) {
468 $_POST['answers'][$row['question_id']] = intval($_POST['answers'][$row['question_id']]);
470 if ($row['answer_0'] == $_POST['answers'][$row['question_id']]) {
471 return (int) $row['weight'];
481 class LikertQuestion extends AbstracttestQuestion {
482 /*protected */ var $sPrefix = 'likert';
483 /*protected */ var $sNameVar = 'test_lk';
485 /*protected */function assignQTIVariables($row) {
486 $choices = $this->getChoices($row);
487 $num_choices = count($choices);
489 $this->savant->assign('num_choices', $num_choices);
490 $this->savant->assign('row', $row);
493 /*protected */function assignDisplayResultVariables($row, $answer_row) {
494 $this->savant->assign('answer', $answer_row['answer']);
495 $this->savant->assign('row', $row);
498 /*protected */function assignDisplayVariables($row) {
499 $choices = $this->getChoices($row);
500 $num_choices = count($choices);
502 $this->savant->assign('num_choices', $num_choices);
503 $this->savant->assign('row', $row);
506 /*protected */function assignDisplayStatisticsVariables($row, $answers) {
508 foreach ($answers as $answer) {
509 $num_results += $answer['count'];
512 $choices = $this->getChoices($row);
513 $num_choices = count($choices);
516 for ($i=0; $i<$num_choices; $i++) {
517 $sum += ($i+1) * $answers[$i]['count'];
519 $average = round($sum/$num_results, 1);
521 $this->savant->assign('num_results', $num_results);
522 $this->savant->assign('average', $average);
523 $this->savant->assign('num_choices', $num_choices);
524 $this->savant->assign('num_blanks', (int) $answers['-1']['count']);
525 $this->savant->assign('answers', $answers);
526 $this->savant->assign('row', $row);
529 /*public */function mark($row) {
530 $_POST['answers'][$row['question_id']] = intval($_POST['answers'][$row['question_id']]);
539 class LongQuestion extends AbstracttestQuestion {
540 /*protected */ var $sPrefix = 'long';
541 /*protected */ var $sNameVar = 'test_open';
543 /*protected */function assignQTIVariables($row) {
544 $this->savant->assign('row', $row);
547 /*protected */function assignDisplayResultVariables($row, $answer_row) {
548 $this->savant->assign('answer', $answer_row['answer']);
549 $this->savant->assign('row', $row);
552 /*protected */function assignDisplayVariables($row) {
553 $this->savant->assign('row', $row);
556 /*protected */function assignDisplayStatisticsVariables($row, $answers) {
558 foreach ($answers as $answer) {
559 $num_results += $answer['count'];
562 $this->savant->assign('num_results', $num_results);
563 $this->savant->assign('num_blanks', (int) $answers['']['count']);
564 $this->savant->assign('answers', $answers);
565 $this->savant->assign('row', $row);
568 /*public */function mark($row) {
570 $_POST['answers'][$row['question_id']] = $addslashes($_POST['answers'][$row['question_id']]);
579 class MatchingQuestion extends AbstracttestQuestion {
580 /*protected */ var $sPrefix = 'matching';
581 /*protected */ var $sNameVar = 'test_matching';
583 /*protected */function assignQTIVariables($row) {
584 $choices = $this->getChoices($row);
585 $num_choices = count($choices);
588 for ($i=0; $i < 10; $i++) {
589 if ($row['option_'. $i] != '') {
594 $this->savant->assign('num_choices', $num_choices);
595 $this->savant->assign('num_options', $num_options);
596 $this->savant->assign('row', $row);
599 /*protected */function assignDisplayResultVariables($row, $answer_row) {
601 for ($i=0; $i < 10; $i++) {
602 if ($row['option_'. $i] != '') {
607 $answer_row['answer'] = explode('|', $answer_row['answer']);
611 $this->savant->assign('base_href', AT_BASE_HREF);
612 $this->savant->assign('answers', $answer_row['answer']);
613 $this->savant->assign('letters', $_letters);
614 $this->savant->assign('num_options', $num_options);
615 $this->savant->assign('row', $row);
618 /*protected */function assignDisplayVariables($row) {
619 $choices = $this->getChoices($row);
620 $num_choices = count($choices);
623 for ($i=0; $i < 10; $i++) {
624 if ($row['option_'. $i] != '') {
631 $this->savant->assign('num_choices', $num_choices);
632 $this->savant->assign('base_href', AT_BASE_HREF);
633 $this->savant->assign('letters', $_letters);
634 $this->savant->assign('num_options', $num_options);
635 $this->savant->assign('row', $row);
638 /*protected */function assignDisplayStatisticsVariables($row, $answers) {
639 $choices = $this->getChoices($row);
640 $num_choices = count($choices);
643 foreach ($answers as $answer) {
644 $num_results += $answer['count'];
647 foreach ($answers as $key => $value) {
648 $values = explode('|', $key);
649 if (count($values) > 1) {
650 for ($i=0; $i<count($values); $i++) {
651 $answers[$values[$i]]['count']++;
656 $this->savant->assign('num_choices', $num_choices);
657 $this->savant->assign('num_results', $num_results);
658 $this->savant->assign('answers', $answers);
659 $this->savant->assign('row', $row);
662 /*public */function mark($row) {
663 $num_choices = count($_POST['answers'][$row['question_id']]);
664 $num_answer_correct = 0;
665 foreach ($_POST['answers'][$row['question_id']] as $item_id => $response) {
666 if ($row['answer_' . $item_id] == $response) {
667 $num_answer_correct++;
669 $_POST['answers'][$row['question_id']][$item_id] = intval($_POST['answers'][$row['question_id']][$item_id]);
673 // to avoid roundoff errors:
674 if ($num_answer_correct == $num_choices) {
675 $score = $row['weight'];
676 } else if ($num_answer_correct > 0) {
677 $score = number_format($row['weight'] / $num_choices * $num_answer_correct, 2);
678 if ( (float) (int) $score == $score) {
679 $score = (int) $score; // a whole number with decimals, eg. "2.00"
681 $score = trim($score, '0'); // remove trailing zeros, if any
685 $_POST['answers'][$row['question_id']] = implode('|', $_POST['answers'][$row['question_id']]);
695 class MatchingddQuestion extends MatchingQuestion {
696 /*protected */ var $sPrefix = 'matchingdd';
697 /*protected */ var $sNameVar = 'test_matchingdd';
701 * multichoiceQuestion
704 class MultichoiceQuestion extends AbstracttestQuestion {
705 /*protected */ var $sPrefix = 'multichoice';
706 /*protected */var $sNameVar = 'test_mc';
708 /*protected */function assignQTIVariables($row) {
709 $choices = $this->getChoices($row);
710 $num_choices = count($choices);
712 $this->savant->assign('num_choices', $num_choices);
713 $this->savant->assign('row', $row);
716 /*protected */function assignDisplayResultVariables($row, $answer_row) {
717 if (array_sum(array_slice($row, 16, -6)) > 1) {
718 $answer_row['answer'] = explode('|', $answer_row['answer']);
720 $answer_row['answer'] = array($answer_row['answer']);
724 $this->savant->assign('base_href', AT_BASE_HREF);
725 $this->savant->assign('answers', $answer_row['answer']);
726 $this->savant->assign('row', $row);
729 /*protected */function assignDisplayVariables($row) {
730 $choices = $this->getChoices($row);
731 $num_choices = count($choices);
733 $this->savant->assign('num_choices', $num_choices);
734 $this->savant->assign('row', $row);
737 /*protected */function assignDisplayStatisticsVariables($row, $answers) {
738 $choices = $this->getChoices($row);
739 $num_choices = count($choices);
742 foreach ($answers as $answer) {
743 $num_results += $answer['count'];
746 foreach ($answers as $key => $value) {
747 $values = explode('|', $key);
748 if (count($values) > 1) {
749 for ($i=0; $i<count($values); $i++) {
750 $answers[$values[$i]]['count']++;
755 $this->savant->assign('num_choices', $num_choices);
756 $this->savant->assign('num_results', $num_results);
757 $this->savant->assign('num_blanks', (int) $answers['-1']['count']);
758 $this->savant->assign('answers', $answers);
759 $this->savant->assign('row', $row);
762 /*public */function mark($row) {
763 $_POST['answers'][$row['question_id']] = intval($_POST['answers'][$row['question_id']]);
764 if ($row['answer_' . $_POST['answers'][$row['question_id']]]) {
765 $score = $row['weight'];
766 } else if ($_POST['answers'][$row['question_id']] == -1) {
768 for($i=0; $i<10; $i++) {
769 $has_answer += $row['answer_'.$i];
771 if (!$has_answer && $row['weight']) {
772 // If MC has no answer and user answered "leave blank"
773 $score = $row['weight'];
781 * multianswerQuestion
784 class MultianswerQuestion extends MultichoiceQuestion {
785 /*protected */ var $sPrefix = 'multianswer';
786 /*protected */ var $sNameVar = 'test_ma';
788 /*public */function mark($row) {
789 $num_correct = array_sum(array_slice($row, 3));
791 if (is_array($_POST['answers'][$row['question_id']]) && count($_POST['answers'][$row['question_id']]) > 1) {
792 if (($i = array_search('-1', $_POST['answers'][$row['question_id']])) !== FALSE) {
793 unset($_POST['answers'][$row['question_id']][$i]);
795 $num_answer_correct = 0;
796 foreach ($_POST['answers'][$row['question_id']] as $item_id => $answer) {
797 if ($row['answer_' . $answer]) {
799 $num_answer_correct++;
802 $num_answer_correct--;
804 $_POST['answers'][$row['question_id']][$item_id] = intval($_POST['answers'][$row['question_id']][$item_id]);
806 if ($num_answer_correct == $num_correct) {
807 $score = $row['weight'];
811 $_POST['answers'][$row['question_id']] = implode('|', $_POST['answers'][$row['question_id']]);
814 $_POST['answers'][$row['question_id']] = '-1'; // left blank