MDL-20636 Finish off converting question import.
[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  * @package moodlecore
30  * @subpackage questionbank
31  * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
32  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33  */
36 require_once($CFG->dirroot . '/question/engine/lib.php');
37 require_once($CFG->dirroot . '/question/type/questiontype.php');
40 defined('MOODLE_INTERNAL') || die();
42 /// CONSTANTS ///////////////////////////////////
44 /**#@+
45  * The core question types.
46  */
47 define("SHORTANSWER",   "shortanswer");
48 define("TRUEFALSE",     "truefalse");
49 define("MULTICHOICE",   "multichoice");
50 define("RANDOM",        "random");
51 define("MATCH",         "match");
52 define("RANDOMSAMATCH", "randomsamatch");
53 define("DESCRIPTION",   "description");
54 define("NUMERICAL",     "numerical");
55 define("MULTIANSWER",   "multianswer");
56 define("CALCULATED",    "calculated");
57 define("ESSAY",         "essay");
58 /**#@-*/
60 /**
61  * Constant determines the number of answer boxes supplied in the editing
62  * form for multiple choice and similar question types.
63  */
64 define("QUESTION_NUMANS", 10);
66 /**
67  * Constant determines the number of answer boxes supplied in the editing
68  * form for multiple choice and similar question types to start with, with
69  * the option of adding QUESTION_NUMANS_ADD more answers.
70  */
71 define("QUESTION_NUMANS_START", 3);
73 /**
74  * Constant determines the number of answer boxes to add in the editing
75  * form for multiple choice and similar question types when the user presses
76  * 'add form fields button'.
77  */
78 define("QUESTION_NUMANS_ADD", 3);
80 /**
81  * The options used when popping up a question preview window in Javascript.
82  */
83 define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=800,height=600');
85 /**
86  * @global array holding question type objects
87  * @deprecated
88  */
89 global $QTYPES;
90 $QTYPES = question_bank::get_all_qtypes();
91 function question_register_questiontype() {
92     // TODO kill this.
93 }
94 // TODO kill this.
95 class default_questiontype {
96     function plugin_dir() {
97         return '';
98     }
99 }
101 /**
102  * An array of question type names translated to the user's language, suitable for use when
103  * creating a drop-down menu of options.
104  *
105  * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
106  * The array returned will only hold the names of all the question types that the user should
107  * be able to create directly. Some internal question types like random questions are excluded.
108  *
109  * @return array an array of question type names translated to the user's language.
110  */
111 function question_type_menu() {
112     static $menuoptions = null;
113     if (is_null($menuoptions)) {
114         $config = get_config('question');
115         $menuoptions = array();
116         foreach (question_bank::get_all_qtypes() as $name => $qtype) {
117             $menuname = $qtype->menu_name();
118             $enabledvar = $name . '_disabled';
119             if ($menuname && !isset($config->$enabledvar)) {
120                 $menuoptions[$name] = $menuname;
121             }
122         }
124         $menuoptions = question_sort_qtype_array($menuoptions, $config);
125     }
126     return $menuoptions;
129 /**
130  * Sort an array of question type names according to the question type sort order stored in
131  * config_plugins. Entries for which there is no xxx_sortorder defined will go
132  * at the end, sorted according to textlib_get_instance()->asort($inarray).
133  * @param $inarray an array $qtypename => $qtype->local_name().
134  * @param $config get_config('question'), if you happen to have it around, to save one DB query.
135  * @return array the sorted version of $inarray.
136  */
137 function question_sort_qtype_array($inarray, $config = null) {
138     if (is_null($config)) {
139         $config = get_config('question');
140     }
142     $sortorder = array();
143     foreach ($inarray as $name => $notused) {
144         $sortvar = $name . '_sortorder';
145         if (isset($config->$sortvar)) {
146             $sortorder[$config->$sortvar] = $name;
147         }
148     }
150     ksort($sortorder);
151     $outarray = array();
152     foreach ($sortorder as $name) {
153         $outarray[$name] = $inarray[$name];
154         unset($inarray[$name]);
155     }
156     textlib_get_instance()->asort($inarray);
157     return array_merge($outarray, $inarray);
160 /**
161  * Move one question type in a list of question types. If you try to move one element
162  * off of the end, nothing will change.
163  *
164  * @param array $sortedqtypes An array $qtype => anything.
165  * @param string $tomove one of the keys from $sortedqtypes
166  * @param integer $direction +1 or -1
167  * @return array an array $index => $qtype, with $index from 0 to n in order, and
168  *      the $qtypes in the same order as $sortedqtypes, except that $tomove will
169  *      have been moved one place.
170  */
171 function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
172     $neworder = array_keys($sortedqtypes);
173     // Find the element to move.
174     $key = array_search($tomove, $neworder);
175     if ($key === false) {
176         return $neworder;
177     }
178     // Work out the other index.
179     $otherkey = $key + $direction;
180     if (!isset($neworder[$otherkey])) {
181         return $neworder;
182     }
183     // Do the swap.
184     $swap = $neworder[$otherkey];
185     $neworder[$otherkey] = $neworder[$key];
186     $neworder[$key] = $swap;
187     return $neworder;
190 /**
191  * Save a new question type order to the config_plugins table.
192  * @global object
193  * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
194  * @param $config get_config('question'), if you happen to have it around, to save one DB query.
195  */
196 function question_save_qtype_order($neworder, $config = null) {
197     global $DB;
199     if (is_null($config)) {
200         $config = get_config('question');
201     }
203     foreach ($neworder as $index => $qtype) {
204         $sortvar = $qtype . '_sortorder';
205         if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
206             set_config($sortvar, $index + 1, 'question');
207         }
208     }
211 /// FUNCTIONS //////////////////////////////////////////////////////
213 /**
214  * Returns an array of names of activity modules that use this question
215  *
216  * @deprecated since Moodle 2.1. Use {@link questions_in_use} instead.
218  * @param object $questionid
219  * @return array of strings
220  */
221 function question_list_instances($questionid) {
222     throw new coding_exception('question_list_instances has been deprectated. Please use questions_in_use instead.');
225 /**
226  * @param array $questionids of question ids.
227  * @return boolean whether any of these questions are being used by any part of Moodle.
228  */
229 function questions_in_use($questionids) {
230     global $CFG;
232     if (question_engine::questions_in_use($questionids)) {
233         return true;
234     }
236     foreach (get_plugin_list('mod') as $module => $path) {
237         $lib = $path . '/lib.php';
238         if (is_readable($lib)) {
239             include_once($lib);
241             $fn = $module . '_questions_in_use';
242             if (function_exists($fn)) {
243                 if ($fn($questionids)) {
244                     return true;
245                 }
246             } else {
248                 // Fallback for legacy modules.
249                 $fn = $module . '_question_list_instances';
250                 if (function_exists($fn)) {
251                     foreach ($questionids as $questionid) {
252                         $instances = $fn($questionid);
253                         if (!empty($instances)) {
254                             return true;
255                         }
256                     }
257                 }
258             }
259         }
260     }
262     return false;
265 /**
266  * Determine whether there arey any questions belonging to this context, that is whether any of its
267  * question categories contain any questions. This will return true even if all the questions are
268  * hidden.
269  *
270  * @param mixed $context either a context object, or a context id.
271  * @return boolean whether any of the question categories beloning to this context have
272  *         any questions in them.
273  */
274 function question_context_has_any_questions($context) {
275     global $DB;
276     if (is_object($context)) {
277         $contextid = $context->id;
278     } else if (is_numeric($context)) {
279         $contextid = $context;
280     } else {
281         print_error('invalidcontextinhasanyquestions', 'question');
282     }
283     return $DB->record_exists_sql("SELECT *
284                                      FROM {question} q
285                                      JOIN {question_categories} qc ON qc.id = q.category
286                                     WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
289 /**
290  * Returns list of 'allowed' grades for grade selection
291  * formatted suitably for dropdown box function
292  * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
293  */
294 function get_grade_options() {
295     // define basic array of grades. This list comprises all fractions of the form:
296     // a. p/q for q <= 6, 0 <= p <= q
297     // b. p/10 for 0 <= p <= 10
298     // c. 1/q for 1 <= q <= 10
299     // d. 1/20
300     $grades = array(
301         1.0000000,
302         0.9000000,
303         0.8333333,
304         0.8000000,
305         0.7500000,
306         0.7000000,
307         0.6666667,
308         0.6000000,
309         0.5000000,
310         0.4000000,
311         0.3333333,
312         0.3000000,
313         0.2500000,
314         0.2000000,
315         0.1666667,
316         0.1428571,
317         0.1250000,
318         0.1111111,
319         0.1000000,
320         0.0500000,
321         0.0000000);
323     // iterate through grades generating full range of options
324     $gradeoptionsfull = array();
325     $gradeoptions = array();
326     foreach ($grades as $grade) {
327         $percentage = 100 * $grade;
328         $gradeoptions["$grade"] = $percentage . '%';
329         $gradeoptionsfull["$grade"] = $percentage . '%';
330         $gradeoptionsfull['' . (-$grade)] = (-$percentage) . '%';
331     }
332     $gradeoptionsfull['0'] = $gradeoptions['0'] = get_string('none');
334     // sort lists
335     arsort($gradeoptions, SORT_NUMERIC);
336     arsort($gradeoptionsfull, SORT_NUMERIC);
338     // construct return object
339     $grades = new stdClass;
340     $grades->gradeoptions = $gradeoptions;
341     $grades->gradeoptionsfull = $gradeoptionsfull;
343     return $grades;
346 /**
347  * match grade options
348  * if no match return error or match nearest
349  * @param array $gradeoptionsfull list of valid options
350  * @param int $grade grade to be tested
351  * @param string $matchgrades 'error' or 'nearest'
352  * @return mixed either 'fixed' value or false if erro
353  */
354 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
355     // if we just need an error...
356     if ($matchgrades=='error') {
357         foreach($gradeoptionsfull as $value => $option) {
358             // slightly fuzzy test, never check floats for equality :-)
359             if (abs($grade-$value)<0.00001) {
360                 return $grade;
361             }
362         }
363         // didn't find a match so that's an error
364         return false;
365     }
366     // work out nearest value
367     else if ($matchgrades=='nearest') {
368         $hownear = array();
369         foreach($gradeoptionsfull as $value => $option) {
370             if ($grade==$value) {
371                 return $grade;
372             }
373             $hownear[ $value ] = abs( $grade - $value );
374         }
375         // reverse sort list of deltas and grab the last (smallest)
376         asort( $hownear, SORT_NUMERIC );
377         reset( $hownear );
378         return key( $hownear );
379     }
380     else {
381         return false;
382     }
385 /**
386  * @deprecated Since Moodle 2.1. Use {@link question_category_in_use} instead.
387  * @param integer $categoryid a question category id.
388  * @param boolean $recursive whether to check child categories too.
389  * @return boolean whether any question in this category is in use.
390  */
391 function question_category_isused($categoryid, $recursive = false) {
392     throw new coding_exception('question_category_isused has been deprectated. Please use question_category_in_use instead.');
395 /**
396  * Tests whether any question in a category is used by any part of Moodle.
397  *
398  * @param integer $categoryid a question category id.
399  * @param boolean $recursive whether to check child categories too.
400  * @return boolean whether any question in this category is in use.
401  */
402 function question_category_in_use($categoryid, $recursive = false) {
403     global $DB;
405     //Look at each question in the category
406     if ($questions = $DB->get_records_menu('question', array('category' => $categoryid), '', 'id,1')) {
407         if (questions_in_use(array_keys($questions))) {
408             return true;
409         }
410     }
411     if (!$recursive) {
412         return false;
413     }
415     //Look under child categories recursively
416     if ($children = $DB->get_records('question_categories', array('parent' => $categoryid), '', 'id,1')) {
417         foreach ($children as $child) {
418             if (question_category_in_use($child->id, $recursive)) {
419                 return true;
420             }
421         }
422     }
424     return false;
427 /**
428  * Deletes question and all associated data from the database
429  *
430  * It will not delete a question if it is used by an activity module
431  * @param object $question  The question being deleted
432  */
433 function question_delete_question($questionid) {
434     global $DB;
436     $question = $DB->get_record_sql('
437             SELECT q.*, qc.contextid
438             FROM {question} q
439             JOIN {question_categories} qc ON qc.id = q.category
440             WHERE q.id = ?', array($questionid));
441     if (!$question) {
442         // In some situations, for example if this was a child of a
443         // Cloze question that was previously deleted, the question may already
444         // have gone. In this case, just do nothing.
445         return;
446     }
448     // Do not delete a question if it is used by an activity module
449     if (questions_in_use(array($questionid))) {
450         return;
451     }
453     // Check permissions.
454     question_require_capability_on($question, 'edit');
456     $dm = new question_engine_data_mapper();
457     $dm->delete_previews($questionid);
459     // delete questiontype-specific data
460     question_bank::get_qtype($question->qtype, false)->delete_question(
461             $questionid, $question->contextid);
463     // Now recursively delete all child questions
464     if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id,qtype')) {
465         foreach ($children as $child) {
466             if ($child->id != $questionid) {
467                 delete_question($child->id);
468             }
469         }
470     }
472     // Finally delete the question record itself
473     $DB->delete_records('question', array('id' => $questionid));
476 /**
477  * All question categories and their questions are deleted for this course.
478  *
479  * @param object $mod an object representing the activity
480  * @param boolean $feedback to specify if the process must output a summary of its work
481  * @return boolean
482  */
483 function question_delete_course($course, $feedback=true) {
484     global $DB, $OUTPUT;
486     //To store feedback to be showed at the end of the process
487     $feedbackdata   = array();
489     //Cache some strings
490     $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
491     $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
492     $categoriescourse = $DB->get_records('question_categories', array('contextid'=>$coursecontext->id), 'parent', 'id, parent, name, contextid');
494     if ($categoriescourse) {
496         //Sort categories following their tree (parent-child) relationships
497         //this will make the feedback more readable
498         $categoriescourse = sort_categories_by_tree($categoriescourse);
500         foreach ($categoriescourse as $category) {
502             //Delete it completely (questions and category itself)
503             //deleting questions
504             if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
505                 foreach ($questions as $question) {
506                     delete_question($question->id);
507                 }
508                 $DB->delete_records("question", array("category"=>$category->id));
509             }
510             //delete the category
511             $DB->delete_records('question_categories', array('id'=>$category->id));
513             //Fill feedback
514             $feedbackdata[] = array($category->name, $strcatdeleted);
515         }
516         //Inform about changes performed if feedback is enabled
517         if ($feedback) {
518             $table = new html_table();
519             $table->head = array(get_string('category','quiz'), get_string('action'));
520             $table->data = $feedbackdata;
521             echo html_writer::table($table);
522         }
523     }
524     return true;
527 /**
528  * Category is about to be deleted,
529  * 1/ All question categories and their questions are deleted for this course category.
530  * 2/ All questions are moved to new category
531  *
532  * @param object $category course category object
533  * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
534  * @param boolean $feedback to specify if the process must output a summary of its work
535  * @return boolean
536  */
537 function question_delete_course_category($category, $newcategory, $feedback=true) {
538     global $DB, $OUTPUT;
540     $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
541     if (empty($newcategory)) {
542         $feedbackdata   = array(); // To store feedback to be showed at the end of the process
543         $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
544         $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
546         // Loop over question categories.
547         if ($categories = $DB->get_records('question_categories', array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
548             foreach ($categories as $category) {
550                 // Deal with any questions in the category.
551                 if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
553                     // Try to delete each question.
554                     foreach ($questions as $question) {
555                         delete_question($question->id);
556                     }
558                     // Check to see if there were any questions that were kept because they are
559                     // still in use somehow, even though quizzes in courses in this category will
560                     // already have been deteted. This could happen, for example, if questions are
561                     // added to a course, and then that course is moved to another category (MDL-14802).
562                     $questionids = $DB->get_records_menu('question', array('category'=>$category->id), '', 'id,1');
563                     if (!empty($questionids)) {
564                         if (!$rescueqcategory = question_save_from_deletion(array_keys($questionids),
565                                 get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
566                             return false;
567                        }
568                        $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
569                     }
570                 }
572                 // Now delete the category.
573                 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
574                     return false;
575                 }
576                 $feedbackdata[] = array($category->name, $strcatdeleted);
578             } // End loop over categories.
579         }
581         // Output feedback if requested.
582         if ($feedback and $feedbackdata) {
583             $table = new html_table();
584             $table->head = array(get_string('questioncategory','question'), get_string('action'));
585             $table->data = $feedbackdata;
586             echo html_writer::table($table);
587         }
589     } else {
590         // Move question categories ot the new context.
591         if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
592             return false;
593         }
594         $DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid'=>$context->id));
595         if ($feedback) {
596             $a = new stdClass;
597             $a->oldplace = print_context_name($context);
598             $a->newplace = print_context_name($newcontext);
599             echo $OUTPUT->notification(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
600         }
601     }
603     return true;
606 /**
607  * Enter description here...
608  *
609  * @param string $questionids list of questionids
610  * @param object $newcontext the context to create the saved category in.
611  * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
612  * @param object $newcategory
613  * @return mixed false on
614  */
615 function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
616     global $DB;
618     // Make a category in the parent context to move the questions to.
619     if (is_null($newcategory)) {
620         $newcategory = new stdClass();
621         $newcategory->parent = 0;
622         $newcategory->contextid = $newcontextid;
623         $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
624         $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
625         $newcategory->sortorder = 999;
626         $newcategory->stamp = make_unique_id_code();
627         $newcategory->id = $DB->insert_record('question_categories', $newcategory);
628     }
630     // Move any remaining questions to the 'saved' category.
631     if (!question_move_questions_to_category($questionids, $newcategory->id)) {
632         return false;
633     }
634     return $newcategory;
637 /**
638  * All question categories and their questions are deleted for this activity.
639  *
640  * @param object $cm the course module object representing the activity
641  * @param boolean $feedback to specify if the process must output a summary of its work
642  * @return boolean
643  */
644 function question_delete_activity($cm, $feedback=true) {
645     global $DB, $OUTPUT;
647     //To store feedback to be showed at the end of the process
648     $feedbackdata   = array();
650     //Cache some strings
651     $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
652     $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
653     if ($categoriesmods = $DB->get_records('question_categories', array('contextid'=>$modcontext->id), 'parent', 'id, parent, name, contextid')){
654         //Sort categories following their tree (parent-child) relationships
655         //this will make the feedback more readable
656         $categoriesmods = sort_categories_by_tree($categoriesmods);
658         foreach ($categoriesmods as $category) {
660             //Delete it completely (questions and category itself)
661             //deleting questions
662             if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
663                 foreach ($questions as $question) {
664                     delete_question($question->id);
665                 }
666                 $DB->delete_records("question", array("category"=>$category->id));
667             }
668             //delete the category
669             $DB->delete_records('question_categories', array('id'=>$category->id));
671             //Fill feedback
672             $feedbackdata[] = array($category->name, $strcatdeleted);
673         }
674         //Inform about changes performed if feedback is enabled
675         if ($feedback) {
676             $table = new html_table();
677             $table->head = array(get_string('category','quiz'), get_string('action'));
678             $table->data = $feedbackdata;
679             echo html_writer::table($table);
680         }
681     }
682     return true;
685 /**
686  * This function should be considered private to the question bank, it is called from
687  * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
688  * acutally moving questions and associated data. However, callers of this function also have to
689  * do other work, which is why you should not call this method directly from outside the questionbank.
690  *
691  * @param string $questionids a comma-separated list of question ids.
692  * @param integer $newcategoryid the id of the category to move to.
693  */
694 function question_move_questions_to_category($questionids, $newcategoryid) {
695     global $DB, $QTYPES;
697     $newcontextid = $DB->get_field('question_categories', 'contextid',
698             array('id' => $newcategoryid));
699     list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
700     $questions = $DB->get_records_sql("
701             SELECT q.id, q.qtype, qc.contextid
702               FROM {question} q
703               JOIN {question_categories} qc ON q.category = qc.id
704              WHERE  q.id $questionidcondition", $params);
705     foreach ($questions as $question) {
706         if ($newcontextid != $question->contextid) {
707             $QTYPES[$question->qtype]->move_files($question->id,
708                     $question->contextid, $newcontextid);
709         }
710     }
712     // Move the questions themselves.
713     $DB->set_field_select('question', 'category', $newcategoryid, "id $questionidcondition", $params);
715     // Move any subquestions belonging to them.
716     $DB->set_field_select('question', 'category', $newcategoryid, "parent $questionidcondition", $params);
718     // TODO Deal with datasets.
720     return true;
723 /**
724  * This function helps move a question cateogry to a new context by moving all
725  * the files belonging to all the questions to the new context.
726  * Also moves subcategories.
727  * @param integer $categoryid the id of the category being moved.
728  * @param integer $oldcontextid the old context id.
729  * @param integer $newcontextid the new context id.
730  */
731 function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) {
732     global $DB, $QTYPES;
734     $questionids = $DB->get_records_menu('question',
735             array('category' => $categoryid), '', 'id,qtype');
736     foreach ($questionids as $questionid => $qtype) {
737         $QTYPES[$qtype]->move_files($questionid, $oldcontextid, $newcontextid);
738     }
740     $subcatids = $DB->get_records_menu('question_categories',
741             array('parent' => $categoryid), '', 'id,1');
742     foreach ($subcatids as $subcatid => $notused) {
743         $DB->set_field('question_categories', 'contextid', $newcontextid, array('id' => $subcatid));
744         question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
745     }
748 /**
749  * Generate the URL for starting a new preview of a given question with the given options.
750  * @param integer $questionid the question to preview.
751  * @param string $preferredbehaviour the behaviour to use for the preview.
752  * @param float $maxmark the maximum to mark the question out of.
753  * @param question_display_options $displayoptions the display options to use.
754  * @return string the URL.
755  */
756 function question_preview_url($questionid, $preferredbehaviour, $maxmark, $displayoptions) {
757     return new moodle_url('/question/preview.php', array(
758             'id'              => $questionid,
759             'behaviour'       => $preferredbehaviour,
760             'maxmark'         => $maxmark,
761             'correctness'     => $displayoptions->correctness,
762             'marks'           => $displayoptions->marks,
763             'markdp'          => $displayoptions->markdp,
764             'feedback'        => (bool) $displayoptions->feedback,
765             'generalfeedback' => (bool) $displayoptions->generalfeedback,
766             'rightanswer'     => (bool) $displayoptions->rightanswer,
767             'history'         => (bool) $displayoptions->history));
770 /**
771  * Given a list of ids, load the basic information about a set of questions from the questions table.
772  * The $join and $extrafields arguments can be used together to pull in extra data.
773  * See, for example, the usage in mod/quiz/attemptlib.php, and
774  * read the code below to see how the SQL is assembled. Throws exceptions on error.
775  *
776  * @global object
777  * @global object
778  * @param array $questionids array of question ids.
779  * @param string $extrafields extra SQL code to be added to the query.
780  * @param string $join extra SQL code to be added to the query.
781  * @param array $extraparams values for any placeholders in $join.
782  * You are strongly recommended to use named placeholder.
783  *
784  * @return array partially complete question objects. You need to call get_question_options
785  * on them before they can be properly used.
786  */
787 function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) {
788     global $DB;
789     if (empty($questionids)) {
790         return array();
791     }
792     if ($join) {
793         $join = ' JOIN '.$join;
794     }
795     if ($extrafields) {
796         $extrafields = ', ' . $extrafields;
797     }
798     list($questionidcondition, $params) = $DB->get_in_or_equal(
799             $questionids, SQL_PARAMS_NAMED, 'qid0000');
800     $sql = 'SELECT q.*, qc.contextid' . $extrafields . ' FROM {question} q
801             JOIN {question_categories} qc ON q.category = qc.id' .
802             $join .
803           ' WHERE q.id ' . $questionidcondition;
805     // Load the questions
806     if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
807         return array();
808     }
810     foreach ($questions as $question) {
811         $question->_partiallyloaded = true;
812     }
814     // Note, a possible optimisation here would be to not load the TEXT fields
815     // (that is, questiontext and generalfeedback) here, and instead load them in
816     // question_load_questions. That would add one DB query, but reduce the amount
817     // of data transferred most of the time. I am not going to do this optimisation
818     // until it is shown to be worthwhile.
820     return $questions;
823 /**
824  * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
825  * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
826  * read the code below to see how the SQL is assembled. Throws exceptions on error.
827  *
828  * @param array $questionids array of question ids.
829  * @param string $extrafields extra SQL code to be added to the query.
830  * @param string $join extra SQL code to be added to the query.
831  * @param array $extraparams values for any placeholders in $join.
832  * You are strongly recommended to use named placeholder.
833  *
834  * @return array question objects.
835  */
836 function question_load_questions($questionids, $extrafields = '', $join = '') {
837     $questions = question_preload_questions($questionids, $extrafields, $join);
839     // Load the question type specific information
840     if (!get_question_options($questions)) {
841         return 'Could not load the question options';
842     }
844     return $questions;
847 /**
848  * Private function to factor common code out of get_question_options().
849  *
850  * @param object $question the question to tidy.
851  * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
852  */
853 function _tidy_question($question, $loadtags = false) {
854     global $CFG, $QTYPES;
855     if (!array_key_exists($question->qtype, $QTYPES)) {
856         $question->qtype = 'missingtype';
857         $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
858     }
859     $QTYPES[$question->qtype]->get_question_options($question);
860     if (isset($question->_partiallyloaded)) {
861         unset($question->_partiallyloaded);
862     }
863     if ($loadtags && !empty($CFG->usetags)) {
864         require_once($CFG->dirroot . '/tag/lib.php');
865         $question->tags = tag_get_tags_array('question', $question->id);
866     }
869 /**
870  * Updates the question objects with question type specific
871  * information by calling {@link get_question_options()}
872  *
873  * Can be called either with an array of question objects or with a single
874  * question object.
875  *
876  * @param mixed $questions Either an array of question objects to be updated
877  *         or just a single question object
878  * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
879  * @return bool Indicates success or failure.
880  */
881 function get_question_options(&$questions, $loadtags = false) {
882     if (is_array($questions)) { // deal with an array of questions
883         foreach ($questions as $i => $notused) {
884             _tidy_question($questions[$i], $loadtags);
885         }
886     } else { // deal with single question
887         _tidy_question($questions, $loadtags);
888     }
889     return true;
892 /**
893 * Print the icon for the question type
895 * @param object $question The question object for which the icon is required.
896 *       Only $question->qtype is used.
897 * @return string the HTML for the img tag.
898 */
899 function print_question_icon($question) {
900     global $OUTPUT;
902     $qtype = question_bank::get_qtype($question->qtype, false);
903     $namestr = $qtype->menu_name();
905     // TODO convert to return a moodle_icon object, or whatever the class is.
906     $html = '<img src="' . $OUTPUT->pix_url('icon', $qtype->plugin_name()) . '" alt="' .
907             $namestr . '" title="' . $namestr . '" />';
909     return $html;
912 /**
913  * Creates a stamp that uniquely identifies this version of the question
914  *
915  * In future we want this to use a hash of the question data to guarantee that
916  * identical versions have the same version stamp.
917  *
918  * @param object $question
919  * @return string A unique version stamp
920  */
921 function question_hash($question) {
922     return make_unique_id_code();
925 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
926 /**
927  * Get anything that needs to be included in the head of the question editing page
928  * for a particular question type. This function is called by question/question.php.
929  *
930  * @param $question A question object. Only $question->qtype is used.
931  * @return string Deprecated. Some HTML code that can go inside the head tag.
932  */
933 function question_get_editing_head_contributions($question) {
934     question_bank::get_qtype($question->qtype, false)->get_editing_head_contributions();
937 /**
938  * Saves question options
939  *
940  * Simply calls the question type specific save_question_options() method.
941  */
942 function save_question_options($question) {
943     global $QTYPES;
945     $QTYPES[$question->qtype]->save_question_options($question);
948 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
950 /**
951  * returns the categories with their names ordered following parent-child relationships
952  * finally it tries to return pending categories (those being orphaned, whose parent is
953  * incorrect) to avoid missing any category from original array.
954  */
955 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
956     global $DB;
958     $children = array();
959     $keys = array_keys($categories);
961     foreach ($keys as $key) {
962         if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
963             $children[$key] = $categories[$key];
964             $categories[$key]->processed = true;
965             $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
966         }
967     }
968     //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
969     if ($level == 1) {
970         foreach ($keys as $key) {
971             // If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
972             if (!isset($categories[$key]->processed) && !$DB->record_exists(
973                     'question_categories', array('contextid'=>$categories[$key]->contextid, 'id'=>$categories[$key]->parent))) {
974                 $children[$key] = $categories[$key];
975                 $categories[$key]->processed = true;
976                 $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
977             }
978         }
979     }
980     return $children;
983 /**
984  * Private method, only for the use of add_indented_names().
985  *
986  * Recursively adds an indentedname field to each category, starting with the category
987  * with id $id, and dealing with that category and all its children, and
988  * return a new array, with those categories in the right order.
989  *
990  * @param array $categories an array of categories which has had childids
991  *          fields added by flatten_category_tree(). Passed by reference for
992  *          performance only. It is not modfied.
993  * @param int $id the category to start the indenting process from.
994  * @param int $depth the indent depth. Used in recursive calls.
995  * @return array a new array of categories, in the right order for the tree.
996  */
997 function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
999     // Indent the name of this category.
1000     $newcategories = array();
1001     $newcategories[$id] = $categories[$id];
1002     $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
1004     // Recursively indent the children.
1005     foreach ($categories[$id]->childids as $childid) {
1006         if ($childid != $nochildrenof){
1007             $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
1008         }
1009     }
1011     // Remove the childids array that were temporarily added.
1012     unset($newcategories[$id]->childids);
1014     return $newcategories;
1017 /**
1018  * Format categories into an indented list reflecting the tree structure.
1019  *
1020  * @param array $categories An array of category objects, for example from the.
1021  * @return array The formatted list of categories.
1022  */
1023 function add_indented_names($categories, $nochildrenof = -1) {
1025     // Add an array to each category to hold the child category ids. This array will be removed
1026     // again by flatten_category_tree(). It should not be used outside these two functions.
1027     foreach (array_keys($categories) as $id) {
1028         $categories[$id]->childids = array();
1029     }
1031     // Build the tree structure, and record which categories are top-level.
1032     // We have to be careful, because the categories array may include published
1033     // categories from other courses, but not their parents.
1034     $toplevelcategoryids = array();
1035     foreach (array_keys($categories) as $id) {
1036         if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
1037             $categories[$categories[$id]->parent]->childids[] = $id;
1038         } else {
1039             $toplevelcategoryids[] = $id;
1040         }
1041     }
1043     // Flatten the tree to and add the indents.
1044     $newcategories = array();
1045     foreach ($toplevelcategoryids as $id) {
1046         $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
1047     }
1049     return $newcategories;
1052 /**
1053  * Output a select menu of question categories.
1054  *
1055  * Categories from this course and (optionally) published categories from other courses
1056  * are included. Optionally, only categories the current user may edit can be included.
1057  *
1058  * @param integer $courseid the id of the course to get the categories for.
1059  * @param integer $published if true, include publised categories from other courses.
1060  * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1061  * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
1062  */
1063 function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
1064     global $OUTPUT;
1065     $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
1066     if ($selected) {
1067         $choose = '';
1068     } else {
1069         $choose = 'choosedots';
1070     }
1071     $options = array();
1072     foreach($categoriesarray as $group=>$opts) {
1073         $options[] = array($group=>$opts);
1074     }
1076     echo html_writer::select($options, 'category', $selected, $choose);
1079 /**
1080  * @param integer $contextid a context id.
1081  * @return object the default question category for that context, or false if none.
1082  */
1083 function question_get_default_category($contextid) {
1084     global $DB;
1085     $category = $DB->get_records('question_categories', array('contextid' => $contextid),'id','*',0,1);
1086     if (!empty($category)) {
1087         return reset($category);
1088     } else {
1089         return false;
1090     }
1093 /**
1094 * Gets the default category in the most specific context.
1095 * If no categories exist yet then default ones are created in all contexts.
1097 * @param array $contexts  The context objects for this context and all parent contexts.
1098 * @return object The default category - the category in the course context
1099 */
1100 function question_make_default_categories($contexts) {
1101     global $DB;
1102     static $preferredlevels = array(
1103         CONTEXT_COURSE => 4,
1104         CONTEXT_MODULE => 3,
1105         CONTEXT_COURSECAT => 2,
1106         CONTEXT_SYSTEM => 1,
1107     );
1109     $toreturn = null;
1110     $preferredness = 0;
1111     // If it already exists, just return it.
1112     foreach ($contexts as $key => $context) {
1113         if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))) {
1114             // Otherwise, we need to make one
1115             $category = new stdClass;
1116             $contextname = print_context_name($context, false, true);
1117             $category->name = get_string('defaultfor', 'question', $contextname);
1118             $category->info = get_string('defaultinfofor', 'question', $contextname);
1119             $category->contextid = $context->id;
1120             $category->parent = 0;
1121             $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
1122             $category->stamp = make_unique_id_code();
1123             $category->id = $DB->insert_record('question_categories', $category);
1124         } else {
1125             $category = question_get_default_category($context->id);
1126         }
1127         if ($preferredlevels[$context->contextlevel] > $preferredness &&
1128                 has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
1129             $toreturn = $category;
1130             $preferredness = $preferredlevels[$context->contextlevel];
1131         }
1132     }
1134     if (!is_null($toreturn)) {
1135         $toreturn = clone($toreturn);
1136     }
1137     return $toreturn;
1140 /**
1141  * Get all the category objects, including a count of the number of questions in that category,
1142  * for all the categories in the lists $contexts.
1143  *
1144  * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
1145  * @param string $sortorder used as the ORDER BY clause in the select statement.
1146  * @return array of category objects.
1147  */
1148 function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
1149     global $DB;
1150     return $DB->get_records_sql("
1151             SELECT c.*, (SELECT count(1) FROM {question} q
1152                         WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
1153               FROM {question_categories} c
1154              WHERE c.contextid IN ($contexts)
1155           ORDER BY $sortorder");
1158 /**
1159  * Output an array of question categories.
1160  */
1161 function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
1162     global $CFG;
1163     $pcontexts = array();
1164     foreach($contexts as $context){
1165         $pcontexts[] = $context->id;
1166     }
1167     $contextslist = join($pcontexts, ', ');
1169     $categories = get_categories_for_contexts($contextslist);
1171     $categories = question_add_context_in_key($categories);
1173     if ($top){
1174         $categories = question_add_tops($categories, $pcontexts);
1175     }
1176     $categories = add_indented_names($categories, $nochildrenof);
1178     //sort cats out into different contexts
1179     $categoriesarray = array();
1180     foreach ($pcontexts as $pcontext){
1181         $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
1182         foreach ($categories as $category) {
1183             if ($category->contextid == $pcontext){
1184                 $cid = $category->id;
1185                 if ($currentcat!= $cid || $currentcat==0) {
1186                     $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
1187                     $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
1188                 }
1189             }
1190         }
1191     }
1192     if ($popupform){
1193         $popupcats = array();
1194         foreach ($categoriesarray as $contextstring => $optgroup){
1195             $group = array();
1196             foreach ($optgroup as $key=>$value) {
1197                 $key = str_replace($CFG->wwwroot, '', $key);
1198                 $group[$key] = $value;
1199             }
1200             $popupcats[] = array($contextstring=>$group);
1201         }
1202         return $popupcats;
1203     } else {
1204         return $categoriesarray;
1205     }
1208 function question_add_context_in_key($categories){
1209     $newcatarray = array();
1210     foreach ($categories as $id => $category) {
1211         $category->parent = "$category->parent,$category->contextid";
1212         $category->id = "$category->id,$category->contextid";
1213         $newcatarray["$id,$category->contextid"] = $category;
1214     }
1215     return $newcatarray;
1218 function question_add_tops($categories, $pcontexts){
1219     $topcats = array();
1220     foreach ($pcontexts as $context){
1221         $newcat = new stdClass();
1222         $newcat->id = "0,$context";
1223         $newcat->name = get_string('top');
1224         $newcat->parent = -1;
1225         $newcat->contextid = $context;
1226         $topcats["0,$context"] = $newcat;
1227     }
1228     //put topcats in at beginning of array - they'll be sorted into different contexts later.
1229     return array_merge($topcats, $categories);
1232 /**
1233  * Returns a comma separated list of ids of the category and all subcategories
1234  */
1235 function question_categorylist($categoryid) {
1236     global $DB;
1238     // returns a comma separated list of ids of the category and all subcategories
1239     $categorylist = $categoryid;
1240     if ($subcategories = $DB->get_records('question_categories', array('parent' => $categoryid), 'sortorder ASC', 'id, 1')) {
1241         foreach ($subcategories as $subcategory) {
1242             $categorylist .= ','. question_categorylist($subcategory->id);
1243         }
1244     }
1245     return $categorylist;
1248 //===========================
1249 // Import/Export Functions
1250 //===========================
1252 /**
1253  * Get list of available import or export formats
1254  * @param string $type 'import' if import list, otherwise export list assumed
1255  * @return array sorted list of import/export formats available
1256  */
1257 function get_import_export_formats($type) {
1258     global $CFG;
1260     $fileformats = get_plugin_list('qformat');
1262     $fileformatname = array();
1263     require_once($CFG->dirroot . '/question/format.php');
1264     foreach ($fileformats as $fileformat => $fdir) {
1265         $formatfile = $fdir . '/format.php';
1266         if (is_readable($formatfile)) {
1267             include_once($formatfile);
1268         } else {
1269             continue;
1270         }
1272         $classname = 'qformat_' . $fileformat;
1273         $formatclass = new $classname();
1274         if ($type == 'import') {
1275             $provided = $formatclass->provide_import();
1276         } else {
1277             $provided = $formatclass->provide_export();
1278         }
1280         if ($provided) {
1281             $fileformatnames[$fileformat] = get_string($fileformat, 'qformat_' . $fileformat);
1282         }
1283     }
1285     textlib_get_instance()->asort($fileformatnames);
1286     return $fileformatnames;
1290 /**
1291 * Create a reasonable default file name for exporting questions from a particular
1292 * category.
1293 * @param object $course the course the questions are in.
1294 * @param object $category the question category.
1295 * @return string the filename.
1296 */
1297 function question_default_export_filename($course, $category) {
1298     // We build a string that is an appropriate name (questions) from the lang pack,
1299     // then the corse shortname, then the question category name, then a timestamp. 
1301     $base = clean_filename(get_string('exportfilename', 'question'));
1303     $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question'));
1304     $timestamp = clean_filename(userdate(time(), $dateformat, 99, false));
1306     $shortname = clean_filename($course->shortname);
1307     if ($shortname == '' || $shortname == '_' ) {
1308         $shortname = $course->id;
1309     }
1311     $categoryname = clean_filename(format_string($category->name));
1313     return "{$base}-{$shortname}-{$categoryname}-{$timestamp}";
1315     return $export_name;
1318 /**
1319  * Converts contextlevels to strings and back to help with reading/writing contexts
1320  * to/from import/export files.
1321  *
1322  * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
1323  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1324  */
1325 class context_to_string_translator{
1326     /**
1327      * @var array used to translate between contextids and strings for this context.
1328      */
1329     protected $contexttostringarray = array();
1331     public function __construct($contexts) {
1332         $this->generate_context_to_string_array($contexts);
1333     }
1335     public function context_to_string($contextid) {
1336         return $this->contexttostringarray[$contextid];
1337     }
1339     public function string_to_context($contextname) {
1340         $contextid = array_search($contextname, $this->contexttostringarray);
1341         return $contextid;
1342     }
1344     protected function generate_context_to_string_array($contexts) {
1345         if (!$this->contexttostringarray){
1346             $catno = 1;
1347             foreach ($contexts as $context){
1348                 switch ($context->contextlevel){
1349                     case CONTEXT_MODULE :
1350                         $contextstring = 'module';
1351                         break;
1352                     case CONTEXT_COURSE :
1353                         $contextstring = 'course';
1354                         break;
1355                     case CONTEXT_COURSECAT :
1356                         $contextstring = "cat$catno";
1357                         $catno++;
1358                         break;
1359                     case CONTEXT_SYSTEM :
1360                         $contextstring = 'system';
1361                         break;
1362                 }
1363                 $this->contexttostringarray[$context->id] = $contextstring;
1364             }
1365         }
1366     }
1370 /**
1371  * Check capability on category
1372  *
1373  * @param mixed $question object or id
1374  * @param string $cap 'add', 'edit', 'view', 'use', 'move'
1375  * @param integer $cachecat useful to cache all question records in a category
1376  * @return boolean this user has the capability $cap for this question $question?
1377  */
1378 function question_has_capability_on($question, $cap, $cachecat = -1){
1379     global $USER, $DB;
1381     // these are capabilities on existing questions capabilties are
1382     //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
1383     $question_questioncaps = array('edit', 'view', 'use', 'move');
1384     static $questions = array();
1385     static $categories = array();
1386     static $cachedcat = array();
1387     if ($cachecat != -1 && array_search($cachecat, $cachedcat) === false) {
1388         $questions += $DB->get_records('question', array('category' => $cachecat));
1389         $cachedcat[] = $cachecat;
1390     }
1391     if (!is_object($question)){
1392         if (!isset($questions[$question])){
1393             if (!$questions[$question] = $DB->get_record('question', array('id' => $question), 'id,category,createdby')) {
1394                 print_error('questiondoesnotexist', 'question');
1395             }
1396         }
1397         $question = $questions[$question];
1398     }
1399     if (!isset($categories[$question->category])){
1400         if (!$categories[$question->category] = $DB->get_record('question_categories', array('id'=>$question->category))) {
1401             print_error('invalidcategory', 'quiz');
1402         }
1403     }
1404     $category = $categories[$question->category];
1405     $context = get_context_instance_by_id($category->contextid);
1407     if (array_search($cap, $question_questioncaps)!== FALSE){
1408         if (!has_capability('moodle/question:'.$cap.'all', $context)){
1409             if ($question->createdby == $USER->id){
1410                 return has_capability('moodle/question:'.$cap.'mine', $context);
1411             } else {
1412                 return false;
1413             }
1414         } else {
1415             return true;
1416         }
1417     } else {
1418         return has_capability('moodle/question:'.$cap, $context);
1419     }
1423 /**
1424  * Require capability on question.
1425  */
1426 function question_require_capability_on($question, $cap){
1427     if (!question_has_capability_on($question, $cap)){
1428         print_error('nopermissions', '', '', $cap);
1429     }
1430     return true;
1433 /**
1434  * Get the real state - the correct question id and answer - for a random
1435  * question.
1436  * @param object $state with property answer.
1437  * @return mixed return integer real question id or false if there was an
1438  * error..
1439  */
1440 function question_get_real_state($state) {
1441     global $OUTPUT;
1442     $realstate = clone($state);
1443     $matches = array();
1444     if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)){
1445         echo $OUTPUT->notification(get_string('errorrandom', 'quiz_statistics'));
1446         return false;
1447     } else {
1448         $realstate->question = $matches[1];
1449         $realstate->answer = $matches[2];
1450         return $realstate;
1451     }
1454 /**
1455  * @param object $context a context
1456  * @return string A URL for editing questions in this context.
1457  */
1458 function question_edit_url($context) {
1459     global $CFG, $SITE;
1460     if (!has_any_capability(question_get_question_capabilities(), $context)) {
1461         return false;
1462     }
1463     $baseurl = $CFG->wwwroot . '/question/edit.php?';
1464     $defaultcategory = question_get_default_category($context->id);
1465     if ($defaultcategory) {
1466         $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
1467     }
1468     switch ($context->contextlevel) {
1469         case CONTEXT_SYSTEM:
1470             return $baseurl . 'courseid=' . $SITE->id;
1471         case CONTEXT_COURSECAT:
1472             // This is nasty, becuase we can only edit questions in a course
1473             // context at the moment, so for now we just return false.
1474             return false;
1475         case CONTEXT_COURSE:
1476             return $baseurl . 'courseid=' . $context->instanceid;
1477         case CONTEXT_MODULE:
1478             return $baseurl . 'cmid=' . $context->instanceid;
1479     }
1483 /**
1484  * Adds question bank setting links to the given navigation node if caps are met.
1485  *
1486  * @param navigation_node $navigationnode The navigation node to add the question branch to
1487  * @param stdClass $context
1488  * @return navigation_node Returns the question branch that was added
1489  */
1490 function question_extend_settings_navigation(navigation_node $navigationnode, $context) {
1491     global $PAGE;
1493     if ($context->contextlevel == CONTEXT_COURSE) {
1494         $params = array('courseid'=>$context->instanceid);
1495     } else if ($context->contextlevel == CONTEXT_MODULE) {
1496         $params = array('cmid'=>$context->instanceid);
1497     } else {
1498         return;
1499     }
1501     $questionnode = $navigationnode->add(get_string('questionbank','question'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER);
1503     $contexts = new question_edit_contexts($context);
1504     if ($contexts->have_one_edit_tab_cap('questions')) {
1505         $questionnode->add(get_string('questions', 'quiz'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_SETTING);
1506     }
1507     if ($contexts->have_one_edit_tab_cap('categories')) {
1508         $questionnode->add(get_string('categories', 'quiz'), new moodle_url('/question/category.php', $params), navigation_node::TYPE_SETTING);
1509     }
1510     if ($contexts->have_one_edit_tab_cap('import')) {
1511         $questionnode->add(get_string('import', 'quiz'), new moodle_url('/question/import.php', $params), navigation_node::TYPE_SETTING);
1512     }
1513     if ($contexts->have_one_edit_tab_cap('export')) {
1514         $questionnode->add(get_string('export', 'quiz'), new moodle_url('/question/export.php', $params), navigation_node::TYPE_SETTING);
1515     }
1517     return $questionnode;
1520 /**
1521  * @return array all the capabilities that relate to accessing particular questions.
1522  */
1523 function question_get_question_capabilities() {
1524     return array(
1525         'moodle/question:add',
1526         'moodle/question:editmine',
1527         'moodle/question:editall',
1528         'moodle/question:viewmine',
1529         'moodle/question:viewall',
1530         'moodle/question:usemine',
1531         'moodle/question:useall',
1532         'moodle/question:movemine',
1533         'moodle/question:moveall',
1534     );
1537 /**
1538  * @return array all the question bank capabilities.
1539  */
1540 function question_get_all_capabilities() {
1541     $caps = question_get_question_capabilities();
1542     $caps[] = 'moodle/question:managecategory';
1543     $caps[] = 'moodle/question:flag';
1544     return $caps;
1547 class question_edit_contexts {
1549     public static $CAPS = array(
1550         'editq' => array('moodle/question:add',
1551             'moodle/question:editmine',
1552             'moodle/question:editall',
1553             'moodle/question:viewmine',
1554             'moodle/question:viewall',
1555             'moodle/question:usemine',
1556             'moodle/question:useall',
1557             'moodle/question:movemine',
1558             'moodle/question:moveall'),
1559         'questions'=>array('moodle/question:add',
1560             'moodle/question:editmine',
1561             'moodle/question:editall',
1562             'moodle/question:viewmine',
1563             'moodle/question:viewall',
1564             'moodle/question:movemine',
1565             'moodle/question:moveall'),
1566         'categories'=>array('moodle/question:managecategory'),
1567         'import'=>array('moodle/question:add'),
1568         'export'=>array('moodle/question:viewall', 'moodle/question:viewmine'));
1570     protected $allcontexts;
1572     /**
1573      * @param current context
1574      */
1575     public function question_edit_contexts($thiscontext) {
1576         $pcontextids = get_parent_contexts($thiscontext);
1577         $contexts = array($thiscontext);
1578         foreach ($pcontextids as $pcontextid){
1579             $contexts[] = get_context_instance_by_id($pcontextid);
1580         }
1581         $this->allcontexts = $contexts;
1582     }
1583     /**
1584      * @return array all parent contexts
1585      */
1586     public function all() {
1587         return $this->allcontexts;
1588     }
1589     /**
1590      * @return object lowest context which must be either the module or course context
1591      */
1592     public function lowest() {
1593         return $this->allcontexts[0];
1594     }
1595     /**
1596      * @param string $cap capability
1597      * @return array parent contexts having capability, zero based index
1598      */
1599     public function having_cap($cap) {
1600         $contextswithcap = array();
1601         foreach ($this->allcontexts as $context){
1602             if (has_capability($cap, $context)){
1603                 $contextswithcap[] = $context;
1604             }
1605         }
1606         return $contextswithcap;
1607     }
1608     /**
1609      * @param array $caps capabilities
1610      * @return array parent contexts having at least one of $caps, zero based index
1611      */
1612     public function having_one_cap($caps) {
1613         $contextswithacap = array();
1614         foreach ($this->allcontexts as $context){
1615             foreach ($caps as $cap){
1616                 if (has_capability($cap, $context)){
1617                     $contextswithacap[] = $context;
1618                     break; //done with caps loop
1619                 }
1620             }
1621         }
1622         return $contextswithacap;
1623     }
1624     /**
1625      * @param string $tabname edit tab name
1626      * @return array parent contexts having at least one of $caps, zero based index
1627      */
1628     public function having_one_edit_tab_cap($tabname) {
1629         return $this->having_one_cap(self::$CAPS[$tabname]);
1630     }
1631     /**
1632      * Has at least one parent context got the cap $cap?
1633      *
1634      * @param string $cap capability
1635      * @return boolean
1636      */
1637     public function have_cap($cap) {
1638         return (count($this->having_cap($cap)));
1639     }
1641     /**
1642      * Has at least one parent context got one of the caps $caps?
1643      *
1644      * @param array $caps capability
1645      * @return boolean
1646      */
1647     public function have_one_cap($caps) {
1648         foreach ($caps as $cap) {
1649             if ($this->have_cap($cap)) {
1650                 return true;
1651             }
1652         }
1653         return false;
1654     }
1656     /**
1657      * Has at least one parent context got one of the caps for actions on $tabname
1658      *
1659      * @param string $tabname edit tab name
1660      * @return boolean
1661      */
1662     public function have_one_edit_tab_cap($tabname){
1663         return $this->have_one_cap(self::$CAPS[$tabname]);
1664     }
1666     /**
1667      * Throw error if at least one parent context hasn't got the cap $cap
1668      *
1669      * @param string $cap capability
1670      */
1671     public function require_cap($cap){
1672         if (!$this->have_cap($cap)){
1673             print_error('nopermissions', '', '', $cap);
1674         }
1675     }
1677     /**
1678      * Throw error if at least one parent context hasn't got one of the caps $caps
1679      *
1680      * @param array $cap capabilities
1681      */
1682      public function require_one_cap($caps) {
1683         if (!$this->have_one_cap($caps)) {
1684             $capsstring = join($caps, ', ');
1685             print_error('nopermissions', '', '', $capsstring);
1686         }
1687     }
1689     /**
1690      * Throw error if at least one parent context hasn't got one of the caps $caps
1691      *
1692      * @param string $tabname edit tab name
1693      */
1694     public function require_one_edit_tab_cap($tabname){
1695         if (!$this->have_one_edit_tab_cap($tabname)) {
1696             print_error('nopermissions', '', '', 'access question edit tab '.$tabname);
1697         }
1698     }
1701 /**
1702  * Rewrite question url, file_rewrite_pluginfile_urls always build url by
1703  * $file/$contextid/$component/$filearea/$itemid/$pathname_in_text, so we cannot add
1704  * extra questionid and attempted in url by it, so we create quiz_rewrite_question_urls
1705  * to build url here
1706  *
1707  * @param string $text text being processed
1708  * @param string $file the php script used to serve files
1709  * @param int $contextid
1710  * @param string $component component
1711  * @param string $filearea filearea
1712  * @param array $ids other IDs will be used to check file permission
1713  * @param int $itemid
1714  * @param array $options
1715  * @return string
1716  */
1717 function question_rewrite_question_urls($text, $file, $contextid, $component, $filearea, array $ids, $itemid, array $options=null) {
1718     global $CFG;
1720     $options = (array)$options;
1721     if (!isset($options['forcehttps'])) {
1722         $options['forcehttps'] = false;
1723     }
1725     if (!$CFG->slasharguments) {
1726         $file = $file . '?file=';
1727     }
1729     $baseurl = "$CFG->wwwroot/$file/$contextid/$component/$filearea/";
1731     if (!empty($ids)) {
1732         $baseurl .= (implode('/', $ids) . '/');
1733     }
1735     if ($itemid !== null) {
1736         $baseurl .= "$itemid/";
1737     }
1739     if ($options['forcehttps']) {
1740         $baseurl = str_replace('http://', 'https://', $baseurl);
1741     }
1743     return str_replace('@@PLUGINFILE@@/', $baseurl, $text);
1746 /**
1747  * Called by pluginfile.php to serve files related to the 'question' core
1748  * component and for files belonging to qtypes.
1749  *
1750  * For files that relate to questions in a question_attempt, then we delegate to
1751  * a function in the component that owns the attempt (for example in the quiz,
1752  * or in core question preview) to get necessary inforation.
1753  *
1754  * (Note that, at the moment, all question file areas relate to questions in
1755  * attempts, so the If at the start of the last paragraph is always true.)
1756  *
1757  * Does not return, either calls send_file_not_found(); or serves the file.
1758  *
1759  * @param object $course course settings object
1760  * @param object $context context object
1761  * @param string $component the name of the component we are serving files for.
1762  * @param string $filearea the name of the file area.
1763  * @param array $args the remaining bits of the file path.
1764  * @param bool $forcedownload whether the user must be forced to download the file.
1765  */
1766 function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload) {
1767     global $DB, $CFG;
1769     list($context, $course, $cm) = get_context_info_array($context->id);
1770     require_login($course, false, $cm);
1772     if ($filearea === 'export') {
1773         require_once($CFG->dirroot . '/question/editlib.php');
1774         $contexts = new question_edit_contexts($context);
1775         // check export capability
1776         $contexts->require_one_edit_tab_cap('export');
1777         $category_id = (int)array_shift($args);
1778         $format      = array_shift($args);
1779         $cattofile   = array_shift($args);
1780         $contexttofile = array_shift($args);
1781         $filename    = array_shift($args);
1783         // load parent class for import/export
1784         require_once($CFG->dirroot . '/question/format.php');
1785         require_once($CFG->dirroot . '/question/editlib.php');
1786         require_once($CFG->dirroot . '/question/format/' . $format . '/format.php');
1788         $classname = 'qformat_' . $format;
1789         if (!class_exists($classname)) {
1790             send_file_not_found();
1791         }
1793         $qformat = new $classname();
1795         if (!$category = $DB->get_record('question_categories', array('id' => $category_id))) {
1796             send_file_not_found();
1797         }
1799         $qformat->setCategory($category);
1800         $qformat->setContexts($contexts->having_one_edit_tab_cap('export'));
1801         $qformat->setCourse($course);
1803         if ($cattofile == 'withcategories') {
1804             $qformat->setCattofile(true);
1805         } else {
1806             $qformat->setCattofile(false);
1807         }
1809         if ($contexttofile == 'withcontexts') {
1810             $qformat->setContexttofile(true);
1811         } else {
1812             $qformat->setContexttofile(false);
1813         }
1815         if (!$qformat->exportpreprocess()) {
1816             send_file_not_found();
1817             print_error('exporterror', 'question', $thispageurl->out());
1818         }
1820         // export data to moodle file pool
1821         if (!$content = $qformat->exportprocess(true)) {
1822             send_file_not_found();
1823         }
1825         //DEBUG
1826         //echo '<textarea cols=90 rows=20>';
1827         //echo $content;
1828         //echo '</textarea>';
1829         //die;
1830         send_file($content, $filename, 0, 0, true, true, $qformat->mime_type());
1831     }
1833     $qubaid = (int)array_shift($args);
1834     $slot = (int)array_shift($args);
1836     $module = $DB->get_field('question_usages', 'component',
1837             array('id' => $qubaid));
1839     if ($module === 'core_question_preview') {
1840         require_once($CFG->dirroot . '/question/previewlib.php');
1841         return question_preview_question_pluginfile($course, $context,
1842                 $component, $filearea, $qubaid, $slot, $args, $forcedownload);
1844     } else {
1845         $dir = get_component_directory($module);
1846         if (!file_exists("$dir/lib.php")) {
1847             send_file_not_found();
1848         }
1849         include_once("$dir/lib.php");
1851         $filefunction = $module . '_question_pluginfile';
1852         if (!function_exists($filefunction)) {
1853             send_file_not_found();
1854         }
1856         $filefunction($course, $context, $component, $filearea, $qubaid, $slot,
1857                 $args, $forcedownload);
1859         send_file_not_found();
1860     }
1863 /**
1864  * Create url for question export
1865  *
1866  * @param int $contextid, current context
1867  * @param int $categoryid, categoryid
1868  * @param string $format
1869  * @param string $withcategories
1870  * @param string $ithcontexts
1871  * @param moodle_url export file url
1872  */
1873 function question_make_export_url($contextid, $categoryid, $format, $withcategories, $withcontexts, $filename) {
1874     global $CFG;
1875     $urlbase = "$CFG->httpswwwroot/pluginfile.php";
1876     return moodle_url::make_file_url($urlbase, "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}/{$withcontexts}/{$filename}", true);