glossary->id for block is now properly recoded by restore. MDL-4934 ; merged from...
[moodle-linuxchix.git] / lib / questionlib.php
blob543b7595ac1069235924ad74a5852e232f961807
1 <?php // $Id$
2 /**
3 * Code for handling and processing questions
5 * This is code that is module independent, i.e., can be used by any module that
6 * uses questions, like quiz, lesson, ..
7 * This script also loads the questiontype classes
8 * Code for handling the editing of questions is in {@link question/editlib.php}
10 * TODO: separate those functions which form part of the API
11 * from the helper functions.
13 * @author Martin Dougiamas and many others. This has recently been completely
14 * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
15 * the Serving Mathematics project
16 * {@link http://maths.york.ac.uk/serving_maths}
17 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
18 * @package question
21 /// CONSTANTS ///////////////////////////////////
23 /**#@+
24 * The different types of events that can create question states
26 define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
27 define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
28 define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
29 define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
30 define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
31 define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
32 define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
33 define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
34 define('QUESTION_EVENTCLOSE', '8'); // The response has been submitted and the session has been closed, either because the student requested it or because Moodle did it (e.g. because of a timelimit). The responses have not been graded.
35 define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
36 /**#@-*/
38 /**#@+
39 * The core question types.
41 define("SHORTANSWER", "shortanswer");
42 define("TRUEFALSE", "truefalse");
43 define("MULTICHOICE", "multichoice");
44 define("RANDOM", "random");
45 define("MATCH", "match");
46 define("RANDOMSAMATCH", "randomsamatch");
47 define("DESCRIPTION", "description");
48 define("NUMERICAL", "numerical");
49 define("MULTIANSWER", "multianswer");
50 define("CALCULATED", "calculated");
51 define("ESSAY", "essay");
52 /**#@-*/
54 /**
55 * Constant determines the number of answer boxes supplied in the editing
56 * form for multiple choice and similar question types.
58 define("QUESTION_NUMANS", "10");
60 /**
61 * Constant determines the number of answer boxes supplied in the editing
62 * form for multiple choice and similar question types to start with, with
63 * the option of adding QUESTION_NUMANS_ADD more answers.
65 define("QUESTION_NUMANS_START", 3);
67 /**
68 * Constant determines the number of answer boxes to add in the editing
69 * form for multiple choice and similar question types when the user presses
70 * 'add form fields button'.
72 define("QUESTION_NUMANS_ADD", 3);
74 /**
75 * The options used when popping up a question preview window in Javascript.
77 define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700,height=540');
79 /**#@+
80 * Option flags for ->optionflags
81 * The options are read out via bitwise operation using these constants
83 /**
84 * Whether the questions is to be run in adaptive mode. If this is not set then
85 * a question closes immediately after the first submission of responses. This
86 * is how question is Moodle always worked before version 1.5
88 define('QUESTION_ADAPTIVE', 1);
90 /**
91 * options used in forms that move files.
94 define('QUESTION_FILENOTHINGSELECTED', 0);
95 define('QUESTION_FILEDONOTHING', 1);
96 define('QUESTION_FILECOPY', 2);
97 define('QUESTION_FILEMOVE', 3);
98 define('QUESTION_FILEMOVELINKSONLY', 4);
100 /**#@-*/
102 /// QTYPES INITIATION //////////////////
103 // These variables get initialised via calls to question_register_questiontype
104 // as the question type classes are included.
105 global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
107 * Array holding question type objects
109 $QTYPES = array();
111 * String in the format "'type1','type2'" that can be used in SQL clauses like
112 * "WHERE q.type IN ($QTYPE_MANUAL)".
114 $QTYPE_MANUAL = '';
116 * String in the format "'type1','type2'" that can be used in SQL clauses like
117 * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)".
119 $QTYPE_EXCLUDE_FROM_RANDOM = '';
122 * Add a new question type to the various global arrays above.
124 * @param object $qtype An instance of the new question type class.
126 function question_register_questiontype($qtype) {
127 global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
129 $name = $qtype->name();
130 $QTYPES[$name] = $qtype;
131 if ($qtype->is_manual_graded()) {
132 if ($QTYPE_MANUAL) {
133 $QTYPE_MANUAL .= ',';
135 $QTYPE_MANUAL .= "'$name'";
137 if (!$qtype->is_usable_by_random()) {
138 if ($QTYPE_EXCLUDE_FROM_RANDOM) {
139 $QTYPE_EXCLUDE_FROM_RANDOM .= ',';
141 $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'";
145 require_once("$CFG->dirroot/question/type/questiontype.php");
147 // Load the questiontype.php file for each question type
148 // These files in turn call question_register_questiontype()
149 // with a new instance of each qtype class.
150 $qtypenames= get_list_of_plugins('question/type');
151 foreach($qtypenames as $qtypename) {
152 // Instanciates all plug-in question types
153 $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
155 // echo "Loading $qtypename<br/>"; // Uncomment for debugging
156 if (is_readable($qtypefilepath)) {
157 require_once($qtypefilepath);
162 * An array of question type names translated to the user's language, suitable for use when
163 * creating a drop-down menu of options.
165 * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
166 * The array returned will only hold the names of all the question types that the user should
167 * be able to create directly. Some internal question types like random questions are excluded.
169 * @return array an array of question type names translated to the user's language.
171 function question_type_menu() {
172 global $QTYPES;
173 static $menu_options = null;
174 if (is_null($menu_options)) {
175 $menu_options = array();
176 foreach ($QTYPES as $name => $qtype) {
177 $menuname = $qtype->menu_name();
178 if ($menuname) {
179 $menu_options[$name] = $menuname;
183 return $menu_options;
186 /// OTHER CLASSES /////////////////////////////////////////////////////////
189 * This holds the options that are set by the course module
191 class cmoptions {
193 * Whether a new attempt should be based on the previous one. If true
194 * then a new attempt will start in a state where all responses are set
195 * to the last responses from the previous attempt.
197 var $attemptonlast = false;
200 * Various option flags. The flags are accessed via bitwise operations
201 * using the constants defined in the CONSTANTS section above.
203 var $optionflags = QUESTION_ADAPTIVE;
206 * Determines whether in the calculation of the score for a question
207 * penalties for earlier wrong responses within the same attempt will
208 * be subtracted.
210 var $penaltyscheme = true;
213 * The maximum time the user is allowed to answer the questions withing
214 * an attempt. This is measured in minutes so needs to be multiplied by
215 * 60 before compared to timestamps. If set to 0 no timelimit will be applied
217 var $timelimit = 0;
220 * Timestamp for the closing time. Responses submitted after this time will
221 * be saved but no credit will be given for them.
223 var $timeclose = 9999999999;
226 * The id of the course from withing which the question is currently being used
228 var $course = SITEID;
231 * Whether the answers in a multiple choice question should be randomly
232 * shuffled when a new attempt is started.
234 var $shuffleanswers = true;
237 * The number of decimals to be shown when scores are printed
239 var $decimalpoints = 2;
243 /// FUNCTIONS //////////////////////////////////////////////////////
246 * Returns an array of names of activity modules that use this question
248 * @param object $questionid
249 * @return array of strings
251 function question_list_instances($questionid) {
252 $instances = array();
253 $modules = get_records('modules');
254 foreach ($modules as $module) {
255 $fn = $module->name.'_question_list_instances';
256 if (function_exists($fn)) {
257 $instances = $instances + $fn($questionid);
260 return $instances;
265 * Returns list of 'allowed' grades for grade selection
266 * formatted suitably for dropdown box function
267 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
269 function get_grade_options() {
270 // define basic array of grades
271 $grades = array(
273 0.9,
274 0.8,
275 0.75,
276 0.70,
277 0.66666,
278 0.60,
279 0.50,
280 0.40,
281 0.33333,
282 0.30,
283 0.25,
284 0.20,
285 0.16666,
286 0.142857,
287 0.125,
288 0.11111,
289 0.10,
290 0.05,
293 // iterate through grades generating full range of options
294 $gradeoptionsfull = array();
295 $gradeoptions = array();
296 foreach ($grades as $grade) {
297 $percentage = 100 * $grade;
298 $neggrade = -$grade;
299 $gradeoptions["$grade"] = "$percentage %";
300 $gradeoptionsfull["$grade"] = "$percentage %";
301 $gradeoptionsfull["$neggrade"] = -$percentage." %";
303 $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
305 // sort lists
306 arsort($gradeoptions, SORT_NUMERIC);
307 arsort($gradeoptionsfull, SORT_NUMERIC);
309 // construct return object
310 $grades = new stdClass;
311 $grades->gradeoptions = $gradeoptions;
312 $grades->gradeoptionsfull = $gradeoptionsfull;
314 return $grades;
318 * match grade options
319 * if no match return error or match nearest
320 * @param array $gradeoptionsfull list of valid options
321 * @param int $grade grade to be tested
322 * @param string $matchgrades 'error' or 'nearest'
323 * @return mixed either 'fixed' value or false if erro
325 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
326 // if we just need an error...
327 if ($matchgrades=='error') {
328 foreach($gradeoptionsfull as $value => $option) {
329 // slightly fuzzy test, never check floats for equality :-)
330 if (abs($grade-$value)<0.00001) {
331 return $grade;
334 // didn't find a match so that's an error
335 return false;
337 // work out nearest value
338 else if ($matchgrades=='nearest') {
339 $hownear = array();
340 foreach($gradeoptionsfull as $value => $option) {
341 if ($grade==$value) {
342 return $grade;
344 $hownear[ $value ] = abs( $grade - $value );
346 // reverse sort list of deltas and grab the last (smallest)
347 asort( $hownear, SORT_NUMERIC );
348 reset( $hownear );
349 return key( $hownear );
351 else {
352 return false;
357 * Tests whether a category is in use by any activity module
359 * @return boolean
360 * @param integer $categoryid
361 * @param boolean $recursive Whether to examine category children recursively
363 function question_category_isused($categoryid, $recursive = false) {
365 //Look at each question in the category
366 if ($questions = get_records('question', 'category', $categoryid)) {
367 foreach ($questions as $question) {
368 if (count(question_list_instances($question->id))) {
369 return true;
374 //Look under child categories recursively
375 if ($recursive) {
376 if ($children = get_records('question_categories', 'parent', $categoryid)) {
377 foreach ($children as $child) {
378 if (question_category_isused($child->id, $recursive)) {
379 return true;
385 return false;
389 * Deletes all data associated to an attempt from the database
391 * @param integer $attemptid The id of the attempt being deleted
393 function delete_attempt($attemptid) {
394 global $QTYPES;
396 $states = get_records('question_states', 'attempt', $attemptid);
397 if ($states) {
398 $stateslist = implode(',', array_keys($states));
400 // delete question-type specific data
401 foreach ($QTYPES as $qtype) {
402 $qtype->delete_states($stateslist);
406 // delete entries from all other question tables
407 // It is important that this is done only after calling the questiontype functions
408 delete_records("question_states", "attempt", $attemptid);
409 delete_records("question_sessions", "attemptid", $attemptid);
410 delete_records("question_attempts", "id", $attemptid);
414 * Deletes question and all associated data from the database
416 * It will not delete a question if it is used by an activity module
417 * @param object $question The question being deleted
419 function delete_question($questionid) {
420 global $QTYPES;
422 // Do not delete a question if it is used by an activity module
423 if (count(question_list_instances($questionid))) {
424 return;
427 // delete questiontype-specific data
428 $question = get_record('question', 'id', $questionid);
429 question_require_capability_on($question, 'edit');
430 if ($question) {
431 if (isset($QTYPES[$question->qtype])) {
432 $QTYPES[$question->qtype]->delete_question($questionid);
434 } else {
435 echo "Question with id $questionid does not exist.<br />";
438 if ($states = get_records('question_states', 'question', $questionid)) {
439 $stateslist = implode(',', array_keys($states));
441 // delete questiontype-specific data
442 foreach ($QTYPES as $qtype) {
443 $qtype->delete_states($stateslist);
447 // delete entries from all other question tables
448 // It is important that this is done only after calling the questiontype functions
449 delete_records("question_answers", "question", $questionid);
450 delete_records("question_states", "question", $questionid);
451 delete_records("question_sessions", "questionid", $questionid);
453 // Now recursively delete all child questions
454 if ($children = get_records('question', 'parent', $questionid)) {
455 foreach ($children as $child) {
456 if ($child->id != $questionid) {
457 delete_question($child->id);
462 // Finally delete the question record itself
463 delete_records('question', 'id', $questionid);
465 return;
469 * All question categories and their questions are deleted for this course.
471 * @param object $mod an object representing the activity
472 * @param boolean $feedback to specify if the process must output a summary of its work
473 * @return boolean
475 function question_delete_course($course, $feedback=true) {
476 //To store feedback to be showed at the end of the process
477 $feedbackdata = array();
479 //Cache some strings
480 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
481 $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
482 $categoriescourse = get_records('question_categories', 'contextid', $coursecontext->id, 'parent', 'id, parent, name');
484 if ($categoriescourse) {
486 //Sort categories following their tree (parent-child) relationships
487 //this will make the feedback more readable
488 $categoriescourse = sort_categories_by_tree($categoriescourse);
490 foreach ($categoriescourse as $category) {
492 //Delete it completely (questions and category itself)
493 //deleting questions
494 if ($questions = get_records("question", "category", $category->id)) {
495 foreach ($questions as $question) {
496 delete_question($question->id);
498 delete_records("question", "category", $category->id);
500 //delete the category
501 delete_records('question_categories', 'id', $category->id);
503 //Fill feedback
504 $feedbackdata[] = array($category->name, $strcatdeleted);
506 //Inform about changes performed if feedback is enabled
507 if ($feedback) {
508 $table = new stdClass;
509 $table->head = array(get_string('category','quiz'), get_string('action'));
510 $table->data = $feedbackdata;
511 print_table($table);
514 return true;
518 * All question categories and their questions are deleted for this activity.
520 * @param object $cm the course module object representing the activity
521 * @param boolean $feedback to specify if the process must output a summary of its work
522 * @return boolean
524 function question_delete_activity($cm, $feedback=true) {
525 //To store feedback to be showed at the end of the process
526 $feedbackdata = array();
528 //Cache some strings
529 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
530 $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
531 if ($categoriesmods = get_records('question_categories', 'contextid', $modcontext->id, 'parent', 'id, parent, name')){
532 //Sort categories following their tree (parent-child) relationships
533 //this will make the feedback more readable
534 $categoriesmods = sort_categories_by_tree($categoriesmods);
536 foreach ($categoriesmods as $category) {
538 //Delete it completely (questions and category itself)
539 //deleting questions
540 if ($questions = get_records("question", "category", $category->id)) {
541 foreach ($questions as $question) {
542 delete_question($question->id);
544 delete_records("question", "category", $category->id);
546 //delete the category
547 delete_records('question_categories', 'id', $category->id);
549 //Fill feedback
550 $feedbackdata[] = array($category->name, $strcatdeleted);
552 //Inform about changes performed if feedback is enabled
553 if ($feedback) {
554 $table = new stdClass;
555 $table->head = array(get_string('category','quiz'), get_string('action'));
556 $table->data = $feedbackdata;
557 print_table($table);
560 return true;
563 * @param array $row tab objects
564 * @param question_edit_contexts $contexts object representing contexts available from this context
565 * @param string $querystring to append to urls
566 * */
567 function questionbank_navigation_tabs(&$row, $contexts, $querystring) {
568 global $CFG, $QUESTION_EDITTABCAPS;
569 $tabs = array(
570 'questions' =>array("$CFG->wwwroot/question/edit.php?$querystring", get_string('questions', 'quiz'), get_string('editquestions', 'quiz')),
571 'categories' =>array("$CFG->wwwroot/question/category.php?$querystring", get_string('categories', 'quiz'), get_string('editqcats', 'quiz')),
572 'import' =>array("$CFG->wwwroot/question/import.php?$querystring", get_string('import', 'quiz'), get_string('importquestions', 'quiz')),
573 'export' =>array("$CFG->wwwroot/question/export.php?$querystring", get_string('export', 'quiz'), get_string('exportquestions', 'quiz')));
574 foreach ($tabs as $tabname => $tabparams){
575 if ($contexts->have_one_edit_tab_cap($tabname)) {
576 $row[] = new tabobject($tabname, $tabparams[0], $tabparams[1], $tabparams[2]);
582 * Private function to factor common code out of get_question_options().
584 * @param object $question the question to tidy.
585 * @return boolean true if successful, else false.
587 function _tidy_question(&$question) {
588 global $QTYPES;
589 if (!array_key_exists($question->qtype, $QTYPES)) {
590 $question->qtype = 'missingtype';
591 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
593 $question->name_prefix = question_make_name_prefix($question->id);
594 return $QTYPES[$question->qtype]->get_question_options($question);
598 * Updates the question objects with question type specific
599 * information by calling {@link get_question_options()}
601 * Can be called either with an array of question objects or with a single
602 * question object.
604 * @param mixed $questions Either an array of question objects to be updated
605 * or just a single question object
606 * @return bool Indicates success or failure.
608 function get_question_options(&$questions) {
609 if (is_array($questions)) { // deal with an array of questions
610 foreach ($questions as $i => $notused) {
611 if (!_tidy_question($questions[$i])) {
612 return false;
615 return true;
616 } else { // deal with single question
617 return _tidy_question($questions);
622 * Loads the most recent state of each question session from the database
623 * or create new one.
625 * For each question the most recent session state for the current attempt
626 * is loaded from the question_states table and the question type specific data and
627 * responses are added by calling {@link restore_question_state()} which in turn
628 * calls {@link restore_session_and_responses()} for each question.
629 * If no states exist for the question instance an empty state object is
630 * created representing the start of a session and empty question
631 * type specific information and responses are created by calling
632 * {@link create_session_and_responses()}.
634 * @return array An array of state objects representing the most recent
635 * states of the question sessions.
636 * @param array $questions The questions for which sessions are to be restored or
637 * created.
638 * @param object $cmoptions
639 * @param object $attempt The attempt for which the question sessions are
640 * to be restored or created.
641 * @param mixed either the id of a previous attempt, if this attmpt is
642 * building on a previous one, or false for a clean attempt.
644 function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
645 global $CFG, $QTYPES;
647 // get the question ids
648 $ids = array_keys($questions);
649 $questionlist = implode(',', $ids);
651 // The question field must be listed first so that it is used as the
652 // array index in the array returned by get_records_sql
653 $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment';
654 // Load the newest states for the questions
655 $sql = "SELECT $statefields".
656 " FROM {$CFG->prefix}question_states s,".
657 " {$CFG->prefix}question_sessions n".
658 " WHERE s.id = n.newest".
659 " AND n.attemptid = '$attempt->uniqueid'".
660 " AND n.questionid IN ($questionlist)";
661 $states = get_records_sql($sql);
663 // Load the newest graded states for the questions
664 $sql = "SELECT $statefields".
665 " FROM {$CFG->prefix}question_states s,".
666 " {$CFG->prefix}question_sessions n".
667 " WHERE s.id = n.newgraded".
668 " AND n.attemptid = '$attempt->uniqueid'".
669 " AND n.questionid IN ($questionlist)";
670 $gradedstates = get_records_sql($sql);
672 // loop through all questions and set the last_graded states
673 foreach ($ids as $i) {
674 if (isset($states[$i])) {
675 restore_question_state($questions[$i], $states[$i]);
676 if (isset($gradedstates[$i])) {
677 restore_question_state($questions[$i], $gradedstates[$i]);
678 $states[$i]->last_graded = $gradedstates[$i];
679 } else {
680 $states[$i]->last_graded = clone($states[$i]);
682 } else {
683 // If the new attempt is to be based on a previous attempt get it and clean things
684 // Having lastattemptid filled implies that (should we double check?):
685 // $attempt->attempt > 1 and $cmoptions->attemptonlast and !$attempt->preview
686 if ($lastattemptid) {
687 // find the responses from the previous attempt and save them to the new session
689 // Load the last graded state for the question
690 $statefields = 'n.questionid as question, s.*, n.sumpenalty';
691 $sql = "SELECT $statefields".
692 " FROM {$CFG->prefix}question_states s,".
693 " {$CFG->prefix}question_sessions n".
694 " WHERE s.id = n.newgraded".
695 " AND n.attemptid = '$lastattemptid'".
696 " AND n.questionid = '$i'";
697 if (!$laststate = get_record_sql($sql)) {
698 // Only restore previous responses that have been graded
699 continue;
701 // Restore the state so that the responses will be restored
702 restore_question_state($questions[$i], $laststate);
703 $states[$i] = clone($laststate);
704 unset($states[$i]->id);
705 } else {
706 // create a new empty state
707 $states[$i] = new object;
708 $states[$i]->question = $i;
709 $states[$i]->responses = array('' => '');
710 $states[$i]->raw_grade = 0;
713 // now fill/overide initial values
714 $states[$i]->attempt = $attempt->uniqueid;
715 $states[$i]->seq_number = 0;
716 $states[$i]->timestamp = $attempt->timestart;
717 $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
718 $states[$i]->grade = 0;
719 $states[$i]->penalty = 0;
720 $states[$i]->sumpenalty = 0;
721 $states[$i]->manualcomment = '';
723 // Prevent further changes to the session from incrementing the
724 // sequence number
725 $states[$i]->changed = true;
727 if ($lastattemptid) {
728 // prepare the previous responses for new processing
729 $action = new stdClass;
730 $action->responses = $laststate->responses;
731 $action->timestamp = $laststate->timestamp;
732 $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
734 // Process these responses ...
735 question_process_responses($questions[$i], $states[$i], $action, $cmoptions, $attempt);
737 // Fix for Bug #5506: When each attempt is built on the last one,
738 // preserve the options from any previous attempt.
739 if ( isset($laststate->options) ) {
740 $states[$i]->options = $laststate->options;
742 } else {
743 // Create the empty question type specific information
744 if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses(
745 $questions[$i], $states[$i], $cmoptions, $attempt)) {
746 return false;
749 $states[$i]->last_graded = clone($states[$i]);
752 return $states;
757 * Creates the run-time fields for the states
759 * Extends the state objects for a question by calling
760 * {@link restore_session_and_responses()}
761 * @param object $question The question for which the state is needed
762 * @param object $state The state as loaded from the database
763 * @return boolean Represents success or failure
765 function restore_question_state(&$question, &$state) {
766 global $QTYPES;
768 // initialise response to the value in the answer field
769 $state->responses = array('' => addslashes($state->answer));
770 unset($state->answer);
771 $state->manualcomment = isset($state->manualcomment) ? addslashes($state->manualcomment) : '';
773 // Set the changed field to false; any code which changes the
774 // question session must set this to true and must increment
775 // ->seq_number. The save_question_session
776 // function will save the new state object to the database if the field is
777 // set to true.
778 $state->changed = false;
780 // Load the question type specific data
781 return $QTYPES[$question->qtype]
782 ->restore_session_and_responses($question, $state);
787 * Saves the current state of the question session to the database
789 * The state object representing the current state of the session for the
790 * question is saved to the question_states table with ->responses[''] saved
791 * to the answer field of the database table. The information in the
792 * question_sessions table is updated.
793 * The question type specific data is then saved.
794 * @return mixed The id of the saved or updated state or false
795 * @param object $question The question for which session is to be saved.
796 * @param object $state The state information to be saved. In particular the
797 * most recent responses are in ->responses. The object
798 * is updated to hold the new ->id.
800 function save_question_session(&$question, &$state) {
801 global $QTYPES;
802 // Check if the state has changed
803 if (!$state->changed && isset($state->id)) {
804 return $state->id;
806 // Set the legacy answer field
807 $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
809 // Save the state
810 if (!empty($state->update)) { // this forces the old state record to be overwritten
811 update_record('question_states', $state);
812 } else {
813 if (!$state->id = insert_record('question_states', $state)) {
814 unset($state->id);
815 unset($state->answer);
816 return false;
820 // create or update the session
821 if (!$session = get_record('question_sessions', 'attemptid',
822 $state->attempt, 'questionid', $question->id)) {
823 $session->attemptid = $state->attempt;
824 $session->questionid = $question->id;
825 $session->newest = $state->id;
826 // The following may seem weird, but the newgraded field needs to be set
827 // already even if there is no graded state yet.
828 $session->newgraded = $state->id;
829 $session->sumpenalty = $state->sumpenalty;
830 $session->manualcomment = $state->manualcomment;
831 if (!insert_record('question_sessions', $session)) {
832 error('Could not insert entry in question_sessions');
834 } else {
835 $session->newest = $state->id;
836 if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
837 // this state is graded or newly opened, so it goes into the lastgraded field as well
838 $session->newgraded = $state->id;
839 $session->sumpenalty = $state->sumpenalty;
840 $session->manualcomment = $state->manualcomment;
841 } else {
842 $session->manualcomment = addslashes($session->manualcomment);
844 update_record('question_sessions', $session);
847 unset($state->answer);
849 // Save the question type specific state information and responses
850 if (!$QTYPES[$question->qtype]->save_session_and_responses(
851 $question, $state)) {
852 return false;
854 // Reset the changed flag
855 $state->changed = false;
856 return $state->id;
860 * Determines whether a state has been graded by looking at the event field
862 * @return boolean true if the state has been graded
863 * @param object $state
865 function question_state_is_graded($state) {
866 return ($state->event == QUESTION_EVENTGRADE
867 or $state->event == QUESTION_EVENTCLOSEANDGRADE
868 or $state->event == QUESTION_EVENTMANUALGRADE);
872 * Determines whether a state has been closed by looking at the event field
874 * @return boolean true if the state has been closed
875 * @param object $state
877 function question_state_is_closed($state) {
878 return ($state->event == QUESTION_EVENTCLOSE
879 or $state->event == QUESTION_EVENTCLOSEANDGRADE
880 or $state->event == QUESTION_EVENTMANUALGRADE);
885 * Extracts responses from submitted form
887 * This can extract the responses given to one or several questions present on a page
888 * It returns an array with one entry for each question, indexed by question id
889 * Each entry is an object with the properties
890 * ->event The event that has triggered the submission. This is determined by which button
891 * the user has pressed.
892 * ->responses An array holding the responses to an individual question, indexed by the
893 * name of the corresponding form element.
894 * ->timestamp A unix timestamp
895 * @return array array of action objects, indexed by question ids.
896 * @param array $questions an array containing at least all questions that are used on the form
897 * @param array $formdata the data submitted by the form on the question page
898 * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
900 function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
902 $time = time();
903 $actions = array();
904 foreach ($formdata as $key => $response) {
905 // Get the question id from the response name
906 if (false !== ($quid = question_get_id_from_name_prefix($key))) {
907 // check if this is a valid id
908 if (!isset($questions[$quid])) {
909 error('Form contained question that is not in questionids');
912 // Remove the name prefix from the name
913 //decrypt trying
914 $key = substr($key, strlen($questions[$quid]->name_prefix));
915 if (false === $key) {
916 $key = '';
918 // Check for question validate and mark buttons & set events
919 if ($key === 'validate') {
920 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
921 } else if ($key === 'submit') {
922 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
923 } else {
924 $actions[$quid]->event = $defaultevent;
927 // Update the state with the new response
928 $actions[$quid]->responses[$key] = $response;
930 // Set the timestamp
931 $actions[$quid]->timestamp = $time;
934 foreach ($actions as $quid => $notused) {
935 ksort($actions[$quid]->responses);
937 return $actions;
942 * Returns the html for question feedback image.
943 * @param float $fraction value representing the correctness of the user's
944 * response to a question.
945 * @param boolean $selected whether or not the answer is the one that the
946 * user picked.
947 * @return string
949 function question_get_feedback_image($fraction, $selected=true) {
951 global $CFG;
953 if ($fraction >= 1.0) {
954 if ($selected) {
955 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_big.gif" '.
956 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
957 } else {
958 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_small.gif" '.
959 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
961 } else if ($fraction > 0.0 && $fraction < 1.0) {
962 if ($selected) {
963 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_big.gif" '.
964 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
965 } else {
966 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_small.gif" '.
967 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
969 } else {
970 if ($selected) {
971 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_big.gif" '.
972 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
973 } else {
974 $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_small.gif" '.
975 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
978 return $feedbackimg;
983 * Returns the class name for question feedback.
984 * @param float $fraction value representing the correctness of the user's
985 * response to a question.
986 * @return string
988 function question_get_feedback_class($fraction) {
990 global $CFG;
992 if ($fraction >= 1.0) {
993 $class = 'correct';
994 } else if ($fraction > 0.0 && $fraction < 1.0) {
995 $class = 'partiallycorrect';
996 } else {
997 $class = 'incorrect';
999 return $class;
1004 * For a given question in an attempt we walk the complete history of states
1005 * and recalculate the grades as we go along.
1007 * This is used when a question is changed and old student
1008 * responses need to be marked with the new version of a question.
1010 * TODO: Make sure this is not quiz-specific
1012 * @return boolean Indicates whether the grade has changed
1013 * @param object $question A question object
1014 * @param object $attempt The attempt, in which the question needs to be regraded.
1015 * @param object $cmoptions
1016 * @param boolean $verbose Optional. Whether to print progress information or not.
1018 function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false) {
1020 // load all states for this question in this attempt, ordered in sequence
1021 if ($states = get_records_select('question_states',
1022 "attempt = '{$attempt->uniqueid}' AND question = '{$question->id}'",
1023 'seq_number ASC')) {
1024 $states = array_values($states);
1026 // Subtract the grade for the latest state from $attempt->sumgrades to get the
1027 // sumgrades for the attempt without this question.
1028 $attempt->sumgrades -= $states[count($states)-1]->grade;
1030 // Initialise the replaystate
1031 $state = clone($states[0]);
1032 $state->manualcomment = get_field('question_sessions', 'manualcomment', 'attemptid',
1033 $attempt->uniqueid, 'questionid', $question->id);
1034 restore_question_state($question, $state);
1035 $state->sumpenalty = 0.0;
1036 $replaystate = clone($state);
1037 $replaystate->last_graded = $state;
1039 $changed = false;
1040 for($j = 1; $j < count($states); $j++) {
1041 restore_question_state($question, $states[$j]);
1042 $action = new stdClass;
1043 $action->responses = $states[$j]->responses;
1044 $action->timestamp = $states[$j]->timestamp;
1046 // Change event to submit so that it will be reprocessed
1047 if (QUESTION_EVENTCLOSE == $states[$j]->event
1048 or QUESTION_EVENTGRADE == $states[$j]->event
1049 or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) {
1050 $action->event = QUESTION_EVENTSUBMIT;
1052 // By default take the event that was saved in the database
1053 } else {
1054 $action->event = $states[$j]->event;
1057 if ($action->event == QUESTION_EVENTMANUALGRADE) {
1058 question_process_comment($question, $replaystate, $attempt,
1059 $replaystate->manualcomment, $states[$j]->grade);
1060 } else {
1062 // Reprocess (regrade) responses
1063 if (!question_process_responses($question, $replaystate,
1064 $action, $cmoptions, $attempt)) {
1065 $verbose && notify("Couldn't regrade state #{$state->id}!");
1069 // We need rounding here because grades in the DB get truncated
1070 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
1071 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
1072 or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1073 or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
1074 $changed = true;
1077 $replaystate->id = $states[$j]->id;
1078 $replaystate->changed = true;
1079 $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
1080 save_question_session($question, $replaystate);
1082 if ($changed) {
1083 // TODO, call a method in quiz to do this, where 'quiz' comes from
1084 // the question_attempts table.
1085 update_record('quiz_attempts', $attempt);
1088 return $changed;
1090 return false;
1094 * Processes an array of student responses, grading and saving them as appropriate
1096 * @return boolean Indicates success/failure
1097 * @param object $question Full question object, passed by reference
1098 * @param object $state Full state object, passed by reference
1099 * @param object $action object with the fields ->responses which
1100 * is an array holding the student responses,
1101 * ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
1102 * and ->timestamp which is a timestamp from when the responses
1103 * were submitted by the student.
1104 * @param object $cmoptions
1105 * @param object $attempt The attempt is passed by reference so that
1106 * during grading its ->sumgrades field can be updated
1108 function question_process_responses(&$question, &$state, $action, $cmoptions, &$attempt) {
1109 global $QTYPES;
1111 // if no responses are set initialise to empty response
1112 if (!isset($action->responses)) {
1113 $action->responses = array('' => '');
1116 // make sure these are gone!
1117 unset($action->responses['submit'], $action->responses['validate']);
1119 // Check the question session is still open
1120 if (question_state_is_closed($state)) {
1121 return true;
1124 // If $action->event is not set that implies saving
1125 if (! isset($action->event)) {
1126 debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
1127 $action->event = QUESTION_EVENTSAVE;
1129 // If submitted then compare against last graded
1130 // responses, not last given responses in this case
1131 if (question_isgradingevent($action->event)) {
1132 $state->responses = $state->last_graded->responses;
1135 // Check for unchanged responses (exactly unchanged, not equivalent).
1136 // We also have to catch questions that the student has not yet attempted
1137 $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
1138 if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
1139 question_isgradingevent($action->event)) {
1140 $sameresponses = false;
1143 // If the response has not been changed then we do not have to process it again
1144 // unless the attempt is closing or validation is requested
1145 if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
1146 and QUESTION_EVENTVALIDATE != $action->event) {
1147 return true;
1150 // Roll back grading information to last graded state and set the new
1151 // responses
1152 $newstate = clone($state->last_graded);
1153 $newstate->responses = $action->responses;
1154 $newstate->seq_number = $state->seq_number + 1;
1155 $newstate->changed = true; // will assure that it gets saved to the database
1156 $newstate->last_graded = clone($state->last_graded);
1157 $newstate->timestamp = $action->timestamp;
1158 $state = $newstate;
1160 // Set the event to the action we will perform. The question type specific
1161 // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
1162 // attempt at the question causes the session to close
1163 $state->event = $action->event;
1165 if (!question_isgradingevent($action->event)) {
1166 // Grade the response but don't update the overall grade
1167 $QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions);
1169 // Temporary hack because question types are not given enough control over what is going
1170 // on. Used by Opaque questions.
1171 // TODO fix this code properly.
1172 if (!empty($state->believeevent)) {
1173 // If the state was graded we need to ...
1174 if (question_state_is_graded($state)) {
1175 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1177 // update the attempt grade
1178 $attempt->sumgrades -= (float)$state->last_graded->grade;
1179 $attempt->sumgrades += (float)$state->grade;
1181 // and update the last_graded field.
1182 unset($state->last_graded);
1183 $state->last_graded = clone($state);
1184 unset($state->last_graded->changed);
1186 } else {
1187 // Don't allow the processing to change the event type
1188 $state->event = $action->event;
1191 } else { // grading event
1193 // Unless the attempt is closing, we want to work out if the current responses
1194 // (or equivalent responses) were already given in the last graded attempt.
1195 if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1196 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
1197 $state->event = QUESTION_EVENTDUPLICATE;
1200 // If we did not find a duplicate or if the attempt is closing, perform grading
1201 if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1202 QUESTION_EVENTCLOSE == $action->event) {
1204 $QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions);
1205 // Calculate overall grade using correct penalty method
1206 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1209 // If the state was graded we need to ...
1210 if (question_state_is_graded($state)) {
1211 // update the attempt grade
1212 $attempt->sumgrades -= (float)$state->last_graded->grade;
1213 $attempt->sumgrades += (float)$state->grade;
1215 // and update the last_graded field.
1216 unset($state->last_graded);
1217 $state->last_graded = clone($state);
1218 unset($state->last_graded->changed);
1221 $attempt->timemodified = $action->timestamp;
1223 return true;
1227 * Determine if event requires grading
1229 function question_isgradingevent($event) {
1230 return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
1234 * Applies the penalty from the previous graded responses to the raw grade
1235 * for the current responses
1237 * The grade for the question in the current state is computed by subtracting the
1238 * penalty accumulated over the previous graded responses at the question from the
1239 * raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1240 * the grade is set to zero. The ->grade field of the state object is modified to
1241 * reflect the new grade but is never allowed to decrease.
1242 * @param object $question The question for which the penalty is to be applied.
1243 * @param object $state The state for which the grade is to be set from the
1244 * raw grade and the cumulative penalty from the last
1245 * graded state. The ->grade field is updated by applying
1246 * the penalty scheme determined in $cmoptions to the ->raw_grade and
1247 * ->last_graded->penalty fields.
1248 * @param object $cmoptions The options set by the course module.
1249 * The ->penaltyscheme field determines whether penalties
1250 * for incorrect earlier responses are subtracted.
1252 function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
1253 // TODO. Quiz dependancy. The fact that the attempt that is passed in here
1254 // is from quiz_attempts, and we use things like $cmoptions->timelimit.
1256 // deal with penalty
1257 if ($cmoptions->penaltyscheme) {
1258 $state->grade = $state->raw_grade - $state->sumpenalty;
1259 $state->sumpenalty += (float) $state->penalty;
1260 } else {
1261 $state->grade = $state->raw_grade;
1264 // deal with timelimit
1265 if ($cmoptions->timelimit) {
1266 // We allow for 5% uncertainty in the following test
1267 if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 63) {
1268 $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
1269 if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
1270 $attempt->userid, false)) {
1271 $state->grade = 0;
1276 // deal with closing time
1277 if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1278 and !$attempt->preview) { // ignore closing time for previews
1279 $state->grade = 0;
1282 // Ensure that the grade does not go down
1283 $state->grade = max($state->grade, $state->last_graded->grade);
1287 * Print the icon for the question type
1289 * @param object $question The question object for which the icon is required
1290 * @param boolean $return If true the functions returns the link as a string
1292 function print_question_icon($question, $return = false) {
1293 global $QTYPES, $CFG;
1295 $namestr = $QTYPES[$question->qtype]->menu_name();
1296 $html = '<img src="' . $CFG->wwwroot . '/question/type/' .
1297 $question->qtype . '/icon.gif" alt="' .
1298 $namestr . '" title="' . $namestr . '" />';
1299 if ($return) {
1300 return $html;
1301 } else {
1302 echo $html;
1307 * Returns a html link to the question image if there is one
1309 * @return string The html image tag or the empy string if there is no image.
1310 * @param object $question The question object
1312 function get_question_image($question) {
1314 global $CFG;
1315 $img = '';
1317 if (!$category = get_record('question_categories', 'id', $question->category)){
1318 error('invalid category id '.$question->category);
1320 $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid));
1322 if ($question->image) {
1324 if (substr(strtolower($question->image), 0, 7) == 'http://') {
1325 $img .= $question->image;
1327 } else if ($CFG->slasharguments) { // Use this method if possible for better caching
1328 $img .= "$CFG->wwwroot/file.php/$coursefilesdir/$question->image";
1330 } else {
1331 $img .= "$CFG->wwwroot/file.php?file=/$coursefilesdir/$question->image";
1334 return $img;
1337 function question_print_comment_box($question, $state, $attempt, $url) {
1338 global $CFG;
1340 $prefix = 'response';
1341 $usehtmleditor = can_use_richtext_editor();
1342 $grade = round($state->last_graded->grade, 3);
1343 echo '<form method="post" action="'.$url.'">';
1344 include($CFG->dirroot.'/question/comment.html');
1345 echo '<input type="hidden" name="attempt" value="'.$attempt->uniqueid.'" />';
1346 echo '<input type="hidden" name="question" value="'.$question->id.'" />';
1347 echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
1348 echo '<input type="submit" name="submit" value="'.get_string('save', 'quiz').'" />';
1349 echo '</form>';
1351 if ($usehtmleditor) {
1352 use_html_editor();
1356 function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1358 // Update the comment and save it in the database
1359 $comment = trim($comment);
1360 $state->manualcomment = $comment;
1361 if (!set_field('question_sessions', 'manualcomment', $comment, 'attemptid', $attempt->uniqueid, 'questionid', $question->id)) {
1362 error("Cannot save comment");
1365 // Update the attempt if the score has changed.
1366 if (abs($state->last_graded->grade - $grade) > 0.002) {
1367 $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1368 $attempt->timemodified = time();
1369 if (!update_record('quiz_attempts', $attempt)) {
1370 error('Failed to save the current quiz attempt!');
1374 // Update the state if either the score has changed, or this is the first
1375 // manual grade event and there is actually a grade of comment to process.
1376 // We don't need to store the modified state in the database, we just need
1377 // to set the $state->changed flag.
1378 if (abs($state->last_graded->grade - $grade) > 0.002 ||
1379 ($state->last_graded->event != QUESTION_EVENTMANUALGRADE && ($grade > 0.002 || $comment != ''))) {
1381 // We want to update existing state (rather than creating new one) if it
1382 // was itself created by a manual grading event.
1383 $state->update = ($state->event == QUESTION_EVENTMANUALGRADE) ? 1 : 0;
1385 // Update the other parts of the state object.
1386 $state->raw_grade = $grade;
1387 $state->grade = $grade;
1388 $state->penalty = 0;
1389 $state->timestamp = time();
1390 $state->seq_number++;
1391 $state->event = QUESTION_EVENTMANUALGRADE;
1393 // Update the last graded state (don't simplify!)
1394 unset($state->last_graded);
1395 $state->last_graded = clone($state);
1397 // We need to indicate that the state has changed in order for it to be saved.
1398 $state->changed = 1;
1404 * Construct name prefixes for question form element names
1406 * Construct the name prefix that should be used for example in the
1407 * names of form elements created by questions.
1408 * This is called by {@link get_question_options()}
1409 * to set $question->name_prefix.
1410 * This name prefix includes the question id which can be
1411 * extracted from it with {@link question_get_id_from_name_prefix()}.
1413 * @return string
1414 * @param integer $id The question id
1416 function question_make_name_prefix($id) {
1417 return 'resp' . $id . '_';
1421 * Extract question id from the prefix of form element names
1423 * @return integer The question id
1424 * @param string $name The name that contains a prefix that was
1425 * constructed with {@link question_make_name_prefix()}
1427 function question_get_id_from_name_prefix($name) {
1428 if (!preg_match('/^resp([0-9]+)_/', $name, $matches))
1429 return false;
1430 return (integer) $matches[1];
1434 * Returns the unique id for a new attempt
1436 * Every module can keep their own attempts table with their own sequential ids but
1437 * the question code needs to also have a unique id by which to identify all these
1438 * attempts. Hence a module, when creating a new attempt, calls this function and
1439 * stores the return value in the 'uniqueid' field of its attempts table.
1441 function question_new_attempt_uniqueid($modulename='quiz') {
1442 global $CFG;
1443 $attempt = new stdClass;
1444 $attempt->modulename = $modulename;
1445 if (!$id = insert_record('question_attempts', $attempt)) {
1446 error('Could not create new entry in question_attempts table');
1448 return $id;
1452 * Creates a stamp that uniquely identifies this version of the question
1454 * In future we want this to use a hash of the question data to guarantee that
1455 * identical versions have the same version stamp.
1457 * @param object $question
1458 * @return string A unique version stamp
1460 function question_hash($question) {
1461 return make_unique_id_code();
1465 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
1467 * Get the HTML that needs to be included in the head tag when the
1468 * questions in $questionlist are printed in the gives states.
1470 * @param array $questionlist a list of questionids of the questions what will appear on this page.
1471 * @param array $questions an array of question objects, whose keys are question ids.
1472 * Must contain all the questions in $questionlist
1473 * @param array $states an array of question state objects, whose keys are question ids.
1474 * Must contain the state of all the questions in $questionlist
1476 * @return string some HTML code that can go inside the head tag.
1478 function get_html_head_contributions(&$questionlist, &$questions, &$states) {
1479 global $QTYPES;
1481 $contributions = array();
1482 foreach ($questionlist as $questionid) {
1483 $question = $questions[$questionid];
1484 $contributions = array_merge($contributions,
1485 $QTYPES[$question->qtype]->get_html_head_contributions(
1486 $question, $states[$questionid]));
1488 return implode("\n", array_unique($contributions));
1492 * Prints a question
1494 * Simply calls the question type specific print_question() method.
1495 * @param object $question The question to be rendered.
1496 * @param object $state The state to render the question in.
1497 * @param integer $number The number for this question.
1498 * @param object $cmoptions The options specified by the course module
1499 * @param object $options An object specifying the rendering options.
1501 function print_question(&$question, &$state, $number, $cmoptions, $options=null) {
1502 global $QTYPES;
1504 $QTYPES[$question->qtype]->print_question($question, $state, $number,
1505 $cmoptions, $options);
1508 * Saves question options
1510 * Simply calls the question type specific save_question_options() method.
1512 function save_question_options($question) {
1513 global $QTYPES;
1515 $QTYPES[$question->qtype]->save_question_options($question);
1519 * Gets all teacher stored answers for a given question
1521 * Simply calls the question type specific get_all_responses() method.
1523 // ULPGC ecastro
1524 function get_question_responses($question, $state) {
1525 global $QTYPES;
1526 $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
1527 return $r;
1532 * Gets the response given by the user in a particular state
1534 * Simply calls the question type specific get_actual_response() method.
1536 // ULPGC ecastro
1537 function get_question_actual_response($question, $state) {
1538 global $QTYPES;
1540 $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
1541 return $r;
1545 * TODO: document this
1547 // ULPGc ecastro
1548 function get_question_fraction_grade($question, $state) {
1549 global $QTYPES;
1551 $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
1552 return $r;
1556 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
1559 * returns the categories with their names ordered following parent-child relationships
1560 * finally it tries to return pending categories (those being orphaned, whose parent is
1561 * incorrect) to avoid missing any category from original array.
1563 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
1564 $children = array();
1565 $keys = array_keys($categories);
1567 foreach ($keys as $key) {
1568 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
1569 $children[$key] = $categories[$key];
1570 $categories[$key]->processed = true;
1571 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1574 //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
1575 if ($level == 1) {
1576 foreach ($keys as $key) {
1577 //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
1578 if (!isset($categories[$key]->processed) && !record_exists('question_categories', 'course', $categories[$key]->course, 'id', $categories[$key]->parent)) {
1579 $children[$key] = $categories[$key];
1580 $categories[$key]->processed = true;
1581 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1585 return $children;
1589 * Private method, only for the use of add_indented_names().
1591 * Recursively adds an indentedname field to each category, starting with the category
1592 * with id $id, and dealing with that category and all its children, and
1593 * return a new array, with those categories in the right order.
1595 * @param array $categories an array of categories which has had childids
1596 * fields added by flatten_category_tree(). Passed by reference for
1597 * performance only. It is not modfied.
1598 * @param int $id the category to start the indenting process from.
1599 * @param int $depth the indent depth. Used in recursive calls.
1600 * @return array a new array of categories, in the right order for the tree.
1602 function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
1604 // Indent the name of this category.
1605 $newcategories = array();
1606 $newcategories[$id] = $categories[$id];
1607 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
1609 // Recursively indent the children.
1610 foreach ($categories[$id]->childids as $childid) {
1611 if ($childid != $nochildrenof){
1612 $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
1616 // Remove the childids array that were temporarily added.
1617 unset($newcategories[$id]->childids);
1619 return $newcategories;
1623 * Format categories into an indented list reflecting the tree structure.
1625 * @param array $categories An array of category objects, for example from the.
1626 * @return array The formatted list of categories.
1628 function add_indented_names($categories, $nochildrenof = -1) {
1630 // Add an array to each category to hold the child category ids. This array will be removed
1631 // again by flatten_category_tree(). It should not be used outside these two functions.
1632 foreach (array_keys($categories) as $id) {
1633 $categories[$id]->childids = array();
1636 // Build the tree structure, and record which categories are top-level.
1637 // We have to be careful, because the categories array may include published
1638 // categories from other courses, but not their parents.
1639 $toplevelcategoryids = array();
1640 foreach (array_keys($categories) as $id) {
1641 if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
1642 $categories[$categories[$id]->parent]->childids[] = $id;
1643 } else {
1644 $toplevelcategoryids[] = $id;
1648 // Flatten the tree to and add the indents.
1649 $newcategories = array();
1650 foreach ($toplevelcategoryids as $id) {
1651 $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
1654 return $newcategories;
1658 * Output a select menu of question categories.
1660 * Categories from this course and (optionally) published categories from other courses
1661 * are included. Optionally, only categories the current user may edit can be included.
1663 * @param integer $courseid the id of the course to get the categories for.
1664 * @param integer $published if true, include publised categories from other courses.
1665 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1666 * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
1668 function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
1669 $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
1670 if ($selected) {
1671 $nothing = '';
1672 } else {
1673 $nothing = 'choose';
1675 choose_from_menu_nested($categoriesarray, 'category', $selected, $nothing);
1679 * Gets the default category in the most specific context.
1680 * If no categories exist yet then default ones are created in all contexts.
1682 * @param array $contexts The context objects for this context and all parent contexts.
1683 * @return object The default category - the category in the course context
1685 function question_make_default_categories($contexts) {
1686 $toreturn = null;
1687 // If it already exists, just return it.
1688 foreach ($contexts as $key => $context) {
1689 if (!$categoryrs = get_recordset_select("question_categories", "contextid = '{$context->id}'", 'sortorder, name', '*', '', 1)) {
1690 error('error getting category record');
1691 } else {
1692 if (!$category = rs_fetch_record($categoryrs)){
1693 // Otherwise, we need to make one
1694 $category = new stdClass;
1695 $contextname = print_context_name($context, false, true);
1696 $category->name = addslashes(get_string('defaultfor', 'question', $contextname));
1697 $category->info = addslashes(get_string('defaultinfofor', 'question', $contextname));
1698 $category->contextid = $context->id;
1699 $category->parent = 0;
1700 $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
1701 $category->stamp = make_unique_id_code();
1702 if (!$category->id = insert_record('question_categories', $category)) {
1703 error('Error creating a default category for context '.print_context_name($context));
1707 if ($context->contextlevel == CONTEXT_COURSE){
1708 $toreturn = clone($category);
1713 return $toreturn;
1717 * Get all the category objects, including a count of the number of questions in that category,
1718 * for all the categories in the lists $contexts.
1720 * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
1721 * @param string $sortorder used as the ORDER BY clause in the select statement.
1722 * @return array of category objects.
1724 function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
1725 global $CFG;
1726 return get_records_sql("
1727 SELECT *, (SELECT count(1) FROM {$CFG->prefix}question q
1728 WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') as questioncount
1729 FROM {$CFG->prefix}question_categories c
1730 WHERE c.contextid IN ($contexts)
1731 ORDER BY $sortorder");
1735 * Output an array of question categories.
1737 function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
1738 global $CFG;
1739 $pcontexts = array();
1740 foreach($contexts as $context){
1741 $pcontexts[] = $context->id;
1743 $contextslist = join($pcontexts, ', ');
1745 $categories = get_categories_for_contexts($contextslist);
1747 $categories = question_add_context_in_key($categories);
1749 if ($top){
1750 $categories = question_add_tops($categories, $pcontexts);
1752 $categories = add_indented_names($categories, $nochildrenof);
1754 //sort cats out into different contexts
1755 $categoriesarray = array();
1756 foreach ($pcontexts as $pcontext){
1757 $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
1758 foreach ($categories as $category) {
1759 if ($category->contextid == $pcontext){
1760 $cid = $category->id;
1761 if ($currentcat!= $cid || $currentcat==0) {
1762 $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
1763 $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
1768 if ($popupform){
1769 $popupcats = array();
1770 foreach ($categoriesarray as $contextstring => $optgroup){
1771 $popupcats[] = '--'.$contextstring;
1772 $popupcats = array_merge($popupcats, $optgroup);
1773 $popupcats[] = '--';
1775 return $popupcats;
1776 } else {
1777 return $categoriesarray;
1781 function question_add_context_in_key($categories){
1782 $newcatarray = array();
1783 foreach ($categories as $id => $category) {
1784 $category->parent = "$category->parent,$category->contextid";
1785 $category->id = "$category->id,$category->contextid";
1786 $newcatarray["$id,$category->contextid"] = $category;
1788 return $newcatarray;
1790 function question_add_tops($categories, $pcontexts){
1791 $topcats = array();
1792 foreach ($pcontexts as $context){
1793 $newcat = new object();
1794 $newcat->id = "0,$context";
1795 $newcat->name = get_string('top');
1796 $newcat->parent = -1;
1797 $newcat->contextid = $context;
1798 $topcats["0,$context"] = $newcat;
1800 //put topcats in at beginning of array - they'll be sorted into different contexts later.
1801 return array_merge($topcats, $categories);
1805 * Returns a comma separated list of ids of the category and all subcategories
1807 function question_categorylist($categoryid) {
1808 // returns a comma separated list of ids of the category and all subcategories
1809 $categorylist = $categoryid;
1810 if ($subcategories = get_records('question_categories', 'parent', $categoryid, 'sortorder ASC', 'id, id')) {
1811 foreach ($subcategories as $subcategory) {
1812 $categorylist .= ','. question_categorylist($subcategory->id);
1815 return $categorylist;
1821 //===========================
1822 // Import/Export Functions
1823 //===========================
1826 * Get list of available import or export formats
1827 * @param string $type 'import' if import list, otherwise export list assumed
1828 * @return array sorted list of import/export formats available
1830 function get_import_export_formats( $type ) {
1832 global $CFG;
1833 $fileformats = get_list_of_plugins("question/format");
1835 $fileformatname=array();
1836 require_once( "{$CFG->dirroot}/question/format.php" );
1837 foreach ($fileformats as $key => $fileformat) {
1838 $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php";
1839 if (file_exists( $format_file ) ) {
1840 require_once( $format_file );
1842 else {
1843 continue;
1845 $classname = "qformat_$fileformat";
1846 $format_class = new $classname();
1847 if ($type=='import') {
1848 $provided = $format_class->provide_import();
1850 else {
1851 $provided = $format_class->provide_export();
1853 if ($provided) {
1854 $formatname = get_string($fileformat, 'quiz');
1855 if ($formatname == "[[$fileformat]]") {
1856 $formatname = $fileformat; // Just use the raw folder name
1858 $fileformatnames[$fileformat] = $formatname;
1861 natcasesort($fileformatnames);
1863 return $fileformatnames;
1868 * Create default export filename
1870 * @return string default export filename
1871 * @param object $course
1872 * @param object $category
1874 function default_export_filename($course,$category) {
1875 //Take off some characters in the filename !!
1876 $takeoff = array(" ", ":", "/", "\\", "|");
1877 $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
1878 //If non-translated, use "export"
1879 if (substr($export_word,0,1) == "[") {
1880 $export_word= "export";
1883 //Calculate the date format string
1884 $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
1885 //If non-translated, use "%Y%m%d-%H%M"
1886 if (substr($export_date_format,0,1) == "[") {
1887 $export_date_format = "%%Y%%m%%d-%%H%%M";
1890 //Calculate the shortname
1891 $export_shortname = clean_filename($course->shortname);
1892 if (empty($export_shortname) or $export_shortname == '_' ) {
1893 $export_shortname = $course->id;
1896 //Calculate the category name
1897 $export_categoryname = clean_filename($category->name);
1899 //Calculate the final export filename
1900 //The export word
1901 $export_name = $export_word."-";
1902 //The shortname
1903 $export_name .= moodle_strtolower($export_shortname)."-";
1904 //The category name
1905 $export_name .= moodle_strtolower($export_categoryname)."-";
1906 //The date format
1907 $export_name .= userdate(time(),$export_date_format,99,false);
1908 //Extension is supplied by format later.
1910 return $export_name;
1912 class context_to_string_translator{
1914 * @var array used to translate between contextids and strings for this context.
1916 var $contexttostringarray = array();
1918 function context_to_string_translator($contexts){
1919 $this->generate_context_to_string_array($contexts);
1922 function context_to_string($contextid){
1923 return $this->contexttostringarray[$contextid];
1926 function string_to_context($contextname){
1927 $contextid = array_search($contextname, $this->contexttostringarray);
1928 return $contextid;
1931 function generate_context_to_string_array($contexts){
1932 if (!$this->contexttostringarray){
1933 $catno = 1;
1934 foreach ($contexts as $context){
1935 switch ($context->contextlevel){
1936 case CONTEXT_MODULE :
1937 $contextstring = 'module';
1938 break;
1939 case CONTEXT_COURSE :
1940 $contextstring = 'course';
1941 break;
1942 case CONTEXT_COURSECAT :
1943 $contextstring = "cat$catno";
1944 $catno++;
1945 break;
1946 case CONTEXT_SYSTEM :
1947 $contextstring = 'system';
1948 break;
1950 $this->contexttostringarray[$context->id] = $contextstring;
1959 * Check capability on category
1960 * @param mixed $question object or id
1961 * @param string $cap 'add', 'edit', 'view', 'use', 'move'
1962 * @param integer $cachecat useful to cache all question records in a category
1963 * @return boolean this user has the capability $cap for this question $question?
1965 function question_has_capability_on($question, $cap, $cachecat = -1){
1966 global $USER;
1967 // these are capabilities on existing questions capabilties are
1968 //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
1969 $question_questioncaps = array('edit', 'view', 'use', 'move');
1970 static $questions = array();
1971 static $categories = array();
1972 static $cachedcat = array();
1973 if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){
1974 $questions += get_records('question', 'category', $cachecat);
1975 $cachedcat[] = $cachecat;
1977 if (!is_object($question)){
1978 if (!isset($questions[$question])){
1979 if (!$questions[$question] = get_record('question', 'id', $question)){
1980 print_error('questiondoesnotexist', 'question');
1983 $question = $questions[$question];
1985 if (!isset($categories[$question->category])){
1986 if (!$categories[$question->category] = get_record('question_categories', 'id', $question->category)){
1987 print_error('invalidcategory', 'quiz');
1990 $category = $categories[$question->category];
1992 if (array_search($cap, $question_questioncaps)!== FALSE){
1993 if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){
1994 if ($question->createdby == $USER->id){
1995 return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid));
1996 } else {
1997 return false;
1999 } else {
2000 return true;
2002 } else {
2003 return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid));
2009 * Require capability on question.
2011 function question_require_capability_on($question, $cap){
2012 if (!question_has_capability_on($question, $cap)){
2013 print_error('nopermissions', '', '', $cap);
2015 return true;
2018 function question_file_links_base_url($courseid){
2019 global $CFG;
2020 $baseurl = preg_quote("$CFG->wwwroot/file.php", '!');
2021 $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not
2022 //be using slasharguments, accept either
2023 $baseurl .= "/$courseid/";//course directory
2024 return $baseurl;
2028 * Find all course / site files linked to in a piece of html.
2029 * @param string html the html to search
2030 * @param int course search for files for courseid course or set to siteid for
2031 * finding site files.
2032 * @return array files with keys being files.
2034 function question_find_file_links_from_html($html, $courseid){
2035 global $CFG;
2036 $baseurl = question_file_links_base_url($courseid);
2037 $searchfor = '!'.
2038 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'.
2039 '|'.
2040 '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''.
2041 '!i';
2042 $matches = array();
2043 $no = preg_match_all($searchfor, $html, $matches);
2044 if ($no){
2045 $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements
2046 //remove any links that point somewhere they shouldn't
2047 foreach (array_keys($rawurls) as $rawurlkey){
2048 if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){
2049 unset($rawurls[$rawurlkey]);
2050 } else {
2051 $rawurls[$rawurlkey] = $cleanedurl;
2055 $urls = array_flip($rawurls);// array_flip removes duplicate files
2056 // and when we merge arrays will continue to automatically remove duplicates
2057 } else {
2058 $urls = array();
2060 return $urls;
2063 * Check that url doesn't point anywhere it shouldn't
2065 * @param $url string relative url within course files directory
2066 * @return mixed boolean false if not OK or cleaned URL as string if OK
2068 function question_url_check($url){
2069 global $CFG;
2070 if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) ||
2071 (substr(strtolower($url), 0, 10) == 'backupdata')){
2072 return false;
2073 } else {
2074 return clean_param($url, PARAM_PATH);
2079 * Find all course / site files linked to in a piece of html.
2080 * @param string html the html to search
2081 * @param int course search for files for courseid course or set to siteid for
2082 * finding site files.
2083 * @return array files with keys being files.
2085 function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){
2086 global $CFG;
2087 if ($CFG->slasharguments) { // Use this method if possible for better caching
2088 $tourl = "$CFG->wwwroot/file.php/$tocourseid/$destination";
2090 } else {
2091 $tourl = "$CFG->wwwroot/file.php?file=/$tocourseid/$destination";
2093 $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!');
2094 $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i',
2095 '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i');
2096 $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html);
2097 if ($newhtml != $html){
2098 $changed = true;
2100 return $newhtml;
2103 function get_filesdir_from_context($context){
2104 switch ($context->contextlevel){
2105 case CONTEXT_COURSE :
2106 $courseid = $context->instanceid;
2107 break;
2108 case CONTEXT_MODULE :
2109 $courseid = get_field('course_modules', 'course', 'id', $context->instanceid);
2110 break;
2111 case CONTEXT_COURSECAT :
2112 case CONTEXT_SYSTEM :
2113 $courseid = SITEID;
2114 break;
2115 default :
2116 error('Unsupported contextlevel in category record!');
2118 return $courseid;