MDL-16094 File storage conversion Quiz and Questions
[moodle.git] / lib / questionlib.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Code for handling and processing questions
20  *
21  * This is code that is module independent, i.e., can be used by any module that
22  * uses questions, like quiz, lesson, ..
23  * This script also loads the questiontype classes
24  * Code for handling the editing of questions is in {@link question/editlib.php}
25  *
26  * TODO: separate those functions which form part of the API
27  *       from the helper functions.
28  *
29  * Major Contributors
30  *     - Alex Smith, Julian Sedding and Gustav Delius {@link http://maths.york.ac.uk/serving_maths}
31  *
32  * @package    core
33  * @subpackage question
34  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
35  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36  */
38 defined('MOODLE_INTERNAL') || die();
40 /// CONSTANTS ///////////////////////////////////
42 /**#@+
43  * The different types of events that can create question states
44  */
45 define('QUESTION_EVENTOPEN', '0');      // The state was created by Moodle
46 define('QUESTION_EVENTNAVIGATE', '1');  // The responses were saved because the student navigated to another page (this is not currently used)
47 define('QUESTION_EVENTSAVE', '2');      // The student has requested that the responses should be saved but not submitted or validated
48 define('QUESTION_EVENTGRADE', '3');     // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
49 define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
50 define('QUESTION_EVENTVALIDATE', '5');  // The student has requested a validation. This causes the responses to be saved as well, but not graded.
51 define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
52 define('QUESTION_EVENTSUBMIT', '7');    // The student response has been submitted but it has not yet been marked
53 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.
54 define('QUESTION_EVENTMANUALGRADE', '9');   // Grade was entered by teacher
56 define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','.
57                     QUESTION_EVENTCLOSEANDGRADE.','.
58                     QUESTION_EVENTMANUALGRADE);
61 define('QUESTION_EVENTS_CLOSED', QUESTION_EVENTCLOSE.','.
62                     QUESTION_EVENTCLOSEANDGRADE.','.
63                     QUESTION_EVENTMANUALGRADE);
65 define('QUESTION_EVENTS_CLOSED_OR_GRADED', QUESTION_EVENTGRADE.','.
66                     QUESTION_EVENTS_CLOSED);
68 /**#@-*/
70 /**#@+
71  * The core question types.
72  */
73 define("SHORTANSWER",   "shortanswer");
74 define("TRUEFALSE",     "truefalse");
75 define("MULTICHOICE",   "multichoice");
76 define("RANDOM",        "random");
77 define("MATCH",         "match");
78 define("RANDOMSAMATCH", "randomsamatch");
79 define("DESCRIPTION",   "description");
80 define("NUMERICAL",     "numerical");
81 define("MULTIANSWER",   "multianswer");
82 define("CALCULATED",    "calculated");
83 define("ESSAY",         "essay");
84 /**#@-*/
86 /**
87  * Constant determines the number of answer boxes supplied in the editing
88  * form for multiple choice and similar question types.
89  */
90 define("QUESTION_NUMANS", "10");
92 /**
93  * Constant determines the number of answer boxes supplied in the editing
94  * form for multiple choice and similar question types to start with, with
95  * the option of adding QUESTION_NUMANS_ADD more answers.
96  */
97 define("QUESTION_NUMANS_START", 3);
99 /**
100  * Constant determines the number of answer boxes to add in the editing
101  * form for multiple choice and similar question types when the user presses
102  * 'add form fields button'.
103  */
104 define("QUESTION_NUMANS_ADD", 3);
106 /**
107  * The options used when popping up a question preview window in Javascript.
108  */
109 define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes&resizable=yes&width=700&height=540');
111 /**#@+
112  * Option flags for ->optionflags
113  * The options are read out via bitwise operation using these constants
114  */
115 /**
116  * Whether the questions is to be run in adaptive mode. If this is not set then
117  * a question closes immediately after the first submission of responses. This
118  * is how question is Moodle always worked before version 1.5
119  */
120 define('QUESTION_ADAPTIVE', 1);
121 /**#@-*/
123 /**#@+
124  * Options used in forms that move files.
125  */
126 define('QUESTION_FILENOTHINGSELECTED', 0);
127 define('QUESTION_FILEDONOTHING', 1);
128 define('QUESTION_FILECOPY', 2);
129 define('QUESTION_FILEMOVE', 3);
130 define('QUESTION_FILEMOVELINKSONLY', 4);
131 /**#@-*/
133 /**#@+
134  * Options for whether flags are shown/editable when rendering questions.
135  */
136 define('QUESTION_FLAGSHIDDEN', 0);
137 define('QUESTION_FLAGSSHOWN', 1);
138 define('QUESTION_FLAGSEDITABLE', 2);
139 /**#@-*/
141 /**
142  * GLOBAL VARAIBLES
143  * @global array $QTYPES
144  * @name $QTYPES
145  */
146 global $QTYPES;
147 /**
148  * Array holding question type objects. Initialised via calls to
149  * question_register_questiontype as the question type classes are included.
150  */
151 $QTYPES = array();
153 /**
154  * Add a new question type to the various global arrays above.
155  *
156  * @global object
157  * @param object $qtype An instance of the new question type class.
158  */
159 function question_register_questiontype($qtype) {
160     global $QTYPES;
162     $name = $qtype->name();
163     $QTYPES[$name] = $qtype;
166 require_once("$CFG->dirroot/question/type/questiontype.php");
168 // Load the questiontype.php file for each question type
169 // These files in turn call question_register_questiontype()
170 // with a new instance of each qtype class.
171 $qtypenames = get_plugin_list('qtype');
172 foreach($qtypenames as $qtypename => $qdir) {
173     // Instanciates all plug-in question types
174     $qtypefilepath= "$qdir/questiontype.php";
176     // echo "Loading $qtypename<br/>"; // Uncomment for debugging
177     if (is_readable($qtypefilepath)) {
178         require_once($qtypefilepath);
179     }
182 /**
183  * An array of question type names translated to the user's language, suitable for use when
184  * creating a drop-down menu of options.
185  *
186  * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
187  * The array returned will only hold the names of all the question types that the user should
188  * be able to create directly. Some internal question types like random questions are excluded.
189  *
190  * @global object
191  * @return array an array of question type names translated to the user's language.
192  */
193 function question_type_menu() {
194     global $QTYPES;
195     static $menuoptions = null;
196     if (is_null($menuoptions)) {
197         $config = get_config('question');
198         $menuoptions = array();
199         foreach ($QTYPES as $name => $qtype) {
200             // Get the name if this qtype is enabled.
201             $menuname = $qtype->menu_name();
202             $enabledvar = $name . '_disabled';
203             if ($menuname && !isset($config->$enabledvar)) {
204                 $menuoptions[$name] = $menuname;
205             }
206         }
208         $menuoptions = question_sort_qtype_array($menuoptions, $config);
209     }
210     return $menuoptions;
213 /**
214  * Sort an array of question type names according to the question type sort order stored in
215  * config_plugins. Entries for which there is no xxx_sortorder defined will go
216  * at the end, sorted according to asort($inarray, SORT_LOCALE_STRING).
217  * @param $inarray an array $qtype => $QTYPES[$qtype]->local_name().
218  * @param $config get_config('question'), if you happen to have it around, to save one DB query.
219  * @return array the sorted version of $inarray.
220  */
221 function question_sort_qtype_array($inarray, $config = null) {
222     if (is_null($config)) {
223         $config = get_config('question');
224     }
226     $sortorder = array();
227     foreach ($inarray as $name => $notused) {
228         $sortvar = $name . '_sortorder';
229         if (isset($config->$sortvar)) {
230             $sortorder[$config->$sortvar] = $name;
231         }
232     }
234     ksort($sortorder);
235     $outarray = array();
236     foreach ($sortorder as $name) {
237         $outarray[$name] = $inarray[$name];
238         unset($inarray[$name]);
239     }
240     asort($inarray, SORT_LOCALE_STRING);
241     return array_merge($outarray, $inarray);
244 /**
245  * Move one question type in a list of question types. If you try to move one element
246  * off of the end, nothing will change.
247  *
248  * @param array $sortedqtypes An array $qtype => anything.
249  * @param string $tomove one of the keys from $sortedqtypes
250  * @param integer $direction +1 or -1
251  * @return array an array $index => $qtype, with $index from 0 to n in order, and
252  *      the $qtypes in the same order as $sortedqtypes, except that $tomove will
253  *      have been moved one place.
254  */
255 function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
256     $neworder = array_keys($sortedqtypes);
257     // Find the element to move.
258     $key = array_search($tomove, $neworder);
259     if ($key === false) {
260         return $neworder;
261     }
262     // Work out the other index.
263     $otherkey = $key + $direction;
264     if (!isset($neworder[$otherkey])) {
265         return $neworder;
266     }
267     // Do the swap.
268     $swap = $neworder[$otherkey];
269     $neworder[$otherkey] = $neworder[$key];
270     $neworder[$key] = $swap;
271     return $neworder;
274 /**
275  * Save a new question type order to the config_plugins table.
276  * @global object
277  * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
278  * @param $config get_config('question'), if you happen to have it around, to save one DB query.
279  */
280 function question_save_qtype_order($neworder, $config = null) {
281     global $DB;
283     if (is_null($config)) {
284         $config = get_config('question');
285     }
287     foreach ($neworder as $index => $qtype) {
288         $sortvar = $qtype . '_sortorder';
289         if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
290             set_config($sortvar, $index + 1, 'question');
291         }
292     }
295 /// OTHER CLASSES /////////////////////////////////////////////////////////
297 /**
298  * This holds the options that are set by the course module
299  *
300  * @package moodlecore
301  * @subpackage question
302  * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
303  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
304  */
305 class cmoptions {
306     /**
307     * Whether a new attempt should be based on the previous one. If true
308     * then a new attempt will start in a state where all responses are set
309     * to the last responses from the previous attempt.
310     */
311     var $attemptonlast = false;
313     /**
314     * Various option flags. The flags are accessed via bitwise operations
315     * using the constants defined in the CONSTANTS section above.
316     */
317     var $optionflags = QUESTION_ADAPTIVE;
319     /**
320     * Determines whether in the calculation of the score for a question
321     * penalties for earlier wrong responses within the same attempt will
322     * be subtracted.
323     */
324     var $penaltyscheme = true;
326     /**
327     * The maximum time the user is allowed to answer the questions withing
328     * an attempt. This is measured in minutes so needs to be multiplied by
329     * 60 before compared to timestamps. If set to 0 no timelimit will be applied
330     */
331     var $timelimit = 0;
333     /**
334     * Timestamp for the closing time. Responses submitted after this time will
335     * be saved but no credit will be given for them.
336     */
337     var $timeclose = 9999999999;
339     /**
340     * The id of the course from withing which the question is currently being used
341     */
342     var $course = SITEID;
344     /**
345     * Whether the answers in a multiple choice question should be randomly
346     * shuffled when a new attempt is started.
347     */
348     var $shuffleanswers = true;
350     /**
351     * The number of decimals to be shown when scores are printed
352     */
353     var $decimalpoints = 2;
357 /// FUNCTIONS //////////////////////////////////////////////////////
359 /**
360  * Returns an array of names of activity modules that use this question
361  *
362  * @global object
363  * @global object
364  * @param object $questionid
365  * @return array of strings
366  */
367 function question_list_instances($questionid) {
368     global $CFG, $DB;
369     $instances = array();
370     $modules = $DB->get_records('modules');
371     foreach ($modules as $module) {
372         $fullmod = $CFG->dirroot . '/mod/' . $module->name;
373         if (file_exists($fullmod . '/lib.php')) {
374             include_once($fullmod . '/lib.php');
375             $fn = $module->name.'_question_list_instances';
376             if (function_exists($fn)) {
377                 $instances = $instances + $fn($questionid);
378             }
379         }
380     }
381     return $instances;
384 /**
385  * Determine whether there arey any questions belonging to this context, that is whether any of its
386  * question categories contain any questions. This will return true even if all the questions are
387  * hidden.
388  *
389  * @global object
390  * @param mixed $context either a context object, or a context id.
391  * @return boolean whether any of the question categories beloning to this context have
392  *         any questions in them.
393  */
394 function question_context_has_any_questions($context) {
395     global $DB;
396     if (is_object($context)) {
397         $contextid = $context->id;
398     } else if (is_numeric($context)) {
399         $contextid = $context;
400     } else {
401         print_error('invalidcontextinhasanyquestions', 'question');
402     }
403     return $DB->record_exists_sql("SELECT *
404                                      FROM {question} q
405                                      JOIN {question_categories} qc ON qc.id = q.category
406                                     WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
409 /**
410  * Returns list of 'allowed' grades for grade selection
411  * formatted suitably for dropdown box function
412  * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
413  */
414 function get_grade_options() {
415     // define basic array of grades. This list comprises all fractions of the form:
416     // a. p/q for q <= 6, 0 <= p <= q
417     // b. p/10 for 0 <= p <= 10
418     // c. 1/q for 1 <= q <= 10
419     // d. 1/20
420     $grades = array(
421         1.0000000,
422         0.9000000,
423         0.8333333,
424         0.8000000,
425         0.7500000,
426         0.7000000,
427         0.6666667,
428         0.6000000,
429         0.5000000,
430         0.4000000,
431         0.3333333,
432         0.3000000,
433         0.2500000,
434         0.2000000,
435         0.1666667,
436         0.1428571,
437         0.1250000,
438         0.1111111,
439         0.1000000,
440         0.0500000,
441         0.0000000);
443     // iterate through grades generating full range of options
444     $gradeoptionsfull = array();
445     $gradeoptions = array();
446     foreach ($grades as $grade) {
447         $percentage = 100 * $grade;
448         $neggrade = -$grade;
449         $gradeoptions["$grade"] = "$percentage %";
450         $gradeoptionsfull["$grade"] = "$percentage %";
451         $gradeoptionsfull["$neggrade"] = -$percentage." %";
452     }
453     $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
455     // sort lists
456     arsort($gradeoptions, SORT_NUMERIC);
457     arsort($gradeoptionsfull, SORT_NUMERIC);
459     // construct return object
460     $grades = new stdClass;
461     $grades->gradeoptions = $gradeoptions;
462     $grades->gradeoptionsfull = $gradeoptionsfull;
464     return $grades;
467 /**
468  * match grade options
469  * if no match return error or match nearest
470  * @param array $gradeoptionsfull list of valid options
471  * @param int $grade grade to be tested
472  * @param string $matchgrades 'error' or 'nearest'
473  * @return mixed either 'fixed' value or false if erro
474  */
475 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
476     // if we just need an error...
477     if ($matchgrades=='error') {
478         foreach($gradeoptionsfull as $value => $option) {
479             // slightly fuzzy test, never check floats for equality :-)
480             if (abs($grade-$value)<0.00001) {
481                 return $grade;
482             }
483         }
484         // didn't find a match so that's an error
485         return false;
486     }
487     // work out nearest value
488     else if ($matchgrades=='nearest') {
489         $hownear = array();
490         foreach($gradeoptionsfull as $value => $option) {
491             if ($grade==$value) {
492                 return $grade;
493             }
494             $hownear[ $value ] = abs( $grade - $value );
495         }
496         // reverse sort list of deltas and grab the last (smallest)
497         asort( $hownear, SORT_NUMERIC );
498         reset( $hownear );
499         return key( $hownear );
500     }
501     else {
502         return false;
503     }
506 /**
507  * Tests whether a category is in use by any activity module
508  *
509  * @global object
510  * @return boolean
511  * @param integer $categoryid
512  * @param boolean $recursive Whether to examine category children recursively
513  */
514 function question_category_isused($categoryid, $recursive = false) {
515     global $DB;
517     //Look at each question in the category
518     if ($questions = $DB->get_records('question', array('category'=>$categoryid), '', 'id,qtype')) {
519         foreach ($questions as $question) {
520             if (count(question_list_instances($question->id))) {
521                 return true;
522             }
523         }
524     }
526     //Look under child categories recursively
527     if ($recursive) {
528         if ($children = $DB->get_records('question_categories', array('parent'=>$categoryid))) {
529             foreach ($children as $child) {
530                 if (question_category_isused($child->id, $recursive)) {
531                     return true;
532                 }
533             }
534         }
535     }
537     return false;
540 /**
541  * Deletes all data associated to an attempt from the database
542  *
543  * @global object
544  * @global object
545  * @param integer $attemptid The id of the attempt being deleted
546  */
547 function delete_attempt($attemptid) {
548     global $QTYPES, $DB;
550     $states = $DB->get_records('question_states', array('attempt'=>$attemptid));
551     if ($states) {
552         $stateslist = implode(',', array_keys($states));
554         // delete question-type specific data
555         foreach ($QTYPES as $qtype) {
556             $qtype->delete_states($stateslist);
557         }
558     }
560     // delete entries from all other question tables
561     // It is important that this is done only after calling the questiontype functions
562     $DB->delete_records("question_states", array("attempt"=>$attemptid));
563     $DB->delete_records("question_sessions", array("attemptid"=>$attemptid));
564     $DB->delete_records("question_attempts", array("id"=>$attemptid));
567 /**
568  * Deletes question and all associated data from the database
569  *
570  * It will not delete a question if it is used by an activity module
571  *
572  * @global object
573  * @global object
574  * @param object $question  The question being deleted
575  */
576 function delete_question($questionid) {
577     global $QTYPES, $DB;
579     if (!$question = $DB->get_record('question', array('id'=>$questionid))) {
580         // In some situations, for example if this was a child of a
581         // Cloze question that was previously deleted, the question may already
582         // have gone. In this case, just do nothing.
583         return;
584     }
586     // Do not delete a question if it is used by an activity module
587     if (count(question_list_instances($questionid))) {
588         return;
589     }
591     // delete questiontype-specific data
592     question_require_capability_on($question, 'edit');
593     if ($question) {
594         if (isset($QTYPES[$question->qtype])) {
595             $QTYPES[$question->qtype]->delete_question($questionid);
596         }
597     } else {
598         echo "Question with id $questionid does not exist.<br />";
599     }
601     if ($states = $DB->get_records('question_states', array('question'=>$questionid))) {
602         $stateslist = implode(',', array_keys($states));
604         // delete questiontype-specific data
605         foreach ($QTYPES as $qtype) {
606             $qtype->delete_states($stateslist);
607         }
608     }
610     // delete entries from all other question tables
611     // It is important that this is done only after calling the questiontype functions
612     $DB->delete_records("question_answers", array("question"=>$questionid));
613     $DB->delete_records("question_states", array("question"=>$questionid));
614     $DB->delete_records("question_sessions", array("questionid"=>$questionid));
616     // Now recursively delete all child questions
617     if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id,qtype')) {
618         foreach ($children as $child) {
619             if ($child->id != $questionid) {
620                 delete_question($child->id);
621             }
622         }
623     }
625     // Finally delete the question record itself
626     $DB->delete_records('question', array('id'=>$questionid));
628     return;
631 /**
632  * All question categories and their questions are deleted for this course.
633  *
634  * @global object
635  * @param object $mod an object representing the activity
636  * @param boolean $feedback to specify if the process must output a summary of its work
637  * @return boolean
638  */
639 function question_delete_course($course, $feedback=true) {
640     global $DB, $OUTPUT;
642     //To store feedback to be showed at the end of the process
643     $feedbackdata   = array();
645     //Cache some strings
646     $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
647     $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
648     $categoriescourse = $DB->get_records('question_categories', array('contextid'=>$coursecontext->id), 'parent', 'id, parent, name');
650     if ($categoriescourse) {
652         //Sort categories following their tree (parent-child) relationships
653         //this will make the feedback more readable
654         $categoriescourse = sort_categories_by_tree($categoriescourse);
656         foreach ($categoriescourse as $category) {
658             //Delete it completely (questions and category itself)
659             //deleting questions
660             if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
661                 foreach ($questions as $question) {
662                     delete_question($question->id);
663                 }
664                 $DB->delete_records("question", array("category"=>$category->id));
665             }
666             //delete the category
667             $DB->delete_records('question_categories', array('id'=>$category->id));
669             //Fill feedback
670             $feedbackdata[] = array($category->name, $strcatdeleted);
671         }
672         //Inform about changes performed if feedback is enabled
673         if ($feedback) {
674             $table = new html_table();
675             $table->head = array(get_string('category','quiz'), get_string('action'));
676             $table->data = $feedbackdata;
677             echo html_writer::table($table);
678         }
679     }
680     return true;
683 /**
684  * Category is about to be deleted,
685  * 1/ All question categories and their questions are deleted for this course category.
686  * 2/ All questions are moved to new category
687  *
688  * @global object
689  * @param object $category course category object
690  * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
691  * @param boolean $feedback to specify if the process must output a summary of its work
692  * @return boolean
693  */
694 function question_delete_course_category($category, $newcategory, $feedback=true) {
695     global $DB, $OUTPUT;
697     $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
698     if (empty($newcategory)) {
699         $feedbackdata   = array(); // To store feedback to be showed at the end of the process
700         $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
701         $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
703         // Loop over question categories.
704         if ($categories = $DB->get_records('question_categories', array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
705             foreach ($categories as $category) {
707                 // Deal with any questions in the category.
708                 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
710                     // Try to delete each question.
711                     foreach ($questions as $question) {
712                         delete_question($question->id);
713                     }
715                     // Check to see if there were any questions that were kept because they are
716                     // still in use somehow, even though quizzes in courses in this category will
717                     // already have been deteted. This could happen, for example, if questions are
718                     // added to a course, and then that course is moved to another category (MDL-14802).
719                     $questionids = $DB->get_records_menu('question', array('category'=>$category->id), '', 'id,1');
720                     if (!empty($questionids)) {
721                         if (!$rescueqcategory = question_save_from_deletion(implode(',', array_keys($questionids)),
722                                 get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
723                             return false;
724                        }
725                        $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
726                     }
727                 }
729                 // Now delete the category.
730                 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
731                     return false;
732                 }
733                 $feedbackdata[] = array($category->name, $strcatdeleted);
735             } // End loop over categories.
736         }
738         // Output feedback if requested.
739         if ($feedback and $feedbackdata) {
740             $table = new html_table();
741             $table->head = array(get_string('questioncategory','question'), get_string('action'));
742             $table->data = $feedbackdata;
743             echo html_writer::table($table);
744         }
746     } else {
747         // Move question categories ot the new context.
748         if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
749             return false;
750         }
751         if (!$DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid'=>$context->id))) {
752             return false;
753         }
754         if ($feedback) {
755             $a = new stdClass;
756             $a->oldplace = print_context_name($context);
757             $a->newplace = print_context_name($newcontext);
758             echo $OUTPUT->notification(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
759         }
760     }
762     return true;
765 /**
766  * Enter description here...
767  *
768  * @global object
769  * @param string $questionids list of questionids
770  * @param object $newcontext the context to create the saved category in.
771  * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
772  * @param object $newcategory
773  * @return mixed false on
774  */
775 function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
776     global $DB;
778     // Make a category in the parent context to move the questions to.
779     if (is_null($newcategory)) {
780         $newcategory = new object();
781         $newcategory->parent = 0;
782         $newcategory->contextid = $newcontextid;
783         $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
784         $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
785         $newcategory->sortorder = 999;
786         $newcategory->stamp = make_unique_id_code();
787         $newcategory->id = $DB->insert_record('question_categories', $newcategory);
788     }
790     // Move any remaining questions to the 'saved' category.
791     if (!question_move_questions_to_category($questionids, $newcategory->id)) {
792         return false;
793     }
794     return $newcategory;
797 /**
798  * All question categories and their questions are deleted for this activity.
799  *
800  * @global object
801  * @param object $cm the course module object representing the activity
802  * @param boolean $feedback to specify if the process must output a summary of its work
803  * @return boolean
804  */
805 function question_delete_activity($cm, $feedback=true) {
806     global $DB, $OUTPUT;
808     //To store feedback to be showed at the end of the process
809     $feedbackdata   = array();
811     //Cache some strings
812     $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
813     $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
814     if ($categoriesmods = $DB->get_records('question_categories', array('contextid'=>$modcontext->id), 'parent', 'id, parent, name')){
815         //Sort categories following their tree (parent-child) relationships
816         //this will make the feedback more readable
817         $categoriesmods = sort_categories_by_tree($categoriesmods);
819         foreach ($categoriesmods as $category) {
821             //Delete it completely (questions and category itself)
822             //deleting questions
823             if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
824                 foreach ($questions as $question) {
825                     delete_question($question->id);
826                 }
827                 $DB->delete_records("question", array("category"=>$category->id));
828             }
829             //delete the category
830             $DB->delete_records('question_categories', array('id'=>$category->id));
832             //Fill feedback
833             $feedbackdata[] = array($category->name, $strcatdeleted);
834         }
835         //Inform about changes performed if feedback is enabled
836         if ($feedback) {
837             $table = new html_table();
838             $table->head = array(get_string('category','quiz'), get_string('action'));
839             $table->data = $feedbackdata;
840             echo html_writer::table($table);
841         }
842     }
843     return true;
846 /**
847  * This function should be considered private to the question bank, it is called from
848  * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
849  * acutally moving questions and associated data. However, callers of this function also have to
850  * do other work, which is why you should not call this method directly from outside the questionbank.
851  *
852  * @global object
853  * @param string $questionids a comma-separated list of question ids.
854  * @param integer $newcategoryid the id of the category to move to.
855  */
856 function question_move_questions_to_category($questionids, $newcategoryid) {
857     global $DB, $QTYPES;
858     $result = true;
859     $ids = explode(',', $questionids);
860     foreach ($ids as $questionid) {
861         $questionid = (int)$questionid;
862         $params = array();
863         $params[] = $questionid;
864         $sql = 'SELECT q.*, c.id AS contextid, c.contextlevel, c.instanceid, c.path, c.depth
865                   FROM {question} q, {question_categories} qc, {context} c
866                  WHERE q.category=qc.id AND q.id=? AND qc.contextid=c.id';
867         $question = $DB->get_record_sql($sql, $params);
868         $category = $DB->get_record('question_categories', array('id'=>$newcategoryid));
869         // process files
870         $QTYPES[$question->qtype]->move_files($question, $category);
871     }
874     // Move the questions themselves.
875     $result = $result && $DB->set_field_select('question', 'category', $newcategoryid, "id IN ($questionids)");
877     // Move any subquestions belonging to them.
878     $result = $result && $DB->set_field_select('question', 'category', $newcategoryid, "parent IN ($questionids)");
880     // TODO Deal with datasets.
882     return $result;
885 /**
886  * Given a list of ids, load the basic information about a set of questions from the questions table.
887  * The $join and $extrafields arguments can be used together to pull in extra data.
888  * See, for example, the usage in mod/quiz/attemptlib.php, and
889  * read the code below to see how the SQL is assembled. Throws exceptions on error.
890  *
891  * @global object
892  * @global object
893  * @param array $questionids array of question ids.
894  * @param string $extrafields extra SQL code to be added to the query.
895  * @param string $join extra SQL code to be added to the query.
896  * @param array $extraparams values for any placeholders in $join.
897  * You are strongly recommended to use named placeholder.
898  *
899  * @return array partially complete question objects. You need to call get_question_options
900  * on them before they can be properly used.
901  */
902 function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) {
903     global $CFG, $DB;
904     if (empty($questionids)) {
905         return array();
906     }
907     if ($join) {
908         $join = ' JOIN '.$join;
909     }
910     if ($extrafields) {
911         $extrafields = ', ' . $extrafields;
912     }
913     list($questionidcondition, $params) = $DB->get_in_or_equal(
914             $questionids, SQL_PARAMS_NAMED, 'qid0000');
915     $sql = 'SELECT q.*' . $extrafields . ' FROM {question} q' . $join .
916             ' WHERE q.id ' . $questionidcondition;
918     // Load the questions
919     if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
920         return 'Could not load questions.';
921     }
923     foreach ($questions as $question) {
924         $question->_partiallyloaded = true;
925     }
927     // Note, a possible optimisation here would be to not load the TEXT fields
928     // (that is, questiontext and generalfeedback) here, and instead load them in
929     // question_load_questions. That would add one DB query, but reduce the amount
930     // of data transferred most of the time. I am not going to do this optimisation
931     // until it is shown to be worthwhile.
933     return $questions;
936 /**
937  * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
938  * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
939  * read the code below to see how the SQL is assembled. Throws exceptions on error.
940  *
941  * @param array $questionids array of question ids.
942  * @param string $extrafields extra SQL code to be added to the query.
943  * @param string $join extra SQL code to be added to the query.
944  * @param array $extraparams values for any placeholders in $join.
945  * You are strongly recommended to use named placeholder.
946  *
947  * @return array question objects.
948  */
949 function question_load_questions($questionids, $extrafields = '', $join = '') {
950     $questions = question_preload_questions($questionids, $extrafields, $join);
952     // Load the question type specific information
953     if (!get_question_options($questions)) {
954         return 'Could not load the question options';
955     }
957     return $questions;
960 /**
961  * Private function to factor common code out of get_question_options().
962  *
963  * @global object
964  * @global object
965  * @param object $question the question to tidy.
966  * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
967  * @return boolean true if successful, else false.
968  */
969 function _tidy_question(&$question, $loadtags = false) {
970     global $CFG, $QTYPES;
971     if (!array_key_exists($question->qtype, $QTYPES)) {
972         $question->qtype = 'missingtype';
973         $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
974     }
975     $question->name_prefix = question_make_name_prefix($question->id);
976     if ($success = $QTYPES[$question->qtype]->get_question_options($question)) {
977         if (isset($question->_partiallyloaded)) {
978             unset($question->_partiallyloaded);
979         }
980     }
981     if ($loadtags && !empty($CFG->usetags)) {
982         require_once($CFG->dirroot . '/tag/lib.php');
983         $question->tags = tag_get_tags_array('question', $question->id);
984     }
985     return $success;
988 /**
989  * Updates the question objects with question type specific
990  * information by calling {@link get_question_options()}
991  *
992  * Can be called either with an array of question objects or with a single
993  * question object.
994  *
995  * @param mixed $questions Either an array of question objects to be updated
996  *         or just a single question object
997  * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
998  * @return bool Indicates success or failure.
999  */
1000 function get_question_options(&$questions, $loadtags = false) {
1001     if (is_array($questions)) { // deal with an array of questions
1002         foreach ($questions as $i => $notused) {
1003             if (!_tidy_question($questions[$i], $loadtags)) {
1004                 return false;
1005             }
1006         }
1007         return true;
1008     } else { // deal with single question
1009         return _tidy_question($questions, $loadtags);
1010     }
1013 /**
1014  * Load the basic state information for
1015  *
1016  * @global object
1017  * @param integer $attemptid the attempt id to load the states for.
1018  * @return array an array of state data from the database, you will subsequently
1019  *      need to call question_load_states to get fully loaded states that can be
1020  *      used by the question types. The states here should be sufficient for
1021  *      basic tasks like rendering navigation.
1022  */
1023 function question_preload_states($attemptid) {
1024     global $DB;
1025     // Note, changes here probably also need to be reflected in
1026     // regrade_question_in_attempt and question_load_specific_state.
1028     // The questionid field must be listed first so that it is used as the
1029     // array index in the array returned by $DB->get_records_sql
1030     $statefields = 'n.questionid as question, s.id, s.attempt, ' .
1031             's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' .
1032             's.penalty, n.sumpenalty, n.manualcomment, n.flagged, n.id as questionsessionid';
1034     // Load the newest states for the questions
1035     $sql = "SELECT $statefields
1036               FROM {question_states} s, {question_sessions} n
1037              WHERE s.id = n.newest AND n.attemptid = ?";
1038     $states = $DB->get_records_sql($sql, array($attemptid));
1039     if (!$states) {
1040         return false;
1041     }
1043     // Load the newest graded states for the questions
1044     $sql = "SELECT $statefields
1045               FROM {question_states} s, {question_sessions} n
1046              WHERE s.id = n.newgraded AND n.attemptid = ?";
1047     $gradedstates = $DB->get_records_sql($sql, array($attemptid));
1049     // Hook the two together.
1050     foreach ($states as $questionid => $state) {
1051         $states[$questionid]->_partiallyloaded = true;
1052         if ($gradedstates[$questionid]) {
1053             $states[$questionid]->last_graded = $gradedstates[$questionid];
1054             $states[$questionid]->last_graded->_partiallyloaded = true;
1055         }
1056     }
1058     return $states;
1061 /**
1062  * Finish loading the question states that were extracted from the database with
1063  * question_preload_states, creating new states for any question where there
1064  * is not a state in the database.
1065  *
1066  * @global object
1067  * @global object
1068  * @param array $questions the questions to load state for.
1069  * @param array $states the partially loaded states this array is updated.
1070  * @param object $cmoptions options from the module we are loading the states for. E.g. $quiz.
1071  * @param object $attempt The attempt for which the question sessions are
1072  *      to be restored or created.
1073  * @param mixed either the id of a previous attempt, if this attmpt is
1074  *      building on a previous one, or false for a clean attempt.
1075  * @return true or false for success or failure.
1076  */
1077 function question_load_states(&$questions, &$states, $cmoptions, $attempt, $lastattemptid = false) {
1078     global $QTYPES, $DB;
1080     // loop through all questions and set the last_graded states
1081     foreach (array_keys($questions) as $qid) {
1082         if (isset($states[$qid])) {
1083             restore_question_state($questions[$qid], $states[$qid]);
1084             if (isset($states[$qid]->_partiallyloaded)) {
1085                 unset($states[$qid]->_partiallyloaded);
1086             }
1087             if (isset($states[$qid]->last_graded)) {
1088                 restore_question_state($questions[$qid], $states[$qid]->last_graded);
1089                 if (isset($states[$qid]->last_graded->_partiallyloaded)) {
1090                     unset($states[$qid]->last_graded->_partiallyloaded);
1091                 }
1092             } else {
1093                 $states[$qid]->last_graded = clone($states[$qid]);
1094             }
1095         } else {
1097             if ($lastattemptid) {
1098                 // If the new attempt is to be based on this previous attempt.
1099                 // Find the responses from the previous attempt and save them to the new session
1101                 // Load the last graded state for the question. Note, $statefields is
1102                 // the same as above, except that we don't want n.manualcomment.
1103                 $statefields = 'n.questionid as question, s.id, s.attempt, ' .
1104                         's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' .
1105                         's.penalty, n.sumpenalty';
1106                 $sql = "SELECT $statefields
1107                           FROM {question_states} s, {question_sessions} n
1108                          WHERE s.id = n.newest
1109                                AND n.attemptid = ?
1110                                AND n.questionid = ?";
1111                 if (!$laststate = $DB->get_record_sql($sql, array($lastattemptid, $qid))) {
1112                     // Only restore previous responses that have been graded
1113                     continue;
1114                 }
1115                 // Restore the state so that the responses will be restored
1116                 restore_question_state($questions[$qid], $laststate);
1117                 $states[$qid] = clone($laststate);
1118                 unset($states[$qid]->id);
1119             } else {
1120                 // create a new empty state
1121                 $states[$qid] = new object;
1122                 $states[$qid]->question = $qid;
1123                 $states[$qid]->responses = array('' => '');
1124                 $states[$qid]->raw_grade = 0;
1125             }
1127             // now fill/overide initial values
1128             $states[$qid]->attempt = $attempt->uniqueid;
1129             $states[$qid]->seq_number = 0;
1130             $states[$qid]->timestamp = $attempt->timestart;
1131             $states[$qid]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
1132             $states[$qid]->grade = 0;
1133             $states[$qid]->penalty = 0;
1134             $states[$qid]->sumpenalty = 0;
1135             $states[$qid]->manualcomment = '';
1136             $states[$qid]->flagged = 0;
1138             // Prevent further changes to the session from incrementing the
1139             // sequence number
1140             $states[$qid]->changed = true;
1142             if ($lastattemptid) {
1143                 // prepare the previous responses for new processing
1144                 $action = new stdClass;
1145                 $action->responses = $laststate->responses;
1146                 $action->timestamp = $laststate->timestamp;
1147                 $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
1149                 // Process these responses ...
1150                 question_process_responses($questions[$qid], $states[$qid], $action, $cmoptions, $attempt);
1152                 // Fix for Bug #5506: When each attempt is built on the last one,
1153                 // preserve the options from any previous attempt.
1154                 if ( isset($laststate->options) ) {
1155                     $states[$qid]->options = $laststate->options;
1156                 }
1157             } else {
1158                 // Create the empty question type specific information
1159                 if (!$QTYPES[$questions[$qid]->qtype]->create_session_and_responses(
1160                         $questions[$qid], $states[$qid], $cmoptions, $attempt)) {
1161                     return false;
1162                 }
1163             }
1164             $states[$qid]->last_graded = clone($states[$qid]);
1165         }
1166     }
1167     return true;
1170 /**
1171 * Loads the most recent state of each question session from the database
1172 * or create new one.
1174 * For each question the most recent session state for the current attempt
1175 * is loaded from the question_states table and the question type specific data and
1176 * responses are added by calling {@link restore_question_state()} which in turn
1177 * calls {@link restore_session_and_responses()} for each question.
1178 * If no states exist for the question instance an empty state object is
1179 * created representing the start of a session and empty question
1180 * type specific information and responses are created by calling
1181 * {@link create_session_and_responses()}.
1183 * @return array           An array of state objects representing the most recent
1184 *                         states of the question sessions.
1185 * @param array $questions The questions for which sessions are to be restored or
1186 *                         created.
1187 * @param object $cmoptions
1188 * @param object $attempt  The attempt for which the question sessions are
1189 *                         to be restored or created.
1190 * @param mixed either the id of a previous attempt, if this attmpt is
1191 *                         building on a previous one, or false for a clean attempt.
1192 */
1193 function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
1194     // Preload the states.
1195     $states = question_preload_states($attempt->uniqueid);
1196     if (!$states) {
1197         $states = array();
1198     }
1200     // Then finish the job.
1201     if (!question_load_states($questions, $states, $cmoptions, $attempt, $lastattemptid)) {
1202         return false;
1203     }
1205     return $states;
1208 /**
1209  * Load a particular previous state of a question.
1210  *
1211  * @global object
1212  * @param array $question The question to load the state for.
1213  * @param object $cmoptions Options from the specifica activity module, e.g. $quiz.
1214  * @param object $attempt The attempt for which the question sessions are to be loaded.
1215  * @param integer $stateid The id of a specific state of this question.
1216  * @return object the requested state. False on error.
1217  */
1218 function question_load_specific_state($question, $cmoptions, $attempt, $stateid) {
1219     global $DB;
1220     // Load specified states for the question.
1221     // sess.sumpenalty is probably wrong here shoul really be a sum of penalties from before the one we are asking for.
1222     $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.flagged, sess.id as questionsessionid
1223               FROM {question_states} st, {question_sessions} sess
1224              WHERE st.id = ?
1225                AND st.attempt = ?
1226                AND sess.attemptid = st.attempt
1227                AND st.question = ?
1228                AND sess.questionid = st.question';
1229     $state = $DB->get_record_sql($sql, array($stateid, $attempt->id, $question->id));
1230     if (!$state) {
1231         return false;
1232     }
1233     restore_question_state($question, $state);
1235     // Load the most recent graded states for the questions before the specified one.
1236     $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.flagged, sess.id as questionsessionid
1237               FROM {question_states} st, {question_sessions} sess
1238              WHERE st.seq_number <= ?
1239                AND st.attempt = ?
1240                AND sess.attemptid = st.attempt
1241                AND st.question = ?
1242                AND sess.questionid = st.question
1243                AND st.event IN ('.QUESTION_EVENTS_GRADED.') '.
1244            'ORDER BY st.seq_number DESC';
1245     $gradedstates = $DB->get_records_sql($sql, array($state->seq_number, $attempt->id, $question->id), 0, 1);
1246     if (empty($gradedstates)) {
1247         $state->last_graded = clone($state);
1248     } else {
1249         $gradedstate = reset($gradedstates);
1250         restore_question_state($question, $gradedstate);
1251         $state->last_graded = $gradedstate;
1252     }
1253     return $state;
1256 /**
1257 * Creates the run-time fields for the states
1259 * Extends the state objects for a question by calling
1260 * {@link restore_session_and_responses()}
1261  *
1262  * @global object
1263 * @param object $question The question for which the state is needed
1264 * @param object $state The state as loaded from the database
1265 * @return boolean Represents success or failure
1266 */
1267 function restore_question_state(&$question, &$state) {
1268     global $QTYPES;
1270     // initialise response to the value in the answer field
1271     $state->responses = array('' => $state->answer);
1272     unset($state->answer);
1273     $state->manualcomment = isset($state->manualcomment) ? $state->manualcomment : '';
1275     // Set the changed field to false; any code which changes the
1276     // question session must set this to true and must increment
1277     // ->seq_number. The save_question_session
1278     // function will save the new state object to the database if the field is
1279     // set to true.
1280     $state->changed = false;
1282     // Load the question type specific data
1283     return $QTYPES[$question->qtype]
1284             ->restore_session_and_responses($question, $state);
1288 /**
1289 * Saves the current state of the question session to the database
1291 * The state object representing the current state of the session for the
1292 * question is saved to the question_states table with ->responses[''] saved
1293 * to the answer field of the database table. The information in the
1294 * question_sessions table is updated.
1295 * The question type specific data is then saved.
1296  *
1297  * @global array
1298  * @global object
1299 * @return mixed           The id of the saved or updated state or false
1300 * @param object $question The question for which session is to be saved.
1301 * @param object $state    The state information to be saved. In particular the
1302 *                         most recent responses are in ->responses. The object
1303 *                         is updated to hold the new ->id.
1304 */
1305 function save_question_session($question, $state) {
1306     global $QTYPES, $DB;
1308     // Check if the state has changed
1309     if (!$state->changed && isset($state->id)) {
1310         if (isset($state->newflaggedstate) &&  $state->flagged != $state->newflaggedstate) {
1311             // If this fails, don't worry too much, it is not critical data.
1312             question_update_flag($state->questionsessionid, $state->newflaggedstate);
1313         }
1314         return $state->id;
1315     }
1316     // Set the legacy answer field
1317     $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
1319     // Save the state
1320     if (!empty($state->update)) { // this forces the old state record to be overwritten
1321         $DB->update_record('question_states', $state);
1322     } else {
1323         $state->id = $DB->insert_record('question_states', $state);
1324     }
1326     // create or update the session
1327     if (!$session = $DB->get_record('question_sessions', array('attemptid' => $state->attempt, 'questionid' => $question->id))) {
1328         $session = new stdClass;
1329         $session->attemptid = $state->attempt;
1330         $session->questionid = $question->id;
1331         $session->newest = $state->id;
1332         // The following may seem weird, but the newgraded field needs to be set
1333         // already even if there is no graded state yet.
1334         $session->newgraded = $state->id;
1335         $session->sumpenalty = $state->sumpenalty;
1336         $session->manualcomment = $state->manualcomment;
1337         $session->flagged = !empty($state->newflaggedstate);
1338         $DB->insert_record('question_sessions', $session);
1339     } else {
1340         $session->newest = $state->id;
1341         if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
1342             // this state is graded or newly opened, so it goes into the lastgraded field as well
1343             $session->newgraded = $state->id;
1344             $session->sumpenalty = $state->sumpenalty;
1345             $session->manualcomment = $state->manualcomment;
1346         } else {
1347             $session->manualcomment = $session->manualcomment;
1348         }
1349         $session->flagged = !empty($state->newflaggedstate);
1350         $DB->update_record('question_sessions', $session);
1351     }
1353     unset($state->answer);
1355     // Save the question type specific state information and responses
1356     if (!$QTYPES[$question->qtype]->save_session_and_responses($question, $state)) {
1357         return false;
1358     }
1360     // Reset the changed flag
1361     $state->changed = false;
1362     return $state->id;
1365 /**
1366 * Determines whether a state has been graded by looking at the event field
1368 * @return boolean         true if the state has been graded
1369 * @param object $state
1370 */
1371 function question_state_is_graded($state) {
1372     static $question_events_graded = array();
1373     if (!$question_events_graded){
1374         $question_events_graded = explode(',', QUESTION_EVENTS_GRADED);
1375     }
1376     return (in_array($state->event, $question_events_graded));
1379 /**
1380 * Determines whether a state has been closed by looking at the event field
1382 * @return boolean         true if the state has been closed
1383 * @param object $state
1384 */
1385 function question_state_is_closed($state) {
1386     static $question_events_closed = array();
1387     if (!$question_events_closed){
1388         $question_events_closed = explode(',', QUESTION_EVENTS_CLOSED);
1389     }
1390     return (in_array($state->event, $question_events_closed));
1394 /**
1395  * Extracts responses from submitted form
1396  *
1397  * This can extract the responses given to one or several questions present on a page
1398  * It returns an array with one entry for each question, indexed by question id
1399  * Each entry is an object with the properties
1400  *  ->event     The event that has triggered the submission. This is determined by which button
1401  *               the user has pressed.
1402  *  ->responses An array holding the responses to an individual question, indexed by the
1403  *               name of the corresponding form element.
1404  *  ->timestamp A unix timestamp
1405  * @return array            array of action objects, indexed by question ids.
1406  * @param array $questions  an array containing at least all questions that are used on the form
1407  * @param array $formdata   the data submitted by the form on the question page
1408  * @param integer $defaultevent  the event type used if no 'mark' or 'validate' is submitted
1409  */
1410 function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
1412     $time = time();
1413     $actions = array();
1414     foreach ($formdata as $key => $response) {
1415         // Get the question id from the response name
1416         if (false !== ($quid = question_get_id_from_name_prefix($key))) {
1417             // check if this is a valid id
1418             if (!isset($questions[$quid])) {
1419                 print_error('formquestionnotinids', 'question');
1420             }
1422             // Remove the name prefix from the name
1423             //decrypt trying
1424             $key = substr($key, strlen($questions[$quid]->name_prefix));
1425             if (false === $key) {
1426                 $key = '';
1427             }
1428             // Check for question validate and mark buttons & set events
1429             if ($key === 'validate') {
1430                 $actions[$quid]->event = QUESTION_EVENTVALIDATE;
1431             } else if ($key === 'submit') {
1432                 $actions[$quid]->event = QUESTION_EVENTSUBMIT;
1433             } else {
1434                 $actions[$quid]->event = $defaultevent;
1435             }
1436             // Update the state with the new response
1437             $actions[$quid]->responses[$key] = $response;
1439             // Set the timestamp
1440             $actions[$quid]->timestamp = $time;
1441         }
1442     }
1443     foreach ($actions as $quid => $notused) {
1444         ksort($actions[$quid]->responses);
1445     }
1446     return $actions;
1450 /**
1451  * Returns the html for question feedback image.
1452  *
1453  * @global object
1454  * @param float   $fraction  value representing the correctness of the user's
1455  *                           response to a question.
1456  * @param boolean $selected  whether or not the answer is the one that the
1457  *                           user picked.
1458  * @return string
1459  */
1460 function question_get_feedback_image($fraction, $selected=true) {
1461     global $CFG, $OUTPUT;
1462     static $icons = array('correct' => 'tick_green', 'partiallycorrect' => 'tick_amber',
1463             'incorrect' => 'cross_red');
1465     if ($selected) {
1466         $size = 'big';
1467     } else {
1468         $size = 'small';
1469     }
1470     $class = question_get_feedback_class($fraction);
1471     return '<img src="' . $OUTPUT->pix_url('i/' . $icons[$class] . '_' . $size) .
1472             '" alt="' . get_string($class, 'quiz') . '" class="icon" />';
1475 /**
1476  * Returns the class name for question feedback.
1477  * @param float  $fraction  value representing the correctness of the user's
1478  *                          response to a question.
1479  * @return string
1480  */
1481 function question_get_feedback_class($fraction) {
1482     if ($fraction >= 1/1.01) {
1483         return 'correct';
1484     } else if ($fraction > 0.0) {
1485         return 'partiallycorrect';
1486     } else {
1487         return 'incorrect';
1488     }
1492 /**
1493 * For a given question in an attempt we walk the complete history of states
1494 * and recalculate the grades as we go along.
1496 * This is used when a question is changed and old student
1497 * responses need to be marked with the new version of a question.
1499 * @todo Make sure this is not quiz-specific
1501  * @global object
1502 * @return boolean            Indicates whether the grade has changed
1503 * @param object  $question   A question object
1504 * @param object  $attempt    The attempt, in which the question needs to be regraded.
1505 * @param object  $cmoptions
1506 * @param boolean $verbose    Optional. Whether to print progress information or not.
1507 * @param boolean $dryrun     Optional. Whether to make changes to grades records
1508 * or record that changes need to be made for a later regrade.
1509 */
1510 function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false, $dryrun=false) {
1511     global $DB, $OUTPUT;
1513     // load all states for this question in this attempt, ordered in sequence
1514     if ($states = $DB->get_records('question_states',
1515             array('attempt'=>$attempt->uniqueid, 'question'=>$question->id),
1516             'seq_number ASC')) {
1517         $states = array_values($states);
1519         // Subtract the grade for the latest state from $attempt->sumgrades to get the
1520         // sumgrades for the attempt without this question.
1521         $attempt->sumgrades -= $states[count($states)-1]->grade;
1523         // Initialise the replaystate
1524         $replaystate = question_load_specific_state($question, $cmoptions, $attempt, $states[0]->id);
1525         $replaystate->sumpenalty = 0;
1526         $replaystate->last_graded->sumpenalty = 0;
1528         $changed = false;
1529         for($j = 1; $j < count($states); $j++) {
1530             restore_question_state($question, $states[$j]);
1531             $action = new stdClass;
1532             $action->responses = $states[$j]->responses;
1533             $action->timestamp = $states[$j]->timestamp;
1535             // Change event to submit so that it will be reprocessed
1536             if (in_array($states[$j]->event, array(QUESTION_EVENTCLOSE,
1537                     QUESTION_EVENTGRADE, QUESTION_EVENTCLOSEANDGRADE))) {
1538                 $action->event = QUESTION_EVENTSUBMIT;
1540             // By default take the event that was saved in the database
1541             } else {
1542                 $action->event = $states[$j]->event;
1543             }
1545             if ($action->event == QUESTION_EVENTMANUALGRADE) {
1546                 // Ensure that the grade is in range - in the past this was not checked,
1547                 // but now it is (MDL-14835) - so we need to ensure the data is valid before
1548                 // proceeding.
1549                 if ($states[$j]->grade < 0) {
1550                     $states[$j]->grade = 0;
1551                     $changed = true;
1552                 } else if ($states[$j]->grade > $question->maxgrade) {
1553                     $states[$j]->grade = $question->maxgrade;
1554                     $changed = true;
1556                 }
1557                 if (!$dryrun){
1558                     $error = question_process_comment($question, $replaystate, $attempt,
1559                             $replaystate->manualcomment, $states[$j]->grade);
1560                     if (is_string($error)) {
1561                          echo $OUTPUT->notification($error);
1562                     }
1563                 } else {
1564                     $replaystate->grade = $states[$j]->grade;
1565                 }
1566             } else {
1567                 // Reprocess (regrade) responses
1568                 if (!question_process_responses($question, $replaystate,
1569                         $action, $cmoptions, $attempt) && $verbose) {
1570                     $a = new stdClass;
1571                     $a->qid = $question->id;
1572                     $a->stateid = $states[$j]->id;
1573                     echo $OUTPUT->notification(get_string('errorduringregrade', 'question', $a));
1574                 }
1575                 // We need rounding here because grades in the DB get truncated
1576                 // e.g. 0.33333 != 0.3333333, but we want them to be equal here
1577                 if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
1578                         or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
1579                         or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
1580                     $changed = true;
1581                 }
1582                 // If this was previously a closed state, and it has been knoced back to
1583                 // graded, then fix up the state again.
1584                 if ($replaystate->event == QUESTION_EVENTGRADE &&
1585                         ($states[$j]->event == QUESTION_EVENTCLOSE ||
1586                         $states[$j]->event == QUESTION_EVENTCLOSEANDGRADE)) {
1587                     $replaystate->event = $states[$j]->event;
1588                 }
1589             }
1591             $replaystate->id = $states[$j]->id;
1592             $replaystate->changed = true;
1593             $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
1594             if (!$dryrun){
1595                 save_question_session($question, $replaystate);
1596             }
1597         }
1598         if ($changed) {
1599             if (!$dryrun){
1600                 // TODO, call a method in quiz to do this, where 'quiz' comes from
1601                 // the question_attempts table.
1602                 $DB->update_record('quiz_attempts', $attempt);
1603             }
1604         }
1605         if ($changed){
1606             $toinsert = new object();
1607             $toinsert->oldgrade = round((float)$states[count($states)-1]->grade, 5);
1608             $toinsert->newgrade = round((float)$replaystate->grade, 5);
1609             $toinsert->attemptid = $attempt->uniqueid;
1610             $toinsert->questionid = $question->id;
1611             //the grade saved is the old grade if the new grade is saved
1612             //it is the new grade if this is a dry run.
1613             $toinsert->regraded = $dryrun?0:1;
1614             $toinsert->timemodified = time();
1615             $DB->insert_record('quiz_question_regrade', $toinsert);
1616             return true;
1617         } else {
1618             return false;
1619         }
1620     }
1621     return false;
1624 /**
1625 * Processes an array of student responses, grading and saving them as appropriate
1627  * @global array
1628 * @param object $question Full question object, passed by reference
1629 * @param object $state    Full state object, passed by reference
1630 * @param object $action   object with the fields ->responses which
1631 *                         is an array holding the student responses,
1632 *                         ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
1633 *                         and ->timestamp which is a timestamp from when the responses
1634 *                         were submitted by the student.
1635 * @param object $cmoptions
1636 * @param object $attempt  The attempt is passed by reference so that
1637 *                         during grading its ->sumgrades field can be updated
1638 * @return boolean         Indicates success/failure
1639 */
1640 function question_process_responses($question, &$state, $action, $cmoptions, &$attempt) {
1641     global $QTYPES;
1643     // if no responses are set initialise to empty response
1644     if (!isset($action->responses)) {
1645         $action->responses = array('' => '');
1646     }
1648     $state->newflaggedstate = !empty($action->responses['_flagged']);
1650     // make sure these are gone!
1651     unset($action->responses['submit'], $action->responses['validate'], $action->responses['_flagged']);
1653     // Check the question session is still open
1654     if (question_state_is_closed($state)) {
1655         return true;
1656     }
1658     // If $action->event is not set that implies saving
1659     if (! isset($action->event)) {
1660         debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
1661         $action->event = QUESTION_EVENTSAVE;
1662     }
1663     // If submitted then compare against last graded
1664     // responses, not last given responses in this case
1665     if (question_isgradingevent($action->event)) {
1666         $state->responses = $state->last_graded->responses;
1667     }
1669     // Check for unchanged responses (exactly unchanged, not equivalent).
1670     // We also have to catch questions that the student has not yet attempted
1671     $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
1672     if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
1673             question_isgradingevent($action->event)) {
1674         $sameresponses = false;
1675     }
1677     // If the response has not been changed then we do not have to process it again
1678     // unless the attempt is closing or validation is requested
1679     if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
1680             and QUESTION_EVENTVALIDATE != $action->event) {
1681         return true;
1682     }
1684     // Roll back grading information to last graded state and set the new
1685     // responses
1686     $newstate = clone($state->last_graded);
1687     $newstate->responses = $action->responses;
1688     $newstate->seq_number = $state->seq_number + 1;
1689     $newstate->changed = true; // will assure that it gets saved to the database
1690     $newstate->last_graded = clone($state->last_graded);
1691     $newstate->timestamp = $action->timestamp;
1692     $newstate->newflaggedstate = $state->newflaggedstate;
1693     $newstate->flagged = $state->flagged;
1694     $newstate->questionsessionid = $state->questionsessionid;
1695     $state = $newstate;
1697     // Set the event to the action we will perform. The question type specific
1698     // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
1699     // attempt at the question causes the session to close
1700     $state->event = $action->event;
1702     if (!question_isgradingevent($action->event)) {
1703         // Grade the response but don't update the overall grade
1704         if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1705             return false;
1706         }
1708         // Temporary hack because question types are not given enough control over what is going
1709         // on. Used by Opaque questions.
1710         // TODO fix this code properly.
1711         if (!empty($state->believeevent)) {
1712             // If the state was graded we need to ...
1713             if (question_state_is_graded($state)) {
1714                 question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1716                 // update the attempt grade
1717                 $attempt->sumgrades -= (float)$state->last_graded->grade;
1718                 $attempt->sumgrades += (float)$state->grade;
1720                 // and update the last_graded field.
1721                 unset($state->last_graded);
1722                 $state->last_graded = clone($state);
1723                 unset($state->last_graded->changed);
1724             }
1725         } else {
1726             // Don't allow the processing to change the event type
1727             $state->event = $action->event;
1728         }
1730     } else { // grading event
1732         // Unless the attempt is closing, we want to work out if the current responses
1733         // (or equivalent responses) were already given in the last graded attempt.
1734         if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
1735                 $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
1736             $state->event = QUESTION_EVENTDUPLICATE;
1737         }
1739         // If we did not find a duplicate or if the attempt is closing, perform grading
1740         if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
1741                 QUESTION_EVENTCLOSE == $action->event) {
1742             if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
1743                 return false;
1744             }
1746             // Calculate overall grade using correct penalty method
1747             question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
1748         }
1750         // If the state was graded we need to ...
1751         if (question_state_is_graded($state)) {
1752             // update the attempt grade
1753             $attempt->sumgrades -= (float)$state->last_graded->grade;
1754             $attempt->sumgrades += (float)$state->grade;
1756             // and update the last_graded field.
1757             unset($state->last_graded);
1758             $state->last_graded = clone($state);
1759             unset($state->last_graded->changed);
1760         }
1761     }
1762     $attempt->timemodified = $action->timestamp;
1764     return true;
1767 /**
1768 * Determine if event requires grading
1769 */
1770 function question_isgradingevent($event) {
1771     return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
1774 /**
1775 * Applies the penalty from the previous graded responses to the raw grade
1776 * for the current responses
1778 * The grade for the question in the current state is computed by subtracting the
1779 * penalty accumulated over the previous graded responses at the question from the
1780 * raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
1781 * the grade is set to zero. The ->grade field of the state object is modified to
1782 * reflect the new grade but is never allowed to decrease.
1783 * @param object $question The question for which the penalty is to be applied.
1784 * @param object $state    The state for which the grade is to be set from the
1785 *                         raw grade and the cumulative penalty from the last
1786 *                         graded state. The ->grade field is updated by applying
1787 *                         the penalty scheme determined in $cmoptions to the ->raw_grade and
1788 *                         ->last_graded->penalty fields.
1789 * @param object $cmoptions  The options set by the course module.
1790 *                           The ->penaltyscheme field determines whether penalties
1791 *                           for incorrect earlier responses are subtracted.
1792 */
1793 function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
1794     // TODO. Quiz dependancy. The fact that the attempt that is passed in here
1795     // is from quiz_attempts, and we use things like $cmoptions->timelimit.
1797     // deal with penalty
1798     if ($cmoptions->penaltyscheme) {
1799         $state->grade = $state->raw_grade - $state->sumpenalty;
1800         $state->sumpenalty += (float) $state->penalty;
1801     } else {
1802         $state->grade = $state->raw_grade;
1803     }
1805     // deal with timelimit
1806     if ($cmoptions->timelimit) {
1807         // We allow for 5% uncertainty in the following test
1808         if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 1.05) {
1809             $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
1810             if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
1811                     $attempt->userid, false)) {
1812                 $state->grade = 0;
1813             }
1814         }
1815     }
1817     // deal with closing time
1818     if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
1819              and !$attempt->preview) { // ignore closing time for previews
1820         $state->grade = 0;
1821     }
1823     // Ensure that the grade does not go down
1824     $state->grade = max($state->grade, $state->last_graded->grade);
1827 /**
1828 * Print the icon for the question type
1830  * @global array
1831  * @global object
1832 * @param object $question The question object for which the icon is required
1833 *       only $question->qtype is used.
1834 * @param boolean $return If true the functions returns the link as a string
1835 */
1836 function print_question_icon($question, $return = false) {
1837     global $QTYPES, $CFG;
1839     if (array_key_exists($question->qtype, $QTYPES)) {
1840         $namestr = $QTYPES[$question->qtype]->local_name();
1841     } else {
1842         $namestr = 'missingtype';
1843     }
1844     $html = '<img src="' . $CFG->wwwroot . '/question/type/' .
1845             $question->qtype . '/icon.gif" alt="' .
1846             $namestr . '" title="' . $namestr . '" />';
1847     if ($return) {
1848         return $html;
1849     } else {
1850         echo $html;
1851     }
1854 /**
1855 * Returns a html link to the question image if there is one
1857  * @global object
1858  * @global object
1859 * @return string The html image tag or the empy string if there is no image.
1860 * @param object $question The question object
1861 */
1862 function get_question_image($question) {
1863     global $CFG, $DB;
1864     $img = '';
1866     if (!$category = $DB->get_record('question_categories', array('id'=>$question->category))) {
1867         print_error('invalidcategory');
1868     }
1869     $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid));
1871     if ($question->image) {
1873         if (substr(strtolower($question->image), 0, 7) == 'http://') {
1874             $img .= $question->image;
1876         } else {
1877             require_once($CFG->libdir .'/filelib.php');
1878             $img = get_file_url("$coursefilesdir/{$question->image}");
1879         }
1880     }
1881     return $img;
1884 /**
1885  * @global array
1886  */
1887 function question_print_comment_fields($question, $state, $prefix, $cmoptions, $caption = '') {
1888     global $QTYPES;
1889     $idprefix = preg_replace('/[^-_a-zA-Z0-9]/', '', $prefix);
1890     $otherquestionsinuse = '';
1891     if (!empty($cmoptions->questions)) {
1892         $otherquestionsinuse = $cmoptions->questions;
1893     }
1894     if (!question_state_is_graded($state) && $QTYPES[$question->qtype]->is_question_manual_graded($question, $otherquestionsinuse)) {
1895         $grade = '';
1896     } else {
1897         $grade = question_format_grade($cmoptions, $state->last_graded->grade);
1898     }
1899     $maxgrade = question_format_grade($cmoptions, $question->maxgrade);
1900     $fieldsize = strlen($maxgrade) - 1;
1901     if (empty($caption)) {
1902         $caption = format_string($question->name);
1903     }
1904     ?>
1905 <fieldset class="que comment clearfix">
1906     <legend class="ftoggler"><?php echo $caption; ?></legend>
1907     <div class="fcontainer clearfix">
1908         <div class="fitem">
1909             <div class="fitemtitle">
1910                 <label for="<?php echo $idprefix; ?>_comment_box"><?php print_string('comment', 'quiz'); ?></label>
1911             </div>
1912             <div class="felement fhtmleditor">
1913                 <?php print_textarea(can_use_html_editor(), 15, 60, 630, 300, $prefix . '[comment]',
1914                         $state->manualcomment, 0, false, $idprefix . '_comment_box'); ?>
1915             </div>
1916         </div>
1917         <div class="fitem">
1918             <div class="fitemtitle">
1919                 <label for="<?php echo $idprefix; ?>_grade_field"><?php print_string('grade', 'quiz'); ?></label>
1920             </div>
1921             <div class="felement ftext">
1922                 <input type="text" name="<?php echo $prefix; ?>[grade]" size="<?php echo $fieldsize; ?>" id="<?php echo $idprefix; ?>_grade_field" value="<?php echo $grade; ?>" /> / <?php echo $maxgrade; ?>
1923             </div>
1924         </div>
1925     </div>
1926 </fieldset>
1927     <?php
1930 /**
1931  * Process a manual grading action. That is, use $comment and $grade to update
1932  * $state and $attempt. The attempt and the comment text are stored in the
1933  * database. $state is only updated in memory, it is up to the call to store
1934  * that, if appropriate.
1935  *
1936  * @global object
1937  * @param object $question the question
1938  * @param object $state the state to be updated.
1939  * @param object $attempt the attempt the state belongs to, to be updated.
1940  * @param string $comment the new comment from the teacher.
1941  * @param mixed $grade the grade the teacher assigned, or '' to not change the grade.
1942  * @return mixed true on success, a string error message if a problem is detected
1943  *         (for example score out of range).
1944  */
1945 function question_process_comment($question, &$state, &$attempt, $comment, $grade) {
1946     global $DB;
1948     $grade = trim($grade);
1949     if ($grade < 0 || $grade > $question->maxgrade) {
1950         $a = new stdClass;
1951         $a->grade = $grade;
1952         $a->maxgrade = $question->maxgrade;
1953         $a->name = $question->name;
1954         return get_string('errormanualgradeoutofrange', 'question', $a);
1955     }
1957     // Update the comment and save it in the database
1958     $comment = trim($comment);
1959     $state->manualcomment = $comment;
1960     $state->newflaggedstate = $state->flagged;
1961     if (!$DB->set_field('question_sessions', 'manualcomment', $comment, array('attemptid'=>$attempt->uniqueid, 'questionid'=>$question->id))) {
1962         return get_string('errorsavingcomment', 'question', $question);
1963     }
1965     // Update the attempt if the score has changed.
1966     if ($grade !== '' && (abs($state->last_graded->grade - $grade) > 0.002 || $state->last_graded->event != QUESTION_EVENTMANUALGRADE)) {
1967         $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
1968         $attempt->timemodified = time();
1969         $DB->update_record('quiz_attempts', $attempt);
1971         // We want to update existing state (rather than creating new one) if it
1972         // was itself created by a manual grading event.
1973         $state->update = $state->event == QUESTION_EVENTMANUALGRADE;
1975         // Update the other parts of the state object.
1976         $state->raw_grade = $grade;
1977         $state->grade = $grade;
1978         $state->penalty = 0;
1979         $state->timestamp = time();
1980         $state->seq_number++;
1981         $state->event = QUESTION_EVENTMANUALGRADE;
1983         // Update the last graded state (don't simplify!)
1984         unset($state->last_graded);
1985         $state->last_graded = clone($state);
1987         // We need to indicate that the state has changed in order for it to be saved.
1988         $state->changed = 1;
1989     }
1991     return true;
1994 /**
1995 * Construct name prefixes for question form element names
1997 * Construct the name prefix that should be used for example in the
1998 * names of form elements created by questions.
1999 * This is called by {@link get_question_options()}
2000 * to set $question->name_prefix.
2001 * This name prefix includes the question id which can be
2002 * extracted from it with {@link question_get_id_from_name_prefix()}.
2004 * @return string
2005 * @param integer $id  The question id
2006 */
2007 function question_make_name_prefix($id) {
2008     return 'resp' . $id . '_';
2011 /**
2012  * Extract question id from the prefix of form element names
2013  *
2014  * @return integer      The question id
2015  * @param string $name  The name that contains a prefix that was
2016  *                      constructed with {@link question_make_name_prefix()}
2017  */
2018 function question_get_id_from_name_prefix($name) {
2019     if (!preg_match('/^resp([0-9]+)_/', $name, $matches)) {
2020         return false;
2021     }
2022     return (integer) $matches[1];
2025 /**
2026  * Extract question id from the prefix of form element names
2027  *
2028  * @return integer      The question id
2029  * @param string $name  The name that contains a prefix that was
2030  *                      constructed with {@link question_make_name_prefix()}
2031  */
2032 function question_id_and_key_from_post_name($name) {
2033     if (!preg_match('/^resp([0-9]+)_(.*)$/', $name, $matches)) {
2034         return array(false, false);
2035     }
2036     return array((integer) $matches[1], $matches[2]);
2039 /**
2040  * Returns the unique id for a new attempt
2041  *
2042  * Every module can keep their own attempts table with their own sequential ids but
2043  * the question code needs to also have a unique id by which to identify all these
2044  * attempts. Hence a module, when creating a new attempt, calls this function and
2045  * stores the return value in the 'uniqueid' field of its attempts table.
2046  *
2047  * @global object
2048  */
2049 function question_new_attempt_uniqueid($modulename='quiz') {
2050     global $DB;
2052     $attempt = new stdClass;
2053     $attempt->modulename = $modulename;
2054     $id = $DB->insert_record('question_attempts', $attempt);
2055     return $id;
2058 /**
2059  * Creates a stamp that uniquely identifies this version of the question
2060  *
2061  * In future we want this to use a hash of the question data to guarantee that
2062  * identical versions have the same version stamp.
2063  *
2064  * @param object $question
2065  * @return string A unique version stamp
2066  */
2067 function question_hash($question) {
2068     return make_unique_id_code();
2071 /**
2072  * Round a grade to to the correct number of decimal places, and format it for display.
2073  * If $cmoptions->questiondecimalpoints is set, that is used, otherwise
2074  * else if $cmoptions->decimalpoints is used,
2075  * otherwise a default of 2 is used, but this should not be relied upon, and generated a developer debug warning.
2076  * However, if $cmoptions->questiondecimalpoints is -1, the means use $cmoptions->decimalpoints.
2077  *
2078  * @param object $cmoptions The modules settings.
2079  * @param float $grade The grade to round.
2080  */
2081 function question_format_grade($cmoptions, $grade) {
2082     if (isset($cmoptions->questiondecimalpoints) && $cmoptions->questiondecimalpoints != -1) {
2083         $decimalplaces = $cmoptions->questiondecimalpoints;
2084     } else if (isset($cmoptions->decimalpoints)) {
2085         $decimalplaces = $cmoptions->decimalpoints;
2086     } else {
2087         $decimalplaces = 2;
2088         debugging('Code that leads to question_format_grade being called should set ' .
2089                 '$cmoptions->questiondecimalpoints or $cmoptions->decimalpoints', DEBUG_DEVELOPER);
2090     }
2091     return format_float($grade, $decimalplaces);
2094 /**
2095  * @return string An inline script that creates a JavaScript object storing
2096  * various strings and bits of configuration that the scripts in qengine.js need
2097  * to get from PHP.
2098  */
2099 function question_init_qengine_js() {
2100     global $CFG, $PAGE, $OUTPUT;
2101     static $done = false;
2102     if ($done) {
2103         return;
2104     }
2105     $module = array(
2106         'name' => 'core_question_flags',
2107         'fullpath' => '/question/flags.js',
2108         'requires' => array('base', 'dom', 'event-delegate', 'io-base'),
2109     );
2110     $actionurl = $CFG->wwwroot . '/question/toggleflag.php';
2111     $flagattributes = array(
2112         0 => array(
2113             'src' => $OUTPUT->pix_url('i/unflagged') . '',
2114             'title' => get_string('clicktoflag', 'question'),
2115             'alt' => get_string('notflagged', 'question'),
2116         ),
2117         1 => array(
2118             'src' => $OUTPUT->pix_url('i/flagged') . '',
2119             'title' => get_string('clicktounflag', 'question'),
2120             'alt' => get_string('flagged', 'question'),
2121         ),
2122     );
2123     $PAGE->requires->js_init_call('M.core_question_flags.init',
2124             array($actionurl, $flagattributes), false, $module);
2125     $done = true;
2128 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
2129 /**
2130  * Give the questions in $questionlist a chance to request the CSS or JavaScript
2131  * they need, before the header is printed.
2132  *
2133  * If your code is going to call the print_question function, it must call this
2134  * funciton before print_header.
2135  *
2136  * @param array $questionlist a list of questionids of the questions what will appear on this page.
2137  * @param array $questions an array of question objects, whose keys are question ids.
2138  *      Must contain all the questions in $questionlist
2139  * @param array $states an array of question state objects, whose keys are question ids.
2140  *      Must contain the state of all the questions in $questionlist
2141  */
2142 function question_get_html_head_contributions($questionlist, &$questions, &$states) {
2143     global $CFG, $PAGE, $QTYPES;
2145     // The question engine's own JavaScript.
2146     question_init_qengine_js();
2148     // Anything that questions on this page need.
2149     foreach ($questionlist as $questionid) {
2150         $question = $questions[$questionid];
2151         $QTYPES[$question->qtype]->get_html_head_contributions($question, $states[$questionid]);
2152     }
2155 /**
2156  * Like {@link get_html_head_contributions()} but for the editing page
2157  * question/question.php.
2158  *
2159  * @param $question A question object. Only $question->qtype is used.
2160  * @return string Deprecated. Some HTML code that can go inside the head tag.
2161  */
2162 function question_get_editing_head_contributions($question) {
2163     global $QTYPES;
2164     $QTYPES[$question->qtype]->get_editing_head_contributions();
2167 /**
2168  * Prints a question
2169  *
2170  * Simply calls the question type specific print_question() method.
2171  *
2172  * @global array
2173  * @param object $question The question to be rendered.
2174  * @param object $state    The state to render the question in.
2175  * @param integer $number  The number for this question.
2176  * @param object $cmoptions  The options specified by the course module
2177  * @param object $options  An object specifying the rendering options.
2178  */
2179 function print_question(&$question, &$state, $number, $cmoptions, $options=null, $context=null) {
2180     global $QTYPES;
2181     $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options, $context);
2183 /**
2184  * Saves question options
2185  *
2186  * Simply calls the question type specific save_question_options() method.
2187  *
2188  * @global array
2189  */
2190 function save_question_options($question) {
2191     global $QTYPES;
2193     $QTYPES[$question->qtype]->save_question_options($question);
2196 /**
2197 * Gets all teacher stored answers for a given question
2199 * Simply calls the question type specific get_all_responses() method.
2200  *
2201  * @global array
2202 */
2203 // ULPGC ecastro
2204 function get_question_responses($question, $state) {
2205     global $QTYPES;
2206     $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
2207     return $r;
2210 /**
2211 * Gets the response given by the user in a particular state
2213 * Simply calls the question type specific get_actual_response() method.
2214  *
2215  * @global array
2216 */
2217 // ULPGC ecastro
2218 function get_question_actual_response($question, $state) {
2219     global $QTYPES;
2221     $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
2222     return $r;
2225 /**
2226 * TODO: document this
2227  *
2228  * @global array
2229 */
2230 // ULPGc ecastro
2231 function get_question_fraction_grade($question, $state) {
2232     global $QTYPES;
2234     $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
2235     return $r;
2237 /**
2238  * @global array
2239 * @return integer grade out of 1 that a random guess by a student might score.
2240 */
2241 // ULPGc ecastro
2242 function question_get_random_guess_score($question) {
2243     global $QTYPES;
2245     $r = $QTYPES[$question->qtype]->get_random_guess_score($question);
2246     return $r;
2248 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
2250 /**
2251  * returns the categories with their names ordered following parent-child relationships
2252  * finally it tries to return pending categories (those being orphaned, whose parent is
2253  * incorrect) to avoid missing any category from original array.
2254  *
2255  * @global object
2256  */
2257 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
2258     global $DB;
2260     $children = array();
2261     $keys = array_keys($categories);
2263     foreach ($keys as $key) {
2264         if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
2265             $children[$key] = $categories[$key];
2266             $categories[$key]->processed = true;
2267             $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
2268         }
2269     }
2270     //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
2271     if ($level == 1) {
2272         foreach ($keys as $key) {
2273             //If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
2274             if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories', array('course'=>$categories[$key]->course, 'id'=>$categories[$key]->parent))) {
2275                 $children[$key] = $categories[$key];
2276                 $categories[$key]->processed = true;
2277                 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
2278             }
2279         }
2280     }
2281     return $children;
2284 /**
2285  * Private method, only for the use of add_indented_names().
2286  *
2287  * Recursively adds an indentedname field to each category, starting with the category
2288  * with id $id, and dealing with that category and all its children, and
2289  * return a new array, with those categories in the right order.
2290  *
2291  * @param array $categories an array of categories which has had childids
2292  *          fields added by flatten_category_tree(). Passed by reference for
2293  *          performance only. It is not modfied.
2294  * @param int $id the category to start the indenting process from.
2295  * @param int $depth the indent depth. Used in recursive calls.
2296  * @return array a new array of categories, in the right order for the tree.
2297  */
2298 function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
2300     // Indent the name of this category.
2301     $newcategories = array();
2302     $newcategories[$id] = $categories[$id];
2303     $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
2305     // Recursively indent the children.
2306     foreach ($categories[$id]->childids as $childid) {
2307         if ($childid != $nochildrenof){
2308             $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
2309         }
2310     }
2312     // Remove the childids array that were temporarily added.
2313     unset($newcategories[$id]->childids);
2315     return $newcategories;
2318 /**
2319  * Format categories into an indented list reflecting the tree structure.
2320  *
2321  * @param array $categories An array of category objects, for example from the.
2322  * @return array The formatted list of categories.
2323  */
2324 function add_indented_names($categories, $nochildrenof = -1) {
2326     // Add an array to each category to hold the child category ids. This array will be removed
2327     // again by flatten_category_tree(). It should not be used outside these two functions.
2328     foreach (array_keys($categories) as $id) {
2329         $categories[$id]->childids = array();
2330     }
2332     // Build the tree structure, and record which categories are top-level.
2333     // We have to be careful, because the categories array may include published
2334     // categories from other courses, but not their parents.
2335     $toplevelcategoryids = array();
2336     foreach (array_keys($categories) as $id) {
2337         if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
2338             $categories[$categories[$id]->parent]->childids[] = $id;
2339         } else {
2340             $toplevelcategoryids[] = $id;
2341         }
2342     }
2344     // Flatten the tree to and add the indents.
2345     $newcategories = array();
2346     foreach ($toplevelcategoryids as $id) {
2347         $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
2348     }
2350     return $newcategories;
2353 /**
2354  * Output a select menu of question categories.
2355  *
2356  * Categories from this course and (optionally) published categories from other courses
2357  * are included. Optionally, only categories the current user may edit can be included.
2358  *
2359  * @param integer $courseid the id of the course to get the categories for.
2360  * @param integer $published if true, include publised categories from other courses.
2361  * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
2362  * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
2363  */
2364 function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
2365     global $OUTPUT;
2366     $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
2367     if ($selected) {
2368         $choose = '';
2369     } else {
2370         $choose = 'choosedots';
2371     }
2372     $options = array();
2373     foreach($categoriesarray as $group=>$opts) {
2374         $options[] = array($group=>$opts);
2375     }
2377     echo html_writer::select($options, 'category', $selected, $choose);
2380 /**
2381  * @global object
2382  * @param integer $contextid a context id.
2383  * @return object the default question category for that context, or false if none.
2384  */
2385 function question_get_default_category($contextid) {
2386     global $DB;
2387     $category = $DB->get_records('question_categories', array('contextid' => $contextid),'id','*',0,1);
2388     if (!empty($category)) {
2389         return reset($category);
2390     } else {
2391         return false;
2392     }
2395 /**
2396  * @global object
2397  * @global object
2398  * @param object $context a context
2399  * @return string A URL for editing questions in this context.
2400  */
2401 function question_edit_url($context) {
2402     global $CFG, $SITE;
2403     if (!has_any_capability(question_get_question_capabilities(), $context)) {
2404         return false;
2405     }
2406     $baseurl = $CFG->wwwroot . '/question/edit.php?';
2407     $defaultcategory = question_get_default_category($context->id);
2408     if ($defaultcategory) {
2409         $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
2410     }
2411     switch ($context->contextlevel) {
2412         case CONTEXT_SYSTEM:
2413             return $baseurl . 'courseid=' . $SITE->id;
2414         case CONTEXT_COURSECAT:
2415             // This is nasty, becuase we can only edit questions in a course
2416             // context at the moment, so for now we just return false.
2417             return false;
2418         case CONTEXT_COURSE:
2419             return $baseurl . 'courseid=' . $context->instanceid;
2420         case CONTEXT_MODULE:
2421             return $baseurl . 'cmid=' . $context->instanceid;
2422     }
2426 /**
2427 * Gets the default category in the most specific context.
2428 * If no categories exist yet then default ones are created in all contexts.
2430  * @global object
2431 * @param array $contexts  The context objects for this context and all parent contexts.
2432 * @return object The default category - the category in the course context
2433 */
2434 function question_make_default_categories($contexts) {
2435     global $DB;
2436     static $preferredlevels = array(
2437         CONTEXT_COURSE => 4,
2438         CONTEXT_MODULE => 3,
2439         CONTEXT_COURSECAT => 2,
2440         CONTEXT_SYSTEM => 1,
2441     );
2443     $toreturn = null;
2444     $preferredness = 0;
2445     // If it already exists, just return it.
2446     foreach ($contexts as $key => $context) {
2447         if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))) {
2448             // Otherwise, we need to make one
2449             $category = new stdClass;
2450             $contextname = print_context_name($context, false, true);
2451             $category->name = get_string('defaultfor', 'question', $contextname);
2452             $category->info = get_string('defaultinfofor', 'question', $contextname);
2453             $category->contextid = $context->id;
2454             $category->parent = 0;
2455             $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
2456             $category->stamp = make_unique_id_code();
2457             $category->id = $DB->insert_record('question_categories', $category);
2458         } else {
2459             $category = question_get_default_category($context->id);
2460         }
2461         if ($preferredlevels[$context->contextlevel] > $preferredness &&
2462                 has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
2463             $toreturn = $category;
2464             $preferredness = $preferredlevels[$context->contextlevel];
2465         }
2466     }
2468     if (!is_null($toreturn)) {
2469         $toreturn = clone($toreturn);
2470     }
2471     return $toreturn;
2474 /**
2475  * Get all the category objects, including a count of the number of questions in that category,
2476  * for all the categories in the lists $contexts.
2477  *
2478  * @global object
2479  * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
2480  * @param string $sortorder used as the ORDER BY clause in the select statement.
2481  * @return array of category objects.
2482  */
2483 function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
2484     global $DB;
2485     return $DB->get_records_sql("
2486             SELECT c.*, (SELECT count(1) FROM {question} q
2487                         WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
2488               FROM {question_categories} c
2489              WHERE c.contextid IN ($contexts)
2490           ORDER BY $sortorder");
2493 /**
2494  * Output an array of question categories.
2495  * @global object
2496  */
2497 function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
2498     global $CFG;
2499     $pcontexts = array();
2500     foreach($contexts as $context){
2501         $pcontexts[] = $context->id;
2502     }
2503     $contextslist = join($pcontexts, ', ');
2505     $categories = get_categories_for_contexts($contextslist);
2507     $categories = question_add_context_in_key($categories);
2509     if ($top){
2510         $categories = question_add_tops($categories, $pcontexts);
2511     }
2512     $categories = add_indented_names($categories, $nochildrenof);
2514     //sort cats out into different contexts
2515     $categoriesarray = array();
2516     foreach ($pcontexts as $pcontext){
2517         $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
2518         foreach ($categories as $category) {
2519             if ($category->contextid == $pcontext){
2520                 $cid = $category->id;
2521                 if ($currentcat!= $cid || $currentcat==0) {
2522                     $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
2523                     $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
2524                 }
2525             }
2526         }
2527     }
2528     if ($popupform){
2529         $popupcats = array();
2530         foreach ($categoriesarray as $contextstring => $optgroup){
2531             $group = array();
2532             foreach ($optgroup as $key=>$value) {
2533                 $key = str_replace($CFG->wwwroot, '', $key);
2534                 $group[$key] = $value;
2535             }
2536             $popupcats[] = array($contextstring=>$group);
2537         }
2538         return $popupcats;
2539     } else {
2540         return $categoriesarray;
2541     }
2544 function question_add_context_in_key($categories){
2545     $newcatarray = array();
2546     foreach ($categories as $id => $category) {
2547         $category->parent = "$category->parent,$category->contextid";
2548         $category->id = "$category->id,$category->contextid";
2549         $newcatarray["$id,$category->contextid"] = $category;
2550     }
2551     return $newcatarray;
2553 function question_add_tops($categories, $pcontexts){
2554     $topcats = array();
2555     foreach ($pcontexts as $context){
2556         $newcat = new object();
2557         $newcat->id = "0,$context";
2558         $newcat->name = get_string('top');
2559         $newcat->parent = -1;
2560         $newcat->contextid = $context;
2561         $topcats["0,$context"] = $newcat;
2562     }
2563     //put topcats in at beginning of array - they'll be sorted into different contexts later.
2564     return array_merge($topcats, $categories);
2567 /**
2568  * Returns a comma separated list of ids of the category and all subcategories
2569  * @global object
2570  */
2571 function question_categorylist($categoryid) {
2572     global $DB;
2574     // returns a comma separated list of ids of the category and all subcategories
2575     $categorylist = $categoryid;
2576     if ($subcategories = $DB->get_records('question_categories', array('parent'=>$categoryid), 'sortorder ASC', 'id, 1')) {
2577         foreach ($subcategories as $subcategory) {
2578             $categorylist .= ','. question_categorylist($subcategory->id);
2579         }
2580     }
2581     return $categorylist;
2587 //===========================
2588 // Import/Export Functions
2589 //===========================
2591 /**
2592  * Get list of available import or export formats
2593  *
2594  * @global object
2595  * @param string $type 'import' if import list, otherwise export list assumed
2596  * @return array sorted list of import/export formats available
2597  */
2598 function get_import_export_formats( $type ) {
2600     global $CFG;
2601     $fileformats = get_plugin_list("qformat");
2603     $fileformatname=array();
2604     require_once( "{$CFG->dirroot}/question/format.php" );
2605     foreach ($fileformats as $fileformat=>$fdir) {
2606         $format_file = "$fdir/format.php";
2607         if (file_exists($format_file) ) {
2608             require_once($format_file);
2609         }
2610         else {
2611             continue;
2612         }
2613         $classname = "qformat_$fileformat";
2614         $format_class = new $classname();
2615         if ($type=='import') {
2616             $provided = $format_class->provide_import();
2617         }
2618         else {
2619             $provided = $format_class->provide_export();
2620         }
2621         if ($provided) {
2622             $formatname = get_string($fileformat, 'quiz');
2623             if ($formatname == "[[$fileformat]]") {
2624                 $formatname = get_string($fileformat, 'qformat_'.$fileformat);
2625                 if ($formatname == "[[$fileformat]]") {
2626                     $formatname = $fileformat;  // Just use the raw folder name
2627                 }
2628             }
2629             $fileformatnames[$fileformat] = $formatname;
2630         }
2631     }
2632     natcasesort($fileformatnames);
2634     return $fileformatnames;
2638 /**
2639 * Create default export filename
2641 * @return string   default export filename
2642 * @param object $course
2643 * @param object $category
2644 */
2645 function default_export_filename($course,$category) {
2646     //Take off some characters in the filename !!
2647     $takeoff = array(" ", ":", "/", "\\", "|");
2648     $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz")));
2649     //If non-translated, use "export"
2650     if (substr($export_word,0,1) == "[") {
2651         $export_word= "export";
2652     }
2654     //Calculate the date format string
2655     $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz"));
2656     //If non-translated, use "%Y%m%d-%H%M"
2657     if (substr($export_date_format,0,1) == "[") {
2658         $export_date_format = "%%Y%%m%%d-%%H%%M";
2659     }
2661     //Calculate the shortname
2662     $export_shortname = clean_filename($course->shortname);
2663     if (empty($export_shortname) or $export_shortname == '_' ) {
2664         $export_shortname = $course->id;
2665     }
2667     //Calculate the category name
2668     $export_categoryname = clean_filename($category->name);
2670     //Calculate the final export filename
2671     //The export word
2672     $export_name = $export_word."-";
2673     //The shortname
2674     $export_name .= moodle_strtolower($export_shortname)."-";
2675     //The category name
2676     $export_name .= moodle_strtolower($export_categoryname)."-";
2677     //The date format
2678     $export_name .= userdate(time(),$export_date_format,99,false);
2679     //Extension is supplied by format later.
2681     return $export_name;
2684 /**
2685  * @package moodlecore
2686  * @subpackage question
2687  * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
2688  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2689  */
2690 class context_to_string_translator{
2691     /**
2692      * @var array used to translate between contextids and strings for this context.
2693      */
2694     var $contexttostringarray = array();
2696     function context_to_string_translator($contexts){
2697         $this->generate_context_to_string_array($contexts);
2698     }
2700     function context_to_string($contextid){
2701         return $this->contexttostringarray[$contextid];
2702     }
2704     function string_to_context($contextname){
2705         $contextid = array_search($contextname, $this->contexttostringarray);
2706         return $contextid;
2707     }
2709     function generate_context_to_string_array($contexts){
2710         if (!$this->contexttostringarray){
2711             $catno = 1;
2712             foreach ($contexts as $context){
2713                 switch  ($context->contextlevel){
2714                     case CONTEXT_MODULE :
2715                         $contextstring = 'module';
2716                         break;
2717                     case CONTEXT_COURSE :
2718                         $contextstring = 'course';
2719                         break;
2720                     case CONTEXT_COURSECAT :
2721                         $contextstring = "cat$catno";
2722                         $catno++;
2723                         break;
2724                     case CONTEXT_SYSTEM :
2725                         $contextstring = 'system';
2726                         break;
2727                 }
2728                 $this->contexttostringarray[$context->id] = $contextstring;
2729             }
2730         }
2731     }
2735 /**
2736  * @return array all the capabilities that relate to accessing particular questions.
2737  */
2738 function question_get_question_capabilities() {
2739     return array(
2740         'moodle/question:add',
2741         'moodle/question:editmine',
2742         'moodle/question:editall',
2743         'moodle/question:viewmine',
2744         'moodle/question:viewall',
2745         'moodle/question:usemine',
2746         'moodle/question:useall',
2747         'moodle/question:movemine',
2748         'moodle/question:moveall',
2749     );
2752 /**
2753  * @return array all the question bank capabilities.
2754  */
2755 function question_get_all_capabilities() {
2756     $caps = question_get_question_capabilities();
2757     $caps[] = 'moodle/question:managecategory';
2758     $caps[] = 'moodle/question:flag';
2759     return $caps;
2762 /**
2763  * Check capability on category
2764  *
2765  * @global object
2766  * @global object
2767  * @param mixed $question object or id
2768  * @param string $cap 'add', 'edit', 'view', 'use', 'move'
2769  * @param integer $cachecat useful to cache all question records in a category
2770  * @return boolean this user has the capability $cap for this question $question?
2771  */
2772 function question_has_capability_on($question, $cap, $cachecat = -1){
2773     global $USER, $DB;
2775     // 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
2776     if ($question === false) {
2777         return true;
2778     }
2780     // these are capabilities on existing questions capabilties are
2781     //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
2782     $question_questioncaps = array('edit', 'view', 'use', 'move');
2783     static $questions = array();
2784     static $categories = array();
2785     static $cachedcat = array();
2786     if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){
2787         $questions += $DB->get_records('question', array('category'=>$cachecat));
2788         $cachedcat[] = $cachecat;
2789     }
2790     if (!is_object($question)){
2791         if (!isset($questions[$question])){
2792             if (!$questions[$question] = $DB->get_record('question', array('id'=>$question), 'id,category,createdby')) {
2793                 print_error('questiondoesnotexist', 'question');
2794             }
2795         }
2796         $question = $questions[$question];
2797     }
2798     if (!isset($categories[$question->category])){
2799         if (!$categories[$question->category] = $DB->get_record('question_categories', array('id'=>$question->category))) {
2800             print_error('invalidcategory', 'quiz');
2801         }
2802     }
2803     $category = $categories[$question->category];
2805     if (array_search($cap, $question_questioncaps)!== FALSE){
2806         if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){
2807             if ($question->createdby == $USER->id){
2808                 return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid));
2809             } else {
2810                 return false;
2811             }
2812         } else {
2813             return true;
2814         }
2815     } else {
2816         return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid));
2817     }
2821 /**
2822  * Require capability on question.
2823  */
2824 function question_require_capability_on($question, $cap){
2825     if (!question_has_capability_on($question, $cap)){
2826         print_error('nopermissions', '', '', $cap);
2827     }
2828     return true;
2831 /**
2832  * @global object
2833  */
2834 function question_file_links_base_url($courseid){
2835     global $CFG;
2836     $baseurl = preg_quote("$CFG->wwwroot/file.php", '!');
2837     $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not
2838                                      //be using slasharguments, accept either
2839     $baseurl .= "/$courseid/";//course directory
2840     return $baseurl;
2843 /**
2844  * Find all course / site files linked to in a piece of html.
2845  * @global object
2846  * @param string html the html to search
2847  * @param int course search for files for courseid course or set to siteid for
2848  *              finding site files.
2849  * @return array files with keys being files.
2850  */
2851 function question_find_file_links_from_html($html, $courseid){
2852     global $CFG;
2853     $baseurl = question_file_links_base_url($courseid);
2854     $searchfor = '!'.
2855                    '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'.
2856                    '|'.
2857                    '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''.
2858                   '!i';
2859     $matches = array();
2860     $no = preg_match_all($searchfor, $html, $matches);
2861     if ($no){
2862         $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements
2863         //remove any links that point somewhere they shouldn't
2864         foreach (array_keys($rawurls) as $rawurlkey){
2865             if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){
2866                 unset($rawurls[$rawurlkey]);
2867             } else {
2868                 $rawurls[$rawurlkey] = $cleanedurl;
2869             }
2871         }
2872         $urls = array_flip($rawurls);// array_flip removes duplicate files
2873                                             // and when we merge arrays will continue to automatically remove duplicates
2874     } else {
2875         $urls = array();
2876     }
2877     return $urls;
2880 /**
2881  * Check that url doesn't point anywhere it shouldn't
2882  *
2883  * @global object
2884  * @param $url string relative url within course files directory
2885  * @return mixed boolean false if not OK or cleaned URL as string if OK
2886  */
2887 function question_url_check($url){
2888     global $CFG;
2889     if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) ||
2890             (substr(strtolower($url), 0, 10) == 'backupdata')){
2891         return false;
2892     } else {
2893         return clean_param($url, PARAM_PATH);
2894     }
2897 /**
2898  * Find all course / site files linked to in a piece of html.
2899  *
2900  * @global object
2901  * @param string html the html to search
2902  * @param int course search for files for courseid course or set to siteid for
2903  *              finding site files.
2904  * @return array files with keys being files.
2905  */
2906 function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){
2907     global $CFG;
2908     require_once($CFG->libdir .'/filelib.php');
2909     $tourl = get_file_url("$tocourseid/$destination");
2910     $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!');
2911     $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i',
2912                    '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i');
2913     $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html);
2914     if ($newhtml != $html){
2915         $changed = true;
2916     }
2917     return $newhtml;
2920 /**
2921  * @global object
2922  */
2923 function get_filesdir_from_context($context){
2924     global $DB;
2926     switch ($context->contextlevel){
2927         case CONTEXT_COURSE :
2928             $courseid = $context->instanceid;
2929             break;
2930         case CONTEXT_MODULE :
2931             $courseid = $DB->get_field('course_modules', 'course', array('id'=>$context->instanceid));
2932             break;
2933         case CONTEXT_COURSECAT :
2934         case CONTEXT_SYSTEM :
2935             $courseid = SITEID;
2936             break;
2937         default :
2938             print_error('invalidcontext');
2939     }
2940     return $courseid;
2942 /**
2943  * Get the real state - the correct question id and answer - for a random
2944  * question.
2945  * @param object $state with property answer.
2946  * @return mixed return integer real question id or false if there was an
2947  * error..
2948  */
2949 function question_get_real_state($state){
2950     global $OUTPUT;
2951     $realstate = clone($state);
2952     $matches = array();
2953     if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)){
2954         echo $OUTPUT->notification(get_string('errorrandom', 'quiz_statistics'));
2955         return false;
2956     } else {
2957         $realstate->question = $matches[1];
2958         $realstate->answer = $matches[2];
2959         return $realstate;
2960     }
2963 /**
2964  * Update the flagged state of a particular question session.
2965  *
2966  * @global object
2967  * @param integer $sessionid question_session id.
2968  * @param boolean $newstate the new state for the flag.
2969  * @return boolean success or failure.
2970  */
2971 function question_update_flag($sessionid, $newstate) {
2972     global $DB;
2973     return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid));
2976 /**
2977  * Update the flagged state of all the questions in an attempt, where a new .
2978  *
2979  * @global object
2980  * @param integer $sessionid question_session id.
2981  * @param boolean $newstate the new state for the flag.
2982  * @return boolean success or failure.
2983  */
2984 function question_save_flags($formdata, $attemptid, $questionids) {
2985     global $DB;
2986     $donequestionids = array();
2987     foreach ($formdata as $postvariable => $value) {
2988         list($qid, $key) = question_id_and_key_from_post_name($postvariable);
2989         if ($qid !== false && in_array($qid, $questionids)) {
2990             if ($key == '_flagged') {
2991                 $DB->set_field('question_sessions', 'flagged', !empty($value),
2992                         array('attemptid' => $attemptid, 'questionid' => $qid));
2993                 $donequestionids[$qid] = 1;
2994             }
2995         }
2996     }
2997     foreach ($questionids as $qid) {
2998         if (!isset($donequestionids[$qid])) {
2999             $DB->set_field('question_sessions', 'flagged', 0,
3000                     array('attemptid' => $attemptid, 'questionid' => $qid));
3001         }
3002     }
3005 /**
3006  *
3007  * @global object
3008  * @param integer $attemptid the question_attempt id.
3009  * @param integer $questionid the question id.
3010  * @param integer $sessionid the question_session id.
3011  * @param object $user a user, or null to use $USER.
3012  * @return string that needs to be sent to question/toggleflag.php for it to work.
3013  */
3014 function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) {
3015     if (is_null($user)) {
3016         global $USER;
3017         $user = $USER;
3018     }
3019     return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid);
3022 /**
3023  * Adds question bank setting links to the given navigation node if caps are met.
3024  *
3025  * @param navigation_node $navigationnode The navigation node to add the question branch to
3026  * @param stdClass $context
3027  * @return navigation_node Returns the question branch that was added
3028  */
3029 function question_extend_settings_navigation(navigation_node $navigationnode, $context) {
3030     global $PAGE;
3032     if ($context->contextlevel == CONTEXT_COURSE) {
3033         $params = array('courseid'=>$context->instanceid);
3034     } else if ($context->contextlevel == CONTEXT_MODULE) {
3035         $params = array('cmid'=>$context->instanceid);
3036     } else {
3037         return;
3038     }
3040     $questionnode = $navigationnode->add(get_string('questionbank','question'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER);
3042     $contexts = new question_edit_contexts($context);
3043     if ($contexts->have_one_edit_tab_cap('questions')) {
3044         $questionnode->add(get_string('questions', 'quiz'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_SETTING);
3045     }
3046     if ($contexts->have_one_edit_tab_cap('categories')) {
3047         $questionnode->add(get_string('categories', 'quiz'), new moodle_url('/question/category.php', $params), navigation_node::TYPE_SETTING);
3048     }
3049     if ($contexts->have_one_edit_tab_cap('import')) {
3050         $questionnode->add(get_string('import', 'quiz'), new moodle_url('/question/import.php', $params), navigation_node::TYPE_SETTING);
3051     }
3052     if ($contexts->have_one_edit_tab_cap('export')) {
3053         $questionnode->add(get_string('export', 'quiz'), new moodle_url('/question/export.php', $params), navigation_node::TYPE_SETTING);
3054     }
3056     return $questionnode;
3059 class question_edit_contexts {
3061     public static $CAPS = array(
3062         'editq' => array('moodle/question:add',
3063             'moodle/question:editmine',
3064             'moodle/question:editall',
3065             'moodle/question:viewmine',
3066             'moodle/question:viewall',
3067             'moodle/question:usemine',
3068             'moodle/question:useall',
3069             'moodle/question:movemine',
3070             'moodle/question:moveall'),
3071         'questions'=>array('moodle/question:add',
3072             'moodle/question:editmine',
3073             'moodle/question:editall',
3074             'moodle/question:viewmine',
3075             'moodle/question:viewall',
3076             'moodle/question:movemine',
3077             'moodle/question:moveall'),
3078         'categories'=>array('moodle/question:managecategory'),
3079         'import'=>array('moodle/question:add'),
3080         'export'=>array('moodle/question:viewall', 'moodle/question:viewmine'));
3082     protected $allcontexts;
3084     /**
3085      * @param current context
3086      */
3087     public function question_edit_contexts($thiscontext){
3088         $pcontextids = get_parent_contexts($thiscontext);
3089         $contexts = array($thiscontext);
3090         foreach ($pcontextids as $pcontextid){
3091             $contexts[] = get_context_instance_by_id($pcontextid);
3092         }
3093         $this->allcontexts = $contexts;
3094     }
3095     /**
3096      * @return array all parent contexts
3097      */
3098     public function all(){
3099         return $this->allcontexts;
3100     }
3101     /**
3102      * @return object lowest context which must be either the module or course context
3103      */
3104     public function lowest(){
3105         return $this->allcontexts[0];
3106     }
3107     /**
3108      * @param string $cap capability
3109      * @return array parent contexts having capability, zero based index
3110      */
3111     public function having_cap($cap){
3112         $contextswithcap = array();
3113         foreach ($this->allcontexts as $context){
3114             if (has_capability($cap, $context)){
3115                 $contextswithcap[] = $context;
3116             }
3117         }
3118         return $contextswithcap;
3119     }
3120     /**
3121      * @param array $caps capabilities
3122      * @return array parent contexts having at least one of $caps, zero based index
3123      */
3124     public function having_one_cap($caps){
3125         $contextswithacap = array();
3126         foreach ($this->allcontexts as $context){
3127             foreach ($caps as $cap){
3128                 if (has_capability($cap, $context)){
3129                     $contextswithacap[] = $context;
3130                     break; //done with caps loop
3131                 }
3132             }
3133         }
3134         return $contextswithacap;
3135     }
3136     /**
3137      * @param string $tabname edit tab name
3138      * @return array parent contexts having at least one of $caps, zero based index
3139      */
3140     public function having_one_edit_tab_cap($tabname){
3141         return $this->having_one_cap(self::$CAPS[$tabname]);
3142     }
3143     /**
3144      * Has at least one parent context got the cap $cap?
3145      *
3146      * @param string $cap capability
3147      * @return boolean
3148      */
3149     public function have_cap($cap){
3150         return (count($this->having_cap($cap)));
3151     }
3153     /**
3154      * Has at least one parent context got one of the caps $caps?
3155      *
3156      * @param array $caps capability
3157      * @return boolean
3158      */
3159     public function have_one_cap($caps){
3160         foreach ($caps as $cap) {
3161             if ($this->have_cap($cap)) {
3162                 return true;
3163             }
3164         }
3165         return false;
3166     }
3167     /**
3168      * Has at least one parent context got one of the caps for actions on $tabname
3169      *
3170      * @param string $tabname edit tab name
3171      * @return boolean
3172      */
3173     public function have_one_edit_tab_cap($tabname){
3174         return $this->have_one_cap(self::$CAPS[$tabname]);
3175     }
3176     /**
3177      * Throw error if at least one parent context hasn't got the cap $cap
3178      *
3179      * @param string $cap capability
3180      */
3181     public function require_cap($cap){
3182         if (!$this->have_cap($cap)){
3183             print_error('nopermissions', '', '', $cap);
3184         }
3185     }
3186     /**
3187      * Throw error if at least one parent context hasn't got one of the caps $caps
3188      *
3189      * @param array $cap capabilities
3190      */
3191      public function require_one_cap($caps) {
3192         if (!$this->have_one_cap($caps)) {
3193             $capsstring = join($caps, ', ');
3194             print_error('nopermissions', '', '', $capsstring);
3195         }
3196     }
3198     /**
3199      * Throw error if at least one parent context hasn't got one of the caps $caps
3200      *
3201      * @param string $tabname edit tab name
3202      */
3203     public function require_one_edit_tab_cap($tabname){
3204         if (!$this->have_one_edit_tab_cap($tabname)) {
3205             print_error('nopermissions', '', '', 'access question edit tab '.$tabname);
3206         }
3207     }
3210 /**
3211  * Rewrite question url, file_rewrite_pluginfile_urls always build url by
3212  * $file/$contextid/$component/$filearea/$itemid/$pathname_in_text, so we cannot add
3213  * extra questionid and attempted in url by it, so we create quiz_rewrite_question_urls
3214  * to build url here
3215  *
3216  * @param string $text text being processed
3217  * @param string $file the php script used to serve files
3218  * @param int $contextid
3219  * @param string $component component
3220  * @param string $filearea filearea
3221  * @param array $ids other IDs will be used to check file permission
3222  * @param int $itemid
3223  * @param array $options
3224  * @return string
3225  */
3226 function quiz_rewrite_question_urls($text, $file, $contextid, $component, $filearea, array $ids, $itemid, array $options=null) {
3227     global $CFG;
3229     $options = (array)$options;
3230     if (!isset($options['forcehttps'])) {
3231         $options['forcehttps'] = false;
3232     }
3234     if (!$CFG->slasharguments) {
3235         $file = $file . '?file=';
3236     }
3238     $baseurl = "$CFG->wwwroot/$file/$contextid/$component/$filearea/";
3240     if (!empty($ids)) {
3241         $baseurl .= (implode('/', $ids) . '/');
3242     }
3244     if ($itemid !== null) {
3245         $baseurl .= "$itemid/";
3246     }
3248     if ($options['forcehttps']) {
3249         $baseurl = str_replace('http://', 'https://', $baseurl);
3250     }
3252     return str_replace('@@PLUGINFILE@@/', $baseurl, $text);
3255 /**
3256  * Called by pluginfile.php to serve files related to the 'question' core
3257  * component and for files belonging to qtypes.
3258  *
3259  * For files that relate to questions in a question_attempt, then we delegate to
3260  * a function in the component that owns the attempt (for example in the quiz,
3261  * or in core question preview) to get necessary inforation.
3262  *
3263  * (Note that, at the moment, all question file areas relate to questions in
3264  * attempts, so the If at the start of the last paragraph is always true.)
3265  *
3266  * Does not return, either calls send_file_not_found(); or serves the file.
3267  *
3268  * @param object $course course settings object
3269  * @param object $context context object
3270  * @param string $component the name of the component we are serving files for.
3271  * @param string $filearea the name of the file area.
3272  * @param array $args the remaining bits of the file path.
3273  * @param bool $forcedownload whether the user must be forced to download the file.
3274  */
3275 function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload) {
3276     global $DB, $CFG;
3278     $attemptid = (int)array_shift($args);
3279     $questionid = (int)array_shift($args);
3281     require_login($course, false);
3283     if ($attemptid === 0) {
3284         // preview
3285         require_once($CFG->dirroot . '/question/previewlib.php');
3286         return question_preview_question_pluginfile($course, $context,
3287                 $component, $filearea, $attemptid, $questionid, $args, $forcedownload);
3289     } else {
3290         $module = $DB->get_field('question_attempts', 'modulename',
3291                 array('id' => $attemptid));
3293         $dir = get_component_directory($module);
3294         if (!file_exists("$dir/lib.php")) {
3295             send_file_not_found();
3296         }
3297         include_once("$dir/lib.php");
3299         $filefunction = $module . '_question_pluginfile';
3300         if (!function_exists($filefunction)) {
3301             send_file_not_found();
3302         }
3304         $filefunction($course, $context, $component, $filearea, $attemptid, $questionid,
3305                 $args, $forcedownload);
3307         send_file_not_found();
3308     }
3311 /**
3312  * Final test for whether a studnet should be allowed to see a particular file.
3313  * This delegates the decision to the question type plugin.
3314  *
3315  * @param object $question The question to be rendered.
3316  * @param object $state    The state to render the question in.
3317  * @param object $options  An object specifying the rendering options.
3318  * @param string $component the name of the component we are serving files for.
3319  * @param string $filearea the name of the file area.
3320  * @param array $args the remaining bits of the file path.
3321  * @param bool $forcedownload whether the user must be forced to download the file.
3322  */
3323 function question_check_file_access($question, $state, $options, $contextid, $component,
3324         $filearea, $args, $forcedownload) {
3325     global $QTYPES;
3326     return $QTYPES[$question->qtype]->check_file_access($question, $state, $options, $contextid, $component,
3327             $filearea, $args, $forcedownload);