MDL-16263 A way for students to flag/bookmark, particular questions during a quiz...
[moodle.git] / lib / questionlib.php
1 <?php  // $Id$
2 /**
3  * Code for handling and processing questions
4  *
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}
9  *
10  * TODO: separate those functions which form part of the API
11  *       from the helper functions.
12  *
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
19  */
21 /// CONSTANTS ///////////////////////////////////
23 /**#@+
24  * The different types of events that can create question states
25  */
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
37 define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','.
38                     QUESTION_EVENTCLOSEANDGRADE.','.
39                     QUESTION_EVENTMANUALGRADE);
40 global $QUESTION_EVENTS_GRADED;
41 $QUESTION_EVENTS_GRADED = array(QUESTION_EVENTGRADE, QUESTION_EVENTCLOSEANDGRADE,
42         QUESTION_EVENTMANUALGRADE);
44 /**#@-*/
46 /**#@+
47  * The core question types.
48  */
49 define("SHORTANSWER",   "shortanswer");
50 define("TRUEFALSE",     "truefalse");
51 define("MULTICHOICE",   "multichoice");
52 define("RANDOM",        "random");
53 define("MATCH",         "match");
54 define("RANDOMSAMATCH", "randomsamatch");
55 define("DESCRIPTION",   "description");
56 define("NUMERICAL",     "numerical");
57 define("MULTIANSWER",   "multianswer");
58 define("CALCULATED",    "calculated");
59 define("ESSAY",         "essay");
60 /**#@-*/
62 /**
63  * Constant determines the number of answer boxes supplied in the editing
64  * form for multiple choice and similar question types.
65  */
66 define("QUESTION_NUMANS", "10");
68 /**
69  * Constant determines the number of answer boxes supplied in the editing
70  * form for multiple choice and similar question types to start with, with
71  * the option of adding QUESTION_NUMANS_ADD more answers.
72  */
73 define("QUESTION_NUMANS_START", 3);
75 /**
76  * Constant determines the number of answer boxes to add in the editing
77  * form for multiple choice and similar question types when the user presses
78  * 'add form fields button'.
79  */
80 define("QUESTION_NUMANS_ADD", 3);
82 /**
83  * The options used when popping up a question preview window in Javascript.
84  */
85 define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700,height=540');
87 /**#@+
88  * Option flags for ->optionflags
89  * The options are read out via bitwise operation using these constants
90  */
91 /**
92  * Whether the questions is to be run in adaptive mode. If this is not set then
93  * a question closes immediately after the first submission of responses. This
94  * is how question is Moodle always worked before version 1.5
95  */
96 define('QUESTION_ADAPTIVE', 1);
97 /**#@-*/
99 /**#@+
100  * Options used in forms that move files.
101  */
102 define('QUESTION_FILENOTHINGSELECTED', 0);
103 define('QUESTION_FILEDONOTHING', 1);
104 define('QUESTION_FILECOPY', 2);
105 define('QUESTION_FILEMOVE', 3);
106 define('QUESTION_FILEMOVELINKSONLY', 4);
107 /**#@-*/
109 /**#@+
110  * Options for whether flags are shown/editable when rendering questions.
111  */
112 define('QUESTION_FLAGSHIDDEN', 0);
113 define('QUESTION_FLAGSSHOWN', 1);
114 define('QUESTION_FLAGSEDITABLE', 2);
115 /**#@-*/
117 /// QTYPES INITIATION //////////////////
118 // These variables get initialised via calls to question_register_questiontype
119 // as the question type classes are included.
120 global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
121 /**
122  * Array holding question type objects
123  */
124 $QTYPES = array();
125 /**
126  * String in the format "'type1','type2'" that can be used in SQL clauses like
127  * "WHERE q.type IN ($QTYPE_MANUAL)".
128  */
129 $QTYPE_MANUAL = '';
130 /**
131  * String in the format "'type1','type2'" that can be used in SQL clauses like
132  * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)".
133  */
134 $QTYPE_EXCLUDE_FROM_RANDOM = '';
136 /**
137  * Add a new question type to the various global arrays above.
138  *
139  * @param object $qtype An instance of the new question type class.
140  */
141 function question_register_questiontype($qtype) {
142     global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
144     $name = $qtype->name();
145     $QTYPES[$name] = $qtype;
146     if ($qtype->is_manual_graded()) {
147         if ($QTYPE_MANUAL) {
148             $QTYPE_MANUAL .= ',';
149         }
150         $QTYPE_MANUAL .= "'$name'";
151     }
152     if (!$qtype->is_usable_by_random()) {
153         if ($QTYPE_EXCLUDE_FROM_RANDOM) {
154             $QTYPE_EXCLUDE_FROM_RANDOM .= ',';
155         }
156         $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'";
157     }
160 require_once("$CFG->dirroot/question/type/questiontype.php");
162 // Load the questiontype.php file for each question type
163 // These files in turn call question_register_questiontype()
164 // with a new instance of each qtype class.
165 $qtypenames= get_list_of_plugins('question/type');
166 foreach($qtypenames as $qtypename) {
167     // Instanciates all plug-in question types
168     $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
170     // echo "Loading $qtypename<br/>"; // Uncomment for debugging
171     if (is_readable($qtypefilepath)) {
172         require_once($qtypefilepath);
173     }
176 /**
177  * An array of question type names translated to the user's language, suitable for use when
178  * creating a drop-down menu of options.
179  *
180  * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
181  * The array returned will only hold the names of all the question types that the user should
182  * be able to create directly. Some internal question types like random questions are excluded.
183  *
184  * @return array an array of question type names translated to the user's language.
185  */
186 function question_type_menu() {
187     global $QTYPES;
188     static $menu_options = null;
189     if (is_null($menu_options)) {
190         $menu_options = array();
191         foreach ($QTYPES as $name => $qtype) {
192             $menuname = $qtype->menu_name();
193             if ($menuname) {
194                 $menu_options[$name] = $menuname;
195             }
196         }
197     }
198     return $menu_options;
201 /// OTHER CLASSES /////////////////////////////////////////////////////////
203 /**
204  * This holds the options that are set by the course module
205  */
206 class cmoptions {
207     /**
208     * Whether a new attempt should be based on the previous one. If true
209     * then a new attempt will start in a state where all responses are set
210     * to the last responses from the previous attempt.
211     */
212     var $attemptonlast = false;
214     /**
215     * Various option flags. The flags are accessed via bitwise operations
216     * using the constants defined in the CONSTANTS section above.
217     */
218     var $optionflags = QUESTION_ADAPTIVE;
220     /**
221     * Determines whether in the calculation of the score for a question
222     * penalties for earlier wrong responses within the same attempt will
223     * be subtracted.
224     */
225     var $penaltyscheme = true;
227     /**
228     * The maximum time the user is allowed to answer the questions withing
229     * an attempt. This is measured in minutes so needs to be multiplied by
230     * 60 before compared to timestamps. If set to 0 no timelimit will be applied
231     */
232     var $timelimit = 0;
234     /**
235     * Timestamp for the closing time. Responses submitted after this time will
236     * be saved but no credit will be given for them.
237     */
238     var $timeclose = 9999999999;
240     /**
241     * The id of the course from withing which the question is currently being used
242     */
243     var $course = SITEID;
245     /**
246     * Whether the answers in a multiple choice question should be randomly
247     * shuffled when a new attempt is started.
248     */
249     var $shuffleanswers = true;
251     /**
252     * The number of decimals to be shown when scores are printed
253     */
254     var $decimalpoints = 2;
258 /// FUNCTIONS //////////////////////////////////////////////////////
260 /**
261  * Returns an array of names of activity modules that use this question
262  *
263  * @param object $questionid
264  * @return array of strings
265  */
266 function question_list_instances($questionid) {
267     global $CFG, $DB;
268     $instances = array();
269     $modules = $DB->get_records('modules');
270     foreach ($modules as $module) {
271         $fullmod = $CFG->dirroot . '/mod/' . $module->name;
272         if (file_exists($fullmod . '/lib.php')) {
273             include_once($fullmod . '/lib.php');
274             $fn = $module->name.'_question_list_instances';
275             if (function_exists($fn)) {
276                 $instances = $instances + $fn($questionid);
277             }
278         }
279     }
280     return $instances;
283 /**
284  * Determine whether there arey any questions belonging to this context, that is whether any of its
285  * question categories contain any questions. This will return true even if all the questions are
286  * hidden.
287  *
288  * @param mixed $context either a context object, or a context id.
289  * @return boolean whether any of the question categories beloning to this context have
290  *         any questions in them.
291  */
292 function question_context_has_any_questions($context) {
293     global $DB;
294     if (is_object($context)) {
295         $contextid = $context->id;
296     } else if (is_numeric($context)) {
297         $contextid = $context;
298     } else {
299         print_error('invalidcontextinhasanyquestions', 'question');
300     }
301     return $DB->record_exists_sql("SELECT *
302                                      FROM {question} q
303                                      JOIN {question_categories} qc ON qc.id = q.category
304                                     WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
307 /**
308  * Returns list of 'allowed' grades for grade selection
309  * formatted suitably for dropdown box function
310  * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
311  */
312 function get_grade_options() {
313     // define basic array of grades
314     $grades = array(
315         1,
316         0.9,
317         0.8,
318         0.75,
319         0.70,
320         0.66666,
321         0.60,
322         0.50,
323         0.40,
324         0.33333,
325         0.30,
326         0.25,
327         0.20,
328         0.16666,
329         0.142857,
330         0.125,
331         0.11111,
332         0.10,
333         0.05,
334         0);
336     // iterate through grades generating full range of options
337     $gradeoptionsfull = array();
338     $gradeoptions = array();
339     foreach ($grades as $grade) {
340         $percentage = 100 * $grade;
341         $neggrade = -$grade;
342         $gradeoptions["$grade"] = "$percentage %";
343         $gradeoptionsfull["$grade"] = "$percentage %";
344         $gradeoptionsfull["$neggrade"] = -$percentage." %";
345     }
346     $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
348     // sort lists
349     arsort($gradeoptions, SORT_NUMERIC);
350     arsort($gradeoptionsfull, SORT_NUMERIC);
352     // construct return object
353     $grades = new stdClass;
354     $grades->gradeoptions = $gradeoptions;
355     $grades->gradeoptionsfull = $gradeoptionsfull;
357     return $grades;
360 /**
361  * match grade options
362  * if no match return error or match nearest
363  * @param array $gradeoptionsfull list of valid options
364  * @param int $grade grade to be tested
365  * @param string $matchgrades 'error' or 'nearest'
366  * @return mixed either 'fixed' value or false if erro
367  */
368 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
369     // if we just need an error...
370     if ($matchgrades=='error') {
371         foreach($gradeoptionsfull as $value => $option) {
372             // slightly fuzzy test, never check floats for equality :-)
373             if (abs($grade-$value)<0.00001) {
374                 return $grade;
375             }
376         }
377         // didn't find a match so that's an error
378         return false;
379     }
380     // work out nearest value
381     else if ($matchgrades=='nearest') {
382         $hownear = array();
383         foreach($gradeoptionsfull as $value => $option) {
384             if ($grade==$value) {
385                 return $grade;
386             }
387             $hownear[ $value ] = abs( $grade - $value );
388         }
389         // reverse sort list of deltas and grab the last (smallest)
390         asort( $hownear, SORT_NUMERIC );
391         reset( $hownear );
392         return key( $hownear );
393     }
394     else {
395         return false;
396     }
399 /**
400  * Tests whether a category is in use by any activity module
401  *
402  * @return boolean
403  * @param integer $categoryid
404  * @param boolean $recursive Whether to examine category children recursively
405  */
406 function question_category_isused($categoryid, $recursive = false) {
407     global $DB;
409     //Look at each question in the category
410     if ($questions = $DB->get_records('question', array('category'=>$categoryid))) {
411         foreach ($questions as $question) {
412             if (count(question_list_instances($question->id))) {
413                 return true;
414             }
415         }
416     }
418     //Look under child categories recursively
419     if ($recursive) {
420         if ($children = $DB->get_records('question_categories', array('parent'=>$categoryid))) {
421             foreach ($children as $child) {
422                 if (question_category_isused($child->id, $recursive)) {
423                     return true;
424                 }
425             }
426         }
427     }
429     return false;
432 /**
433  * Deletes all data associated to an attempt from the database
434  *
435  * @param integer $attemptid The id of the attempt being deleted
436  */
437 function delete_attempt($attemptid) {
438     global $QTYPES, $DB;
440     $states = $DB->get_records('question_states', array('attempt'=>$attemptid));
441     if ($states) {
442         $stateslist = implode(',', array_keys($states));
444         // delete question-type specific data
445         foreach ($QTYPES as $qtype) {
446             $qtype->delete_states($stateslist);
447         }
448     }
450     // delete entries from all other question tables
451     // It is important that this is done only after calling the questiontype functions
452     $DB->delete_records("question_states", array("attempt"=>$attemptid));
453     $DB->delete_records("question_sessions", array("attemptid"=>$attemptid));
454     $DB->delete_records("question_attempts", array("id"=>$attemptid));
457 /**
458  * Deletes question and all associated data from the database
459  *
460  * It will not delete a question if it is used by an activity module
461  * @param object $question  The question being deleted
462  */
463 function delete_question($questionid) {
464     global $QTYPES, $DB;
466     // Do not delete a question if it is used by an activity module
467     if (count(question_list_instances($questionid))) {
468         return;
469     }
471     // delete questiontype-specific data
472     $question = $DB->get_record('question', array('id'=>$questionid));
473     question_require_capability_on($question, 'edit');
474     if ($question) {
475         if (isset($QTYPES[$question->qtype])) {
476             $QTYPES[$question->qtype]->delete_question($questionid);
477         }
478     } else {
479         echo "Question with id $questionid does not exist.<br />";
480     }
482     if ($states = $DB->get_records('question_states', array('question'=>$questionid))) {
483         $stateslist = implode(',', array_keys($states));
485         // delete questiontype-specific data
486         foreach ($QTYPES as $qtype) {
487             $qtype->delete_states($stateslist);
488         }
489     }
491     // delete entries from all other question tables
492     // It is important that this is done only after calling the questiontype functions
493     $DB->delete_records("question_answers", array("question"=>$questionid));
494     $DB->delete_records("question_states", array("question"=>$questionid));
495     $DB->delete_records("question_sessions", array("questionid"=>$questionid));
497     // Now recursively delete all child questions
498     if ($children = $DB->get_records('question', array('parent'=>$questionid))) {
499         foreach ($children as $child) {
500             if ($child->id != $questionid) {
501                 delete_question($child->id);
502             }
503         }
504     }
506     // Finally delete the question record itself
507     $DB->delete_records('question', array('id'=>$questionid));
509     return;
512 /**
513  * All question categories and their questions are deleted for this course.
514  *
515  * @param object $mod an object representing the activity
516  * @param boolean $feedback to specify if the process must output a summary of its work
517  * @return boolean
518  */
519 function question_delete_course($course, $feedback=true) {
520     global $DB;
522     //To store feedback to be showed at the end of the process
523     $feedbackdata   = array();
525     //Cache some strings
526     $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
527     $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
528     $categoriescourse = $DB->get_records('question_categories', array('contextid'=>$coursecontext->id), 'parent', 'id, parent, name');
530     if ($categoriescourse) {
532         //Sort categories following their tree (parent-child) relationships
533         //this will make the feedback more readable
534         $categoriescourse = sort_categories_by_tree($categoriescourse);
536         foreach ($categoriescourse as $category) {
538             //Delete it completely (questions and category itself)
539             //deleting questions
540             if ($questions = $DB->get_records("question", array("category"=>$category->id))) {
541                 foreach ($questions as $question) {
542                     delete_question($question->id);
543                 }
544                 $DB->delete_records("question", array("category"=>$category->id));
545             }
546             //delete the category
547             $DB->delete_records('question_categories', array('id'=>$category->id));
549             //Fill feedback
550             $feedbackdata[] = array($category->name, $strcatdeleted);
551         }
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);
558         }
559     }
560     return true;
563 /**
564  * Category is about to be deleted,
565  * 1/ All question categories and their questions are deleted for this course category.
566  * 2/ All questions are moved to new category
567  *
568  * @param object $category course category object
569  * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
570  * @param boolean $feedback to specify if the process must output a summary of its work
571  * @return boolean
572  */
573 function question_delete_course_category($category, $newcategory, $feedback=true) {
574     global $DB;
576     $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
577     if (empty($newcategory)) {
578         $feedbackdata   = array(); // To store feedback to be showed at the end of the process
579         $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
580         $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
582         // Loop over question categories.
583         if ($categories = $DB->get_records('question_categories', array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
584             foreach ($categories as $category) {
586                 // Deal with any questions in the category.
587                 if ($questions = $DB->get_records('question', array('category'=>$category->id))) {
589                     // Try to delete each question.
590                     foreach ($questions as $question) {
591                         delete_question($question->id);
592                     }
594                     // Check to see if there were any questions that were kept because they are
595                     // still in use somehow, even though quizzes in courses in this category will
596                     // already have been deteted. This could happen, for example, if questions are
597                     // added to a course, and then that course is moved to another category (MDL-14802).
598                     $questionids = $DB->get_records_menu('question', array('category'=>$category->id), '', 'id,1');
599                     if (!empty($questionids)) {
600                         if (!$rescueqcategory = question_save_from_deletion(implode(',', array_keys($questionids)),
601                                 get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
602                             return false;
603                        }
604                        $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
605                     }
606                 }
608                 // Now delete the category.
609                 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
610                     return false;
611                 }
612                 $feedbackdata[] = array($category->name, $strcatdeleted);
614             } // End loop over categories.
615         }
617         // Output feedback if requested.
618         if ($feedback and $feedbackdata) {
619             $table = new stdClass;
620             $table->head = array(get_string('questioncategory','question'), get_string('action'));
621             $table->data = $feedbackdata;
622             print_table($table);
623         }
625     } else {
626         // Move question categories ot the new context.
627         if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
628             return false;
629         }
630         if (!$DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid'=>$context->id))) {
631             return false;
632         }
633         if ($feedback) {
634             $a = new stdClass;
635             $a->oldplace = print_context_name($context);
636             $a->newplace = print_context_name($newcontext);
637             notify(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
638         }
639     }
641     return true;
644 /**
645  * Enter description here...
646  *
647  * @param string $questionids list of questionids
648  * @param object $newcontext the context to create the saved category in.
649  * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
650  * @param object $newcategory
651  * @return mixed false on
652  */
653 function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
654     global $DB;
656     // Make a category in the parent context to move the questions to.
657     if (is_null($newcategory)) {
658         $newcategory = new object();
659         $newcategory->parent = 0;
660         $newcategory->contextid = $newcontextid;
661         $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
662         $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
663         $newcategory->sortorder = 999;
664         $newcategory->stamp = make_unique_id_code();
665         if (!$newcategory->id = $DB->insert_record('question_categories', $newcategory)) {
666             return false;
667         }
668     }
670     // Move any remaining questions to the 'saved' category.
671     if (!question_move_questions_to_category($questionids, $newcategory->id)) {
672         return false;
673     }
674     return $newcategory;
677 /**
678  * All question categories and their questions are deleted for this activity.
679  *
680  * @param object $cm the course module object representing the activity
681  * @param boolean $feedback to specify if the process must output a summary of its work
682  * @return boolean
683  */
684 function question_delete_activity($cm, $feedback=true) {
685     global $DB;
687     //To store feedback to be showed at the end of the process
688     $feedbackdata   = array();
690     //Cache some strings
691     $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
692     $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
693     if ($categoriesmods = $DB->get_records('question_categories', array('contextid'=>$modcontext->id), 'parent', 'id, parent, name')){
694         //Sort categories following their tree (parent-child) relationships
695         //this will make the feedback more readable
696         $categoriesmods = sort_categories_by_tree($categoriesmods);
698         foreach ($categoriesmods as $category) {
700             //Delete it completely (questions and category itself)
701             //deleting questions
702             if ($questions = $DB->get_records("question", array("category"=>$category->id))) {
703                 foreach ($questions as $question) {
704                     delete_question($question->id);
705                 }
706                 $DB->delete_records("question", array("category"=>$category->id));
707             }
708             //delete the category
709             $DB->delete_records('question_categories', array('id'=>$category->id));
711             //Fill feedback
712             $feedbackdata[] = array($category->name, $strcatdeleted);
713         }
714         //Inform about changes performed if feedback is enabled
715         if ($feedback) {
716             $table = new stdClass;
717             $table->head = array(get_string('category','quiz'), get_string('action'));
718             $table->data = $feedbackdata;
719             print_table($table);
720         }
721     }
722     return true;
725 /**
726  * This function should be considered private to the question bank, it is called from
727  * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
728  * acutally moving questions and associated data. However, callers of this function also have to
729  * do other work, which is why you should not call this method directly from outside the questionbank.
730  *
731  * @param string $questionids a comma-separated list of question ids.
732  * @param integer $newcategory the id of the category to move to.
733  */
734 function question_move_questions_to_category($questionids, $newcategory) {
735     global $DB;
737     $result = true;
739     // Move the questions themselves.
740     $result = $result && $DB->set_field_select('question', 'category', $newcategory, "id IN ($questionids)");
742     // Move any subquestions belonging to them.
743     $result = $result && $DB->set_field_select('question', 'category', $newcategory, "parent IN ($questionids)");
745     // TODO Deal with datasets.
747     return $result;
750 /**
751  * @param array $row tab objects
752  * @param question_edit_contexts $contexts object representing contexts available from this context
753  * @param string $querystring to append to urls
754  * */
755 function questionbank_navigation_tabs(&$row, $contexts, $querystring) {
756     global $CFG, $QUESTION_EDITTABCAPS;
757     $tabs = array(
758             'questions' =>array("$CFG->wwwroot/question/edit.php?$querystring", get_string('questions', 'quiz'), get_string('editquestions', 'quiz')),
759             'categories' =>array("$CFG->wwwroot/question/category.php?$querystring", get_string('categories', 'quiz'), get_string('editqcats', 'quiz')),
760             'import' =>array("$CFG->wwwroot/question/import.php?$querystring", get_string('import', 'quiz'), get_string('importquestions', 'quiz')),
761             'export' =>array("$CFG->wwwroot/question/export.php?$querystring", get_string('export', 'quiz'), get_string('exportquestions', 'quiz')));
762     foreach ($tabs as $tabname => $tabparams){
763         if ($contexts->have_one_edit_tab_cap($tabname)) {
764             $row[] = new tabobject($tabname, $tabparams[0], $tabparams[1], $tabparams[2]);
765         }
766     }
769 /**
770  * Given a list of ids, load the basic information about a set of questions from the questions table.
771  * The $join and $extrafields arguments can be used together to pull in extra data.
772  * See, for example, the usage in mod/quiz/attemptlib.php, and
773  * read the code below to see how the SQL is assembled. Throws exceptions on error.
774  *
775  * @param array $questionids array of question ids.
776  * @param string $extrafields extra SQL code to be added to the query.
777  * @param string $join extra SQL code to be added to the query.
778  * @param array $extraparams values for any placeholders in $join.
779  * You are strongly recommended to use named placeholder.
780  *
781  * @return array partially complete question objects. You need to call get_question_options
782  * on them before they can be properly used.
783  */
784 function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) {
785     global $CFG, $DB;
786     if (empty($questionids)) {
787         return array();
788     }
789     if ($join) {
790         $join = ' JOIN '.$join;
791     }
792     if ($extrafields) {
793         $extrafields = ', ' . $extrafields;
794     }
795     list($questionidcondition, $params) = $DB->get_in_or_equal(
796             $questionids, SQL_PARAMS_NAMED, 'qid0000');
797     $sql = 'SELECT q.*' . $extrafields . ' FROM {question} q' . $join .
798             ' WHERE q.id ' . $questionidcondition;
800     // Load the questions
801     if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
802         return 'Could not load questions.';
803     }
805     foreach ($questions as $question) {
806         $question->_partiallyloaded = true;
807     }
809     // Note, a possible optimisation here would be to not load the TEXT fields
810     // (that is, questiontext and generalfeedback) here, and instead load them in
811     // question_load_questions. That would add one DB query, but reduce the amount
812     // of data transferred most of the time. I am not going to do this optimisation
813     // until it is shown to be worthwhile.
815     return $questions;
818 /**
819  * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
820  * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
821  * read the code below to see how the SQL is assembled. Throws exceptions on error.
822  *
823  * @param array $questionids array of question ids.
824  * @param string $extrafields extra SQL code to be added to the query.
825  * @param string $join extra SQL code to be added to the query.
826  * @param array $extraparams values for any placeholders in $join.
827  * You are strongly recommended to use named placeholder.
828  *
829  * @return array question objects.
830  */
831 function question_load_questions($questionids, $extrafields = '', $join = '') {
832     $questions = question_preload_questions($questionids, $extrafields, $join);
834     // Load the question type specific information
835     if (!get_question_options($questions)) {
836         return 'Could not load the question options';
837     }
839     return $questions;
842 /**
843  * Private function to factor common code out of get_question_options().
844  *
845  * @param object $question the question to tidy.
846  * @return boolean true if successful, else false.
847  */
848 function _tidy_question(&$question) {
849     global $QTYPES;
850     if (!array_key_exists($question->qtype, $QTYPES)) {
851         $question->qtype = 'missingtype';
852         $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
853     }
854     $question->name_prefix = question_make_name_prefix($question->id);
855     if ($success = $QTYPES[$question->qtype]->get_question_options($question)) {
856         if (isset($question->_partiallyloaded)) {
857             unset($question->_partiallyloaded);
858         }
859     }
860     return $success;
863 /**
864  * Updates the question objects with question type specific
865  * information by calling {@link get_question_options()}
866  *
867  * Can be called either with an array of question objects or with a single
868  * question object.
869  *
870  * @param mixed $questions Either an array of question objects to be updated
871  *         or just a single question object
872  * @return bool Indicates success or failure.
873  */
874 function get_question_options(&$questions) {
875     if (is_array($questions)) { // deal with an array of questions
876         foreach ($questions as $i => $notused) {
877             if (!_tidy_question($questions[$i])) {
878                 return false;
879             }
880         }
881         return true;
882     } else { // deal with single question
883         return _tidy_question($questions);
884     }
887 /**
888 * Loads the most recent state of each question session from the database
889 * or create new one.
891 * For each question the most recent session state for the current attempt
892 * is loaded from the question_states table and the question type specific data and
893 * responses are added by calling {@link restore_question_state()} which in turn
894 * calls {@link restore_session_and_responses()} for each question.
895 * If no states exist for the question instance an empty state object is
896 * created representing the start of a session and empty question
897 * type specific information and responses are created by calling
898 * {@link create_session_and_responses()}.
900 * @return array           An array of state objects representing the most recent
901 *                         states of the question sessions.
902 * @param array $questions The questions for which sessions are to be restored or
903 *                         created.
904 * @param object $cmoptions
905 * @param object $attempt  The attempt for which the question sessions are
906 *                         to be restored or created.
907 * @param mixed either the id of a previous attempt, if this attmpt is
908 *                         building on a previous one, or false for a clean attempt.
909 */
910 function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
911     global $CFG, $QTYPES, $DB;
913     // get the question ids
914     $ids = array_keys($questions);
915     $questionlist = implode(',', $ids);
917     // The question field must be listed first so that it is used as the
918     // array index in the array returned by $DB->get_records_sql
919     $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment, n.flagged, n.id as questionsessionid';
920     // Load the newest states for the questions
921     $sql = "SELECT $statefields
922               FROM {question_states} s, {question_sessions} n
923              WHERE s.id = n.newest
924                    AND n.attemptid = ?
925                    AND n.questionid IN ($questionlist)";
926     $states = $DB->get_records_sql($sql, array($attempt->uniqueid));
928     // Load the newest graded states for the questions
929     $sql = "SELECT $statefields
930               FROM {question_states} s, {question_sessions} n
931              WHERE s.id = n.newgraded
932                    AND n.attemptid = ?
933                    AND n.questionid IN ($questionlist)";
934     $gradedstates = $DB->get_records_sql($sql, array($attempt->uniqueid));
936     // loop through all questions and set the last_graded states
937     foreach ($ids as $i) {
938         if (isset($states[$i])) {
939             restore_question_state($questions[$i], $states[$i]);
940             if (isset($gradedstates[$i])) {
941                 restore_question_state($questions[$i], $gradedstates[$i]);
942                 $states[$i]->last_graded = $gradedstates[$i];
943             } else {
944                 $states[$i]->last_graded = clone($states[$i]);
945             }
946         } else {
947             // If the new attempt is to be based on a previous attempt get it and clean things
948             // Having lastattemptid filled implies that (should we double check?):
949             //    $attempt->attempt > 1 and $cmoptions->attemptonlast and !$attempt->preview
950             if ($lastattemptid) {
951                 // find the responses from the previous attempt and save them to the new session
953                 // Load the last graded state for the question
954                 $statefields = 'n.questionid as question, s.*, n.sumpenalty';
955                 $sql = "SELECT $statefields
956                           FROM {question_states} s, {question_sessions} n
957                          WHERE s.id = n.newgraded
958                                AND n.attemptid = ?
959                                AND n.questionid = ?";
960                 if (!$laststate = $DB->get_record_sql($sql, array($lastattemptid, $i))) {
961                     // Only restore previous responses that have been graded
962                     continue;
963                 }
964                 // Restore the state so that the responses will be restored
965                 restore_question_state($questions[$i], $laststate);
966                 $states[$i] = clone($laststate);
967                 unset($states[$i]->id);
968             } else {
969                 // create a new empty state
970                 $states[$i] = new object;
971                 $states[$i]->question = $i;
972                 $states[$i]->responses = array('' => '');
973                 $states[$i]->raw_grade = 0;
974             }
976             // now fill/overide initial values
977             $states[$i]->attempt = $attempt->uniqueid;
978             $states[$i]->seq_number = 0;
979             $states[$i]->timestamp = $attempt->timestart;
980             $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
981             $states[$i]->grade = 0;
982             $states[$i]->penalty = 0;
983             $states[$i]->sumpenalty = 0;
984             $states[$i]->manualcomment = '';
986             // Prevent further changes to the session from incrementing the
987             // sequence number
988             $states[$i]->changed = true;
990             if ($lastattemptid) {
991                 // prepare the previous responses for new processing
992                 $action = new stdClass;
993                 $action->responses = $laststate->responses;
994                 $action->timestamp = $laststate->timestamp;
995                 $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
997                 // Process these responses ...
998                 question_process_responses($questions[$i], $states[$i], $action, $cmoptions, $attempt);
1000                 // Fix for Bug #5506: When each attempt is built on the last one,
1001                 // preserve the options from any previous attempt.
1002                 if ( isset($laststate->options) ) {
1003                     $states[$i]->options = $laststate->options;
1004                 }
1005             } else {
1006                 // Create the empty question type specific information
1007                 if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses(
1008                         $questions[$i], $states[$i], $cmoptions, $attempt)) {
1009                     return false;
1010                 }
1011             }
1012             $states[$i]->last_graded = clone($states[$i]);
1013         }
1014     }
1015     return $states;
1018 /**
1019  * Load a particular previous state of a question.
1020  *
1021  * @param array $question The question to load the state for.
1022  * @param object $cmoptions Options from the specifica activity module, e.g. $quiz.
1023  * @param object $attempt The attempt for which the question sessions are to be loaded.
1024  * @param integer $stateid The id of a specific state of this question.
1025  * @return object the requested state. False on error.
1026  */
1027 function question_load_specific_state($question, $cmoptions, $attempt, $stateid) {
1028     global $DB, $QUESTION_EVENTS_GRADED;
1029     // Load specified states for the question.
1030     $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment
1031               FROM {question_states} st, {question_sessions} sess
1032              WHERE st.id = ?
1033                AND st.attempt = ?
1034                AND sess.attemptid = st.attempt
1035                AND st.question = ?
1036                AND sess.questionid = st.question';
1037     $state = $DB->get_record_sql($sql, array($stateid, $attempt->id, $question->id));
1038     if (!$state) {
1039         return false;
1040     }
1041     restore_question_state($question, $state);
1043     // Load the most recent graded states for the questions before the specified one.
1044     list($eventinsql, $params) = $DB->get_in_or_equal($QUESTION_EVENTS_GRADED);
1045     $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment
1046               FROM {question_states} st, {question_sessions} sess
1047              WHERE st.seq_number <= ?
1048                AND st.attempt = ?
1049                AND sess.attemptid = st.attempt
1050                AND st.question = ?
1051                AND sess.questionid = st.question
1052                AND st.event ' . $eventinsql .
1053            'ORDER BY st.seq_number DESC';
1054     $gradedstates = $DB->get_records_sql($sql, array_merge(
1055             array($state->seq_number, $attempt->id, $question->id), $params), 0, 1);
1056     if (empty($gradedstates)) {
1057         $state->last_graded = clone($state);
1058     } else {
1059         $gradedstate = reset($gradedstates);
1060         restore_question_state($question, $gradedstate);
1061         $state->last_graded = $gradedstate;
1062     }
1063     return $state;
1066 /**
1067 * Creates the run-time fields for the states
1069 * Extends the state objects for a question by calling
1070 * {@link restore_session_and_responses()}
1071 * @param object $question The question for which the state is needed
1072 * @param object $state The state as loaded from the database
1073 * @return boolean Represents success or failure
1074 */
1075 function restore_question_state(&$question, &$state) {
1076     global $QTYPES;
1078     // initialise response to the value in the answer field
1079     $state->responses = array('' => $state->answer);
1080     unset($state->answer);
1081     $state->manualcomment = isset($state->manualcomment) ? $state->manualcomment : '';
1083     // Set the changed field to false; any code which changes the
1084     // question session must set this to true and must increment
1085     // ->seq_number. The save_question_session
1086     // function will save the new state object to the database if the field is
1087     // set to true.
1088     $state->changed = false;
1090     // Load the question type specific data
1091     return $QTYPES[$question->qtype]
1092             ->restore_session_and_responses($question, $state);
1096 /**
1097 * Saves the current state of the question session to the database
1099 * The state object representing the current state of the session for the
1100 * question is saved to the question_states table with ->responses[''] saved
1101 * to the answer field of the database table. The information in the
1102 * question_sessions table is updated.
1103 * The question type specific data is then saved.
1104 * @return mixed           The id of the saved or updated state or false
1105 * @param object $question The question for which session is to be saved.
1106 * @param object $state    The state information to be saved. In particular the
1107 *                         most recent responses are in ->responses. The object
1108 *                         is updated to hold the new ->id.
1109 */
1110 function save_question_session($question, $state) {
1111     global $QTYPES, $DB;
1112     // Check if the state has changed
1113     if (!$state->changed && isset($state->id)) {
1114         return $state->id;
1115     }
1116     // Set the legacy answer field
1117     $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
1119     // Save the state
1120     if (!empty($state->update)) { // this forces the old state record to be overwritten
1121         $DB->update_record('question_states', $state);
1122     } else {
1123         if (!$state->id = $DB->insert_record('question_states', $state)) {
1124             unset($state->id);
1125             unset($state->answer);
1126             return false;
1127         }
1128     }
1130     // create or update the session
1131     if (!$session = $DB->get_record('question_sessions', array('attemptid' => $state->attempt, 'questionid' => $question->id))) {
1132         $session->attemptid = $state->attempt;
1133         $session->questionid = $question->id;
1134         $session->newest = $state->id;
1135         // The following may seem weird, but the newgraded field needs to be set
1136         // already even if there is no graded state yet.
1137         $session->newgraded = $state->id;
1138         $session->sumpenalty = $state->sumpenalty;
1139         $session->manualcomment = $state->manualcomment;
1140         if (!$DB->insert_record('question_sessions', $session)) {
1141             print_error('cannotinsert', 'question');
1142         }
1143     } else {
1144         $session->newest = $state->id;
1145         if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
1146             // this state is graded or newly opened, so it goes into the lastgraded field as well
1147             $session->newgraded = $state->id;
1148             $session->sumpenalty = $state->sumpenalty;
1149             $session->manualcomment = $state->manualcomment;
1150         } else {
1151             $session->manualcomment = $session->manualcomment;
1152         }
1153         $DB->update_record('question_sessions', $session);
1154     }
1156     unset($state->answer);
1158     // Save the question type specific state information and responses
1159     if (!$QTYPES[$question->qtype]->save_session_and_responses(
1160      $question, $state)) {
1161         return false;
1162     }
1163     // Reset the changed flag
1164     $state->changed = false;
1165     return $state->id;
1168 /**
1169 * Determines whether a state has been graded by looking at the event field
1171 * @return boolean         true if the state has been graded
1172 * @param object $state
1173 */
1174 function question_state_is_graded($state) {
1175     $gradedevents = explode(',', QUESTION_EVENTS_GRADED);
1176     return (in_array($state->event, $gradedevents));
1179 /**
1180 * Determines whether a state has been closed by looking at the event field
1182 * @return boolean         true if the state has been closed
1183 * @param object $state
1184 */
1185 function question_state_is_closed($state) {
1186     return ($state->event == QUESTION_EVENTCLOSE
1187         or $state->event == QUESTION_EVENTCLOSEANDGRADE
1188         or $state->event == QUESTION_EVENTMANUALGRADE);
1192 /**
1193  * Extracts responses from submitted form
1194  *
1195  * This can extract the responses given to one or several questions present on a page
1196  * It returns an array with one entry for each question, indexed by question id
1197  * Each entry is an object with the properties
1198  *  ->event     The event that has triggered the submission. This is determined by which button
1199  *               the user has pressed.
1200  *  ->responses An array holding the responses to an individual question, indexed by the
1201  *               name of the corresponding form element.
1202  *  ->timestamp A unix timestamp
1203  * @return array            array of action objects, indexed by question ids.
1204  * @param array $questions  an array containing at least all questions that are used on the form
1205  * @param array $formdata   the data submitted by the form on the question page
1206  * @param integer $defaultevent  the event type used if no 'mark' or 'validate' is submitted
1207  */
1208 function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
1210     $time = time();
1211     $actions = array();
1212     foreach ($formdata as $key => $response) {
1213         // Get the question id from the response name
1214         if (false !== ($quid = question_get_id_from_name_prefix($key))) {
1215             // check if this is a valid id
1216             if (!isset($questions[$quid])) {
1217                 print_error('formquestionnotinids', 'question');
1218             }
1220             // Remove the name prefix from the name
1221             //decrypt trying
1222             $key = substr($key, strlen($questions[$quid]->name_prefix));
1223             if (false === $key) {
1224                 $key = '';
1225             }
1226             // Check for question validate and mark buttons & set events
1227             if ($key === 'validate') {
1228                 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
1229             } else if ($key === 'submit') {
1230                 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
1231             } else {
1232                 $actions[$quid]->event = $defaultevent;
1233             }
1235             // Update the state with the new response
1236             $actions[$quid]->responses[$key] = $response;
1238             // Set the timestamp
1239             $actions[$quid]->timestamp = $time;
1240         }
1241     }
1242     foreach ($actions as $quid => $notused) {
1243         ksort($actions[$quid]->responses);
1244     }
1245     return $actions;
1249 /**
1250  * Returns the html for question feedback image.
1251  * @param float   $fraction  value representing the correctness of the user's
1252  *                           response to a question.
1253  * @param boolean $selected  whether or not the answer is the one that the
1254  *                           user picked.
1255  * @return string
1256  */
1257 function question_get_feedback_image($fraction, $selected=true) {
1258     global $CFG;
1259     static $icons = array('correct' => 'tick_green', 'partiallycorrect' => 'tick_amber',
1260             'incorrect' => 'cross_red');
1262     if ($selected) {
1263         $size = 'big';
1264     } else {
1265         $size = 'small';
1266     }
1267     $class = question_get_feedback_class($fraction);
1268     return '<img src="' . $CFG->pixpath.'/i/' . $icons[$class] . '_' . $size . '.gif" '.
1269             'alt="' . get_string($class, 'quiz') . '" class="icon" />';
1272 /**
1273  * Returns the class name for question feedback.
1274  * @param float  $fraction  value representing the correctness of the user's
1275  *                          response to a question.
1276  * @return string
1277  */
1278 function question_get_feedback_class($fraction) {
1279     if ($fraction >= 1/1.01) {
1280         return 'correct';
1281     } else if ($fraction > 0.0) {
1282         return 'partiallycorrect';
1283     } else {
1284         return 'incorrect';
1285     }
1289 /**
1290 * For a given question in an attempt we walk the complete history of states
1291 * and recalculate the grades as we go along.
1293 * This is used when a question is changed and old student
1294 * responses need to be marked with the new version of a question.
1296 * TODO: Make sure this is not quiz-specific
1298 * @return boolean            Indicates whether the grade has changed
1299 * @param object  $question   A question object
1300 * @param object  $attempt    The attempt, in which the question needs to be regraded.
1301 * @param object  $cmoptions
1302 * @param boolean $verbose    Optional. Whether to print progress information or not.
1303 * @param boolean $dryrun     Optional. Whether to make changes to grades records
1304 * or record that changes need to be made for a later regrade.
1305 */
1306 function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false, $dryrun=false) {
1307     global $DB;
1309     // load all states for this question in this attempt, ordered in sequence
1310     if ($states = $DB->get_records('question_states',
1311             array('attempt'=>$attempt->uniqueid, 'question'=>$question->id),
1312             'seq_number ASC')) {
1313         $states = array_values($states);
1315         // Subtract the grade for the latest state from $attempt->sumgrades to get the
1316         // sumgrades for the attempt without this question.
1317         $attempt->sumgrades -= $states[count($states)-1]->grade;
1319         // Initialise the replaystate
1320         $state = clone($states[0]);
1321         $state->manualcomment = $DB->get_field('question_sessions', 'manualcomment',
1322                 array('attemptid'=> $attempt->uniqueid, 'questionid'=>$question->id));
1323         restore_question_state($question, $state);
1324         $state->sumpenalty = 0.0;
1325         $replaystate = clone($state);
1326         $replaystate->last_graded = $state;
1328         $changed = false;
1329         for($j = 1; $j < count($states); $j++) {
1330             restore_question_state($question, $states[$j]);
1331             $action = new stdClass;
1332             $action->responses = $states[$j]->responses;
1333             $action->timestamp = $states[$j]->timestamp;
1335             // Change event to submit so that it will be reprocessed
1336             if (QUESTION_EVENTCLOSE == $states[$j]->event
1337                     or QUESTION_EVENTGRADE == $states[$j]->event
1338                     or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) {
1339                 $action->event = QUESTION_EVENTSUBMIT;
1341             // By default take the event that was saved in the database
1342             } else {
1343                 $action->event = $states[$j]->event;
1344             }
1346             if ($action->event == QUESTION_EVENTMANUALGRADE) {
1347                 // Ensure that the grade is in range - in the past this was not checked,
1348                 // but now it is (MDL-14835) - so we need to ensure the data is valid before
1349                 // proceeding.
1350                 if ($states[$j]->grade < 0) {
1351                     $states[$j]->grade = 0;
1352                     $changed = true;
1353                 } else if ($states[$j]->grade > $question->maxgrade) {
1354                     $states[$j]->grade = $question->maxgrade;
1355                     $changed = true;
1356                     
1357                 }
1358                 if (!$dryrun){
1359                     $error = question_process_comment($question, $replaystate, $attempt,
1360                             $replaystate->manualcomment, $states[$j]->grade);
1361                     if (is_string($error)) {
1362                          notify($error);
1363                     }
1364                 } else {
1365                     $replaystate->grade = $states[$j]->grade;
1366                 }
1367             } else {
1368                 // Reprocess (regrade) responses
1369                 if (!question_process_responses($question, $replaystate,
1370                         $action, $cmoptions, $attempt)) {
1371                     $verbose && notify("Couldn't regrade state #{$state->id}!");
1372                 }
1373                 // We need rounding here because grades in the DB get truncated
1374                 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
1375                 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
1376                         or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1377                         or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
1378                     $changed = true;
1379                 }
1380             }
1382             $replaystate->id = $states[$j]->id;
1383             $replaystate->changed = true;
1384             $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
1385             if (!$dryrun){
1386                 save_question_session($question, $replaystate);
1387             }
1388         }
1389         if ($changed) {
1390             if (!$dryrun){
1391                 // TODO, call a method in quiz to do this, where 'quiz' comes from
1392                 // the question_attempts table.
1393                 $DB->update_record('quiz_attempts', $attempt);
1394             }
1395         }
1396         if ($changed){
1397             $toinsert = new object();
1398             $toinsert->oldgrade = round((float)$states[count($states)-1]->grade, 5);
1399             $toinsert->newgrade = round((float)$replaystate->grade, 5);
1400             $toinsert->attemptid = $attempt->uniqueid;
1401             $toinsert->questionid = $question->id;
1402             //the grade saved is the old grade if the new grade is saved
1403             //it is the new grade if this is a dry run.
1404             $toinsert->regraded = $dryrun?0:1;
1405             $toinsert->timemodified = time();
1406             $DB->insert_record('quiz_question_regrade', $toinsert);
1407             return true;
1408         } else {
1409             return false;
1410         }
1411     }
1412     return false;
1415 /**
1416 * Processes an array of student responses, grading and saving them as appropriate
1418 * @param object $question Full question object, passed by reference
1419 * @param object $state    Full state object, passed by reference
1420 * @param object $action   object with the fields ->responses which
1421 *                         is an array holding the student responses,
1422 *                         ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
1423 *                         and ->timestamp which is a timestamp from when the responses
1424 *                         were submitted by the student.
1425 * @param object $cmoptions
1426 * @param object $attempt  The attempt is passed by reference so that
1427 *                         during grading its ->sumgrades field can be updated
1428 * @return boolean         Indicates success/failure
1429 */
1430 function question_process_responses($question, &$state, $action, $cmoptions, &$attempt) {
1431     global $QTYPES;
1433     // if no responses are set initialise to empty response
1434     if (!isset($action->responses)) {
1435         $action->responses = array('' => '');
1436     }
1438     // make sure these are gone!
1439     unset($action->responses['submit'], $action->responses['validate']);
1441     // Check the question session is still open
1442     if (question_state_is_closed($state)) {
1443         return true;
1444     }
1446     // If $action->event is not set that implies saving
1447     if (! isset($action->event)) {
1448         debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
1449         $action->event = QUESTION_EVENTSAVE;
1450     }
1451     // If submitted then compare against last graded
1452     // responses, not last given responses in this case
1453     if (question_isgradingevent($action->event)) {
1454         $state->responses = $state->last_graded->responses;
1455     }
1457     // Check for unchanged responses (exactly unchanged, not equivalent).
1458     // We also have to catch questions that the student has not yet attempted
1459     $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
1460     if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
1461             question_isgradingevent($action->event)) {
1462         $sameresponses = false;
1463     }
1465     // If the response has not been changed then we do not have to process it again
1466     // unless the attempt is closing or validation is requested
1467     if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
1468             and QUESTION_EVENTVALIDATE != $action->event) {
1469         return true;
1470     }
1472     // Roll back grading information to last graded state and set the new
1473     // responses
1474     $newstate = clone($state->last_graded);
1475     $newstate->responses = $action->responses;
1476     $newstate->seq_number = $state->seq_number + 1;
1477     $newstate->changed = true; // will assure that it gets saved to the database
1478     $newstate->last_graded = clone($state->last_graded);
1479     $newstate->timestamp = $action->timestamp;
1480     $state = $newstate;
1482     // Set the event to the action we will perform. The question type specific
1483     // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
1484     // attempt at the question causes the session to close
1485     $state->event = $action->event;
1487     if (!question_isgradingevent($action->event)) {
1488         // Grade the response but don't update the overall grade
1489         if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1490             return false;
1491         }
1493         // Temporary hack because question types are not given enough control over what is going
1494         // on. Used by Opaque questions.
1495         // TODO fix this code properly.
1496         if (!empty($state->believeevent)) {
1497             // If the state was graded we need to ...
1498             if (question_state_is_graded($state)) {
1499                 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1501                 // update the attempt grade
1502                 $attempt->sumgrades -= (float)$state->last_graded->grade;
1503                 $attempt->sumgrades += (float)$state->grade;
1505                 // and update the last_graded field.
1506                 unset($state->last_graded);
1507                 $state->last_graded = clone($state);
1508                 unset($state->last_graded->changed);
1509             }
1510         } else {
1511             // Don't allow the processing to change the event type
1512             $state->event = $action->event;
1513         }
1515     } else { // grading event
1517         // Unless the attempt is closing, we want to work out if the current responses
1518         // (or equivalent responses) were already given in the last graded attempt.
1519         if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1520                 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
1521             $state->event = QUESTION_EVENTDUPLICATE;
1522         }
1524         // If we did not find a duplicate or if the attempt is closing, perform grading
1525         if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1526                 QUESTION_EVENTCLOSE == $action->event) {
1527             if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1528                 return false;
1529             }
1531             // Calculate overall grade using correct penalty method
1532             question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1533         }
1535         // If the state was graded we need to ...
1536         if (question_state_is_graded($state)) {
1537             // update the attempt grade
1538             $attempt->sumgrades -= (float)$state->last_graded->grade;
1539             $attempt->sumgrades += (float)$state->grade;
1541             // and update the last_graded field.
1542             unset($state->last_graded);
1543             $state->last_graded = clone($state);
1544             unset($state->last_graded->changed);
1545         }
1546     }
1547     $attempt->timemodified = $action->timestamp;
1549     return true;
1552 /**
1553 * Determine if event requires grading
1554 */
1555 function question_isgradingevent($event) {
1556     return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
1559 /**
1560 * Applies the penalty from the previous graded responses to the raw grade
1561 * for the current responses
1563 * The grade for the question in the current state is computed by subtracting the
1564 * penalty accumulated over the previous graded responses at the question from the
1565 * raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1566 * the grade is set to zero. The ->grade field of the state object is modified to
1567 * reflect the new grade but is never allowed to decrease.
1568 * @param object $question The question for which the penalty is to be applied.
1569 * @param object $state    The state for which the grade is to be set from the
1570 *                         raw grade and the cumulative penalty from the last
1571 *                         graded state. The ->grade field is updated by applying
1572 *                         the penalty scheme determined in $cmoptions to the ->raw_grade and
1573 *                         ->last_graded->penalty fields.
1574 * @param object $cmoptions  The options set by the course module.
1575 *                           The ->penaltyscheme field determines whether penalties
1576 *                           for incorrect earlier responses are subtracted.
1577 */
1578 function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
1579     // TODO. Quiz dependancy. The fact that the attempt that is passed in here
1580     // is from quiz_attempts, and we use things like $cmoptions->timelimit.
1582     // deal with penalty
1583     if ($cmoptions->penaltyscheme) {
1584         $state->grade = $state->raw_grade - $state->sumpenalty;
1585         $state->sumpenalty += (float) $state->penalty;
1586     } else {
1587         $state->grade = $state->raw_grade;
1588     }
1590     // deal with timelimit
1591     if ($cmoptions->timelimit) {
1592         // We allow for 5% uncertainty in the following test
1593         if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 63) {
1594             $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
1595             if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
1596                     $attempt->userid, false)) {
1597                 $state->grade = 0;
1598             }
1599         }
1600     }
1602     // deal with closing time
1603     if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1604              and !$attempt->preview) { // ignore closing time for previews
1605         $state->grade = 0;
1606     }
1608     // Ensure that the grade does not go down
1609     $state->grade = max($state->grade, $state->last_graded->grade);
1612 /**
1613 * Print the icon for the question type
1615 * @param object $question  The question object for which the icon is required
1616 * @param boolean $return   If true the functions returns the link as a string
1617 */
1618 function print_question_icon($question, $return = false) {
1619     global $QTYPES, $CFG;
1621     if (array_key_exists($question->qtype, $QTYPES)) {
1622         $namestr = $QTYPES[$question->qtype]->menu_name();
1623     } else {
1624         $namestr = 'missingtype';
1625     }
1626     $html = '<img src="' . $CFG->wwwroot . '/question/type/' .
1627             $question->qtype . '/icon.gif" alt="' .
1628             $namestr . '" title="' . $namestr . '" />';
1629     if ($return) {
1630         return $html;
1631     } else {
1632         echo $html;
1633     }
1636 /**
1637 * Returns a html link to the question image if there is one
1639 * @return string The html image tag or the empy string if there is no image.
1640 * @param object $question The question object
1641 */
1642 function get_question_image($question) {
1643     global $CFG, $DB;
1644     $img = '';
1646     if (!$category = $DB->get_record('question_categories', array('id'=>$question->category))) {
1647         print_error('invalidcategory');
1648     }
1649     $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid));
1651     if ($question->image) {
1653         if (substr(strtolower($question->image), 0, 7) == 'http://') {
1654             $img .= $question->image;
1656         } else {
1657             require_once($CFG->libdir .'/filelib.php');
1658             $img = get_file_url("$coursefilesdir/{$question->image}");
1659         }      
1660     }
1661     return $img;
1664 function question_print_comment_box($question, $state, $attempt, $url) {
1665    global $CFG;
1667    $prefix = 'response';
1668    $usehtmleditor = can_use_html_editor();
1669    $grade = round($state->last_graded->grade, 3);
1670    echo '<form method="post" action="'.$url.'">';
1671    include($CFG->dirroot.'/question/comment.html');
1672    echo '<input type="hidden" name="attempt" value="'.$attempt->uniqueid.'" />';
1673    echo '<input type="hidden" name="question" value="'.$question->id.'" />';
1674    echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
1675    echo '<input type="submit" name="submit" value="'.get_string('save', 'quiz').'" />';
1676    echo '</form>';
1678    if ($usehtmleditor) {
1679        use_html_editor();
1680    }
1683 /**
1684  * Process a manual grading action. That is, use $comment and $grade to update
1685  * $state and $attempt. The attempt and the comment text are stored in the
1686  * database. $state is only updated in memory, it is up to the call to store
1687  * that, if appropriate.
1688  *
1689  * @param object $question the question
1690  * @param object $state the state to be updated.
1691  * @param object $attempt the attempt the state belongs to, to be updated.
1692  * @param string $comment the comment the teacher added
1693  * @param float $grade the grade the teacher assigned.
1694  * @return mixed true on success, a string error message if a problem is detected
1695  *         (for example score out of range).
1696  */
1697 function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1698     global $DB;
1700     if ($grade < 0 || $grade > $question->maxgrade) {
1701         $a = new stdClass;
1702         $a->grade = $grade;
1703         $a->maxgrade = $question->maxgrade;
1704         $a->name = $question->name;
1705         return get_string('errormanualgradeoutofrange', 'question', $a);
1706     }
1708     // Update the comment and save it in the database
1709     $comment = trim($comment);
1710     $state->manualcomment = $comment;
1711     if (!$DB->set_field('question_sessions', 'manualcomment', $comment, array('attemptid'=>$attempt->uniqueid, 'questionid'=>$question->id))) {
1712         return get_string('errorsavingcomment', 'question', $question);
1713     }
1715     // Update the attempt if the score has changed.
1716     if (abs($state->last_graded->grade - $grade) > 0.002) {
1717         $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1718         $attempt->timemodified = time();
1719         if (!$DB->update_record('quiz_attempts', $attempt)) {
1720             return get_string('errorupdatingattempt', 'question', $attempt);
1721         }
1722     }
1724     // Update the state if either the score has changed, or this is the first
1725     // manual grade event and there is actually a grade of comment to process.
1726     // We don't need to store the modified state in the database, we just need
1727     // to set the $state->changed flag.
1728     if (abs($state->last_graded->grade - $grade) > 0.002 ||
1729             ($state->last_graded->event != QUESTION_EVENTMANUALGRADE && ($grade > 0.002 || $comment != ''))) {
1731         // We want to update existing state (rather than creating new one) if it
1732         // was itself created by a manual grading event.
1733         $state->update = ($state->event == QUESTION_EVENTMANUALGRADE) ? 1 : 0;
1735         // Update the other parts of the state object.
1736         $state->raw_grade = $grade;
1737         $state->grade = $grade;
1738         $state->penalty = 0;
1739         $state->timestamp = time();
1740         $state->seq_number++;
1741         $state->event = QUESTION_EVENTMANUALGRADE;
1743         // Update the last graded state (don't simplify!)
1744         unset($state->last_graded);
1745         $state->last_graded = clone($state);
1747         // We need to indicate that the state has changed in order for it to be saved.
1748         $state->changed = 1;
1749     }
1751     return true;
1754 /**
1755 * Construct name prefixes for question form element names
1757 * Construct the name prefix that should be used for example in the
1758 * names of form elements created by questions.
1759 * This is called by {@link get_question_options()}
1760 * to set $question->name_prefix.
1761 * This name prefix includes the question id which can be
1762 * extracted from it with {@link question_get_id_from_name_prefix()}.
1764 * @return string
1765 * @param integer $id  The question id
1766 */
1767 function question_make_name_prefix($id) {
1768     return 'resp' . $id . '_';
1771 /**
1772 * Extract question id from the prefix of form element names
1774 * @return integer      The question id
1775 * @param string $name  The name that contains a prefix that was
1776 *                      constructed with {@link question_make_name_prefix()}
1777 */
1778 function question_get_id_from_name_prefix($name) {
1779     if (!preg_match('/^resp([0-9]+)_/', $name, $matches))
1780         return false;
1781     return (integer) $matches[1];
1784 /**
1785  * Returns the unique id for a new attempt
1786  *
1787  * Every module can keep their own attempts table with their own sequential ids but
1788  * the question code needs to also have a unique id by which to identify all these
1789  * attempts. Hence a module, when creating a new attempt, calls this function and
1790  * stores the return value in the 'uniqueid' field of its attempts table.
1791  */
1792 function question_new_attempt_uniqueid($modulename='quiz') {
1793     global $DB;
1795     $attempt = new stdClass;
1796     $attempt->modulename = $modulename;
1797     if (!$id = $DB->insert_record('question_attempts', $attempt)) {
1798         print_error('cannotcreate', 'question');
1799     }
1800     return $id;
1803 /**
1804  * Creates a stamp that uniquely identifies this version of the question
1805  *
1806  * In future we want this to use a hash of the question data to guarantee that
1807  * identical versions have the same version stamp.
1808  *
1809  * @param object $question
1810  * @return string A unique version stamp
1811  */
1812 function question_hash($question) {
1813     return make_unique_id_code();
1816 /**
1817  * Round a grade to to the correct number of decimal places, and format it for display.
1818  *
1819  * @param object $cmoptions The modules settings, only ->decimalpoints is used.
1820  * @param float $grade The grade to round.
1821  */
1822 function question_format_grade($cmoptions, $grade) {
1823     return format_float($grade, $cmoptions->decimalpoints);
1826 /**
1827  * @return string An inline script that creates a JavaScript object storing
1828  * various strings and bits of configuration that the scripts in qengine.js need
1829  * to get from PHP.
1830  */
1831 function question_init_qenginejs_script() {
1832     global $CFG;
1834     // Get the properties we want into a PHP array first, becase that is easier
1835     // to build.
1836     $config = array(
1837         'pixpath' => $CFG->pixpath,
1838         'wwwroot' => $CFG->wwwroot,
1839         'flagtooltip' => get_string('clicktoflag', 'question'),
1840         'unflagtooltip' => get_string('clicktounflag', 'question'),
1841         'flaggedalt' => get_string('flagged', 'question'),
1842         'unflaggedalt' => get_string('notflagged', 'question'),
1843     );
1845     // Then generate the script tag.
1846     $script = '<script type="text/javascript">qengine_config = {' . "\n";
1847     foreach ($config as $property => $value) {
1848         $script .= "    $property: '" . addslashes_js($value) . "',\n";
1849     }
1850     $script .= "};</script>\n";
1851     return $script;
1854 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
1855 /**
1856  * Get the HTML that needs to be included in the head tag when the
1857  * questions in $questionlist are printed in the gives states.
1858  *
1859  * @param array $questionlist a list of questionids of the questions what will appear on this page.
1860  * @param array $questions an array of question objects, whose keys are question ids.
1861  *      Must contain all the questions in $questionlist
1862  * @param array $states an array of question state objects, whose keys are question ids.
1863  *      Must contain the state of all the questions in $questionlist
1864  *
1865  * @return string some HTML code that can go inside the head tag.
1866  */
1867 function get_html_head_contributions($questionlist, &$questions, &$states) {
1868     global $CFG, $QTYPES;
1870     // The question engine's own JavaScript.
1871     require_js(array('yui_yahoo','yui_event', 'yui_connection'));
1872     require_js($CFG->wwwroot . '/question/qengine.js');
1874     // An inline script to record various lang strings, etc. that qengine.js needs.
1875     $contributions = array(question_init_qenginejs_script());
1877     // Anything that questions on this page need.
1878     foreach ($questionlist as $questionid) {
1879         $question = $questions[$questionid];
1880         $contributions = array_merge($contributions,
1881                 $QTYPES[$question->qtype]->get_html_head_contributions(
1882                 $question, $states[$questionid]));
1883     }
1885     return implode("\n", array_unique($contributions));
1888 /**
1889  * Prints a question
1890  *
1891  * Simply calls the question type specific print_question() method.
1892  * @param object $question The question to be rendered.
1893  * @param object $state    The state to render the question in.
1894  * @param integer $number  The number for this question.
1895  * @param object $cmoptions  The options specified by the course module
1896  * @param object $options  An object specifying the rendering options.
1897  */
1898 function print_question(&$question, &$state, $number, $cmoptions, $options=null) {
1899     global $QTYPES;
1901     $QTYPES[$question->qtype]->print_question($question, $state, $number,
1902      $cmoptions, $options);
1904 /**
1905  * Saves question options
1906  *
1907  * Simply calls the question type specific save_question_options() method.
1908  */
1909 function save_question_options($question) {
1910     global $QTYPES;
1912     $QTYPES[$question->qtype]->save_question_options($question);
1915 /**
1916 * Gets all teacher stored answers for a given question
1918 * Simply calls the question type specific get_all_responses() method.
1919 */
1920 // ULPGC ecastro
1921 function get_question_responses($question, $state) {
1922     global $QTYPES;
1923     $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
1924     return $r;
1928 /**
1929 * Gets the response given by the user in a particular state
1931 * Simply calls the question type specific get_actual_response() method.
1932 */
1933 // ULPGC ecastro
1934 function get_question_actual_response($question, $state) {
1935     global $QTYPES;
1937     $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
1938     return $r;
1941 /**
1942 * TODO: document this
1943 */
1944 // ULPGc ecastro
1945 function get_question_fraction_grade($question, $state) {
1946     global $QTYPES;
1948     $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
1949     return $r;
1951 /**
1952 * @return integer grade out of 1 that a random guess by a student might score.
1953 */
1954 // ULPGc ecastro
1955 function question_get_random_guess_score($question) {
1956     global $QTYPES;
1957     
1958     $r = $QTYPES[$question->qtype]->get_random_guess_score($question);
1959     return $r;
1961 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
1963 /**
1964  * returns the categories with their names ordered following parent-child relationships
1965  * finally it tries to return pending categories (those being orphaned, whose parent is
1966  * incorrect) to avoid missing any category from original array.
1967  */
1968 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
1969     global $DB;
1971     $children = array();
1972     $keys = array_keys($categories);
1974     foreach ($keys as $key) {
1975         if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
1976             $children[$key] = $categories[$key];
1977             $categories[$key]->processed = true;
1978             $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1979         }
1980     }
1981     //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
1982     if ($level == 1) {
1983         foreach ($keys as $key) {
1984             //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
1985             if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories', array('course'=>$categories[$key]->course, 'id'=>$categories[$key]->parent))) {
1986                 $children[$key] = $categories[$key];
1987                 $categories[$key]->processed = true;
1988                 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
1989             }
1990         }
1991     }
1992     return $children;
1995 /**
1996  * Private method, only for the use of add_indented_names().
1997  *
1998  * Recursively adds an indentedname field to each category, starting with the category
1999  * with id $id, and dealing with that category and all its children, and
2000  * return a new array, with those categories in the right order.
2001  *
2002  * @param array $categories an array of categories which has had childids
2003  *          fields added by flatten_category_tree(). Passed by reference for
2004  *          performance only. It is not modfied.
2005  * @param int $id the category to start the indenting process from.
2006  * @param int $depth the indent depth. Used in recursive calls.
2007  * @return array a new array of categories, in the right order for the tree.
2008  */
2009 function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
2011     // Indent the name of this category.
2012     $newcategories = array();
2013     $newcategories[$id] = $categories[$id];
2014     $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
2016     // Recursively indent the children.
2017     foreach ($categories[$id]->childids as $childid) {
2018         if ($childid != $nochildrenof){
2019             $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
2020         }
2021     }
2023     // Remove the childids array that were temporarily added.
2024     unset($newcategories[$id]->childids);
2026     return $newcategories;
2029 /**
2030  * Format categories into an indented list reflecting the tree structure.
2031  *
2032  * @param array $categories An array of category objects, for example from the.
2033  * @return array The formatted list of categories.
2034  */
2035 function add_indented_names($categories, $nochildrenof = -1) {
2037     // Add an array to each category to hold the child category ids. This array will be removed
2038     // again by flatten_category_tree(). It should not be used outside these two functions.
2039     foreach (array_keys($categories) as $id) {
2040         $categories[$id]->childids = array();
2041     }
2043     // Build the tree structure, and record which categories are top-level.
2044     // We have to be careful, because the categories array may include published
2045     // categories from other courses, but not their parents.
2046     $toplevelcategoryids = array();
2047     foreach (array_keys($categories) as $id) {
2048         if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
2049             $categories[$categories[$id]->parent]->childids[] = $id;
2050         } else {
2051             $toplevelcategoryids[] = $id;
2052         }
2053     }
2055     // Flatten the tree to and add the indents.
2056     $newcategories = array();
2057     foreach ($toplevelcategoryids as $id) {
2058         $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
2059     }
2061     return $newcategories;
2064 /**
2065  * Output a select menu of question categories.
2066  *
2067  * Categories from this course and (optionally) published categories from other courses
2068  * are included. Optionally, only categories the current user may edit can be included.
2069  *
2070  * @param integer $courseid the id of the course to get the categories for.
2071  * @param integer $published if true, include publised categories from other courses.
2072  * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
2073  * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
2074  */
2075 function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
2076     $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
2077     if ($selected) {
2078         $nothing = '';
2079     } else {
2080         $nothing = 'choose';
2081     }
2082     choose_from_menu_nested($categoriesarray, 'category', $selected, $nothing);
2085 /**
2086 * Gets the default category in the most specific context.
2087 * If no categories exist yet then default ones are created in all contexts.
2089 * @param array $contexts  The context objects for this context and all parent contexts.
2090 * @return object The default category - the category in the course context
2091 */
2092 function question_make_default_categories($contexts) {
2093     global $DB;
2095     $toreturn = null;
2096     // If it already exists, just return it.
2097     foreach ($contexts as $key => $context) {
2098         if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))){
2099             // Otherwise, we need to make one
2100             $category = new stdClass;
2101             $contextname = print_context_name($context, false, true);
2102             $category->name = get_string('defaultfor', 'question', $contextname);
2103             $category->info = get_string('defaultinfofor', 'question', $contextname);
2104             $category->contextid = $context->id;
2105             $category->parent = 0;
2106             $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
2107             $category->stamp = make_unique_id_code();
2108             if (!$category->id = $DB->insert_record('question_categories', $category)) {
2109                 print_error('cannotcreatedefaultcat', '', '', print_context_name($context));
2110             }
2111         } else {
2112             $category = $DB->get_record('question_categories', array('contextid' => $context->id),'*',true);
2113         }
2115         if ($context->contextlevel == CONTEXT_COURSE){
2116             $toreturn = clone($category);
2117         }
2118     }
2121     return $toreturn;
2124 /**
2125  * Get all the category objects, including a count of the number of questions in that category,
2126  * for all the categories in the lists $contexts.
2127  *
2128  * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
2129  * @param string $sortorder used as the ORDER BY clause in the select statement.
2130  * @return array of category objects.
2131  */
2132 function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
2133     global $DB;
2134     return $DB->get_records_sql("
2135             SELECT *, (SELECT count(1) FROM {question} q
2136                         WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
2137               FROM {question_categories} c
2138              WHERE c.contextid IN ($contexts)
2139           ORDER BY $sortorder");
2142 /**
2143  * Output an array of question categories.
2144  */
2145 function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
2146     global $CFG;
2147     $pcontexts = array();
2148     foreach($contexts as $context){
2149         $pcontexts[] = $context->id;
2150     }
2151     $contextslist = join($pcontexts, ', ');
2153     $categories = get_categories_for_contexts($contextslist);
2155     $categories = question_add_context_in_key($categories);
2157     if ($top){
2158         $categories = question_add_tops($categories, $pcontexts);
2159     }
2160     $categories = add_indented_names($categories, $nochildrenof);
2162     //sort cats out into different contexts
2163     $categoriesarray = array();
2164     foreach ($pcontexts as $pcontext){
2165         $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
2166         foreach ($categories as $category) {
2167             if ($category->contextid == $pcontext){
2168                 $cid = $category->id;
2169                 if ($currentcat!= $cid || $currentcat==0) {
2170                     $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
2171                     $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
2172                 }
2173             }
2174         }
2175     }
2176     if ($popupform){
2177         $popupcats = array();
2178         foreach ($categoriesarray as $contextstring => $optgroup){
2179             $popupcats[] = '--'.$contextstring;
2180             $popupcats = array_merge($popupcats, $optgroup);
2181             $popupcats[] = '--';
2182         }
2183         return $popupcats;
2184     } else {
2185         return $categoriesarray;
2186     }
2189 function question_add_context_in_key($categories){
2190     $newcatarray = array();
2191     foreach ($categories as $id => $category) {
2192         $category->parent = "$category->parent,$category->contextid";
2193         $category->id = "$category->id,$category->contextid";
2194         $newcatarray["$id,$category->contextid"] = $category;
2195     }
2196     return $newcatarray;
2198 function question_add_tops($categories, $pcontexts){
2199     $topcats = array();
2200     foreach ($pcontexts as $context){
2201         $newcat = new object();
2202         $newcat->id = "0,$context";
2203         $newcat->name = get_string('top');
2204         $newcat->parent = -1;
2205         $newcat->contextid = $context;
2206         $topcats["0,$context"] = $newcat;
2207     }
2208     //put topcats in at beginning of array - they'll be sorted into different contexts later.
2209     return array_merge($topcats, $categories);
2212 /**
2213  * Returns a comma separated list of ids of the category and all subcategories
2214  */
2215 function question_categorylist($categoryid) {
2216     global $DB;
2218     // returns a comma separated list of ids of the category and all subcategories
2219     $categorylist = $categoryid;
2220     if ($subcategories = $DB->get_records('question_categories', array('parent'=>$categoryid), 'sortorder ASC', 'id, id')) {
2221         foreach ($subcategories as $subcategory) {
2222             $categorylist .= ','. question_categorylist($subcategory->id);
2223         }
2224     }
2225     return $categorylist;
2231 //===========================
2232 // Import/Export Functions
2233 //===========================
2235 /**
2236  * Get list of available import or export formats
2237  * @param string $type 'import' if import list, otherwise export list assumed
2238  * @return array sorted list of import/export formats available
2239 **/
2240 function get_import_export_formats( $type ) {
2242     global $CFG;
2243     $fileformats = get_list_of_plugins("question/format");
2245     $fileformatname=array();
2246     require_once( "{$CFG->dirroot}/question/format.php" );
2247     foreach ($fileformats as $key => $fileformat) {
2248         $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php";
2249         if (file_exists( $format_file ) ) {
2250             require_once( $format_file );
2251         }
2252         else {
2253             continue;
2254         }
2255         $classname = "qformat_$fileformat";
2256         $format_class = new $classname();
2257         if ($type=='import') {
2258             $provided = $format_class->provide_import();
2259         }
2260         else {
2261             $provided = $format_class->provide_export();
2262         }
2263         if ($provided) {
2264             $formatname = get_string($fileformat, 'quiz');
2265             if ($formatname == "[[$fileformat]]") {
2266                 $formatname = $fileformat;  // Just use the raw folder name
2267             }
2268             $fileformatnames[$fileformat] = $formatname;
2269         }
2270     }
2271     natcasesort($fileformatnames);
2273     return $fileformatnames;
2277 /**
2278 * Create default export filename
2280 * @return string   default export filename
2281 * @param object $course
2282 * @param object $category
2283 */
2284 function default_export_filename($course,$category) {
2285     //Take off some characters in the filename !!
2286     $takeoff = array(" ", ":", "/", "\\", "|");
2287     $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
2288     //If non-translated, use "export"
2289     if (substr($export_word,0,1) == "[") {
2290         $export_word= "export";
2291     }
2293     //Calculate the date format string
2294     $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
2295     //If non-translated, use "%Y%m%d-%H%M"
2296     if (substr($export_date_format,0,1) == "[") {
2297         $export_date_format = "%%Y%%m%%d-%%H%%M";
2298     }
2300     //Calculate the shortname
2301     $export_shortname = clean_filename($course->shortname);
2302     if (empty($export_shortname) or $export_shortname == '_' ) {
2303         $export_shortname = $course->id;
2304     }
2306     //Calculate the category name
2307     $export_categoryname = clean_filename($category->name);
2309     //Calculate the final export filename
2310     //The export word
2311     $export_name = $export_word."-";
2312     //The shortname
2313     $export_name .= moodle_strtolower($export_shortname)."-";
2314     //The category name
2315     $export_name .= moodle_strtolower($export_categoryname)."-";
2316     //The date format
2317     $export_name .= userdate(time(),$export_date_format,99,false);
2318     //Extension is supplied by format later.
2320     return $export_name;
2322 class context_to_string_translator{
2323     /**
2324      * @var array used to translate between contextids and strings for this context.
2325      */
2326     var $contexttostringarray = array();
2328     function context_to_string_translator($contexts){
2329         $this->generate_context_to_string_array($contexts);
2330     }
2332     function context_to_string($contextid){
2333         return $this->contexttostringarray[$contextid];
2334     }
2336     function string_to_context($contextname){
2337         $contextid = array_search($contextname, $this->contexttostringarray);
2338         return $contextid;
2339     }
2341     function generate_context_to_string_array($contexts){
2342         if (!$this->contexttostringarray){
2343             $catno = 1;
2344             foreach ($contexts as $context){
2345                 switch  ($context->contextlevel){
2346                     case CONTEXT_MODULE :
2347                         $contextstring = 'module';
2348                         break;
2349                     case CONTEXT_COURSE :
2350                         $contextstring = 'course';
2351                         break;
2352                     case CONTEXT_COURSECAT :
2353                         $contextstring = "cat$catno";
2354                         $catno++;
2355                         break;
2356                     case CONTEXT_SYSTEM :
2357                         $contextstring = 'system';
2358                         break;
2359                 }
2360                 $this->contexttostringarray[$context->id] = $contextstring;
2361             }
2362         }
2363     }
2368 /**
2369  * Check capability on category
2370  * @param mixed $question object or id
2371  * @param string $cap 'add', 'edit', 'view', 'use', 'move'
2372  * @param integer $cachecat useful to cache all question records in a category
2373  * @return boolean this user has the capability $cap for this question $question?
2374  */
2375 function question_has_capability_on($question, $cap, $cachecat = -1){
2376     global $USER, $DB;
2378     // nicolasconnault@gmail.com In some cases I get $question === false. Since no such object exists, it can't be deleted, we can safely return true
2379     if ($question === false) {
2380         return true;
2381     }
2383     // these are capabilities on existing questions capabilties are
2384     //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
2385     $question_questioncaps = array('edit', 'view', 'use', 'move');
2386     static $questions = array();
2387     static $categories = array();
2388     static $cachedcat = array();
2389     if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){
2390         $questions += $DB->get_records('question', array('category'=>$cachecat));
2391         $cachedcat[] = $cachecat;
2392     }
2393     if (!is_object($question)){
2394         if (!isset($questions[$question])){
2395             if (!$questions[$question] = $DB->get_record('question', array('id'=>$question))) {
2396                 print_error('questiondoesnotexist', 'question');
2397             }
2398         }
2399         $question = $questions[$question];
2400     }
2401     if (!isset($categories[$question->category])){
2402         if (!$categories[$question->category] = $DB->get_record('question_categories', array('id'=>$question->category))) {
2403             print_error('invalidcategory', 'quiz');
2404         }
2405     }
2406     $category = $categories[$question->category];
2408     if (array_search($cap, $question_questioncaps)!== FALSE){
2409         if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){
2410             if ($question->createdby == $USER->id){
2411                 return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid));
2412             } else {
2413                 return false;
2414             }
2415         } else {
2416             return true;
2417         }
2418     } else {
2419         return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid));
2420     }
2424 /**
2425  * Require capability on question.
2426  */
2427 function question_require_capability_on($question, $cap){
2428     if (!question_has_capability_on($question, $cap)){
2429         print_error('nopermissions', '', '', $cap);
2430     }
2431     return true;
2434 function question_file_links_base_url($courseid){
2435     global $CFG;
2436     $baseurl = preg_quote("$CFG->wwwroot/file.php", '!');
2437     $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not
2438                                      //be using slasharguments, accept either
2439     $baseurl .= "/$courseid/";//course directory
2440     return $baseurl;
2443 /*
2444  * Find all course / site files linked to in a piece of html.
2445  * @param string html the html to search
2446  * @param int course search for files for courseid course or set to siteid for
2447  *              finding site files.
2448  * @return array files with keys being files.
2449  */
2450 function question_find_file_links_from_html($html, $courseid){
2451     global $CFG;
2452     $baseurl = question_file_links_base_url($courseid);
2453     $searchfor = '!'.
2454                    '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'.
2455                    '|'.
2456                    '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''.
2457                   '!i';
2458     $matches = array();
2459     $no = preg_match_all($searchfor, $html, $matches);
2460     if ($no){
2461         $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements
2462         //remove any links that point somewhere they shouldn't
2463         foreach (array_keys($rawurls) as $rawurlkey){
2464             if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){
2465                 unset($rawurls[$rawurlkey]);
2466             } else {
2467                 $rawurls[$rawurlkey] = $cleanedurl;
2468             }
2470         }
2471         $urls = array_flip($rawurls);// array_flip removes duplicate files
2472                                             // and when we merge arrays will continue to automatically remove duplicates
2473     } else {
2474         $urls = array();
2475     }
2476     return $urls;
2479 /**
2480  * Check that url doesn't point anywhere it shouldn't
2481  *
2482  * @param $url string relative url within course files directory
2483  * @return mixed boolean false if not OK or cleaned URL as string if OK
2484  */
2485 function question_url_check($url){
2486     global $CFG;
2487     if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) ||
2488             (substr(strtolower($url), 0, 10) == 'backupdata')){
2489         return false;
2490     } else {
2491         return clean_param($url, PARAM_PATH);
2492     }
2495 /**
2496  * Find all course / site files linked to in a piece of html.
2497  * @param string html the html to search
2498  * @param int course search for files for courseid course or set to siteid for
2499  *              finding site files.
2500  * @return array files with keys being files.
2501  */
2502 function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){
2503     global $CFG;
2504     require_once($CFG->libdir .'/filelib.php');
2505     $tourl = get_file_url("$tocourseid/$destination");
2506     $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!');
2507     $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i',
2508                    '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i');
2509     $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html);
2510     if ($newhtml != $html){
2511         $changed = true;
2512     }
2513     return $newhtml;
2516 function get_filesdir_from_context($context){
2517     global $DB;
2519     switch ($context->contextlevel){
2520         case CONTEXT_COURSE :
2521             $courseid = $context->instanceid;
2522             break;
2523         case CONTEXT_MODULE :
2524             $courseid = $DB->get_field('course_modules', 'course', array('id'=>$context->instanceid));
2525             break;
2526         case CONTEXT_COURSECAT :
2527         case CONTEXT_SYSTEM :
2528             $courseid = SITEID;
2529             break;
2530         default :
2531             print_error('invalidcontext');
2532     }
2533     return $courseid;
2535 /**
2536  * Get the real state - the correct question id and answer - for a random
2537  * question.
2538  * @param object $state with property answer.
2539  * @return mixed return integer real question id or false if there was an
2540  * error..
2541  */
2542 function question_get_real_state($state){
2543     $realstate = clone($state);
2544     $matches = array();
2545     if (!preg_match('|^random([0-9]+)-(.+)|', $state->answer, $matches)){
2546         notify(get_string('errorrandom', 'quiz_statistics'));
2547         return false;
2548     } else {
2549         $realstate->question = $matches[1];
2550         $realstate->answer = $matches[2];
2551         return $realstate;
2552     }
2555 /**
2556  * Update the flagged state of a particular question session.
2557  *
2558  * @param integer $sessionid question_session id.
2559  * @param boolean $newstate the new state for the flag.
2560  * @return boolean success or failure.
2561  */
2562 function question_update_flag($sessionid, $newstate) {
2563     global $DB;
2564     return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid));
2567 /**
2568  * @param integer $attemptid the question_attempt id.
2569  * @param integer $questionid the question id.
2570  * @param integer $sessionid the question_session id.
2571  * @param object $user a user, or null to use $USER.
2572  * @return string that needs to be sent to question/toggleflag.php for it to work.
2573  */
2574 function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) {
2575     if (is_null($user)) {
2576         global $USER;
2577         $user = $USER;
2578     }
2579     return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid);
2582 ?>