MDL-27588 Fixed up several bugs with the formal_white theme
[moodle.git] / lib / questionlib.php
blob9b47a4b3929b4f3f65aa664382c1787dcb2539d0
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Code for handling and processing questions
20 * This is code that is module independent, i.e., can be used by any module that
21 * uses questions, like quiz, lesson, ..
22 * This script also loads the questiontype classes
23 * Code for handling the editing of questions is in {@link question/editlib.php}
25 * TODO: separate those functions which form part of the API
26 * from the helper functions.
28 * @package moodlecore
29 * @subpackage questionbank
30 * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
31 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35 defined('MOODLE_INTERNAL') || die();
37 require_once($CFG->dirroot . '/question/engine/lib.php');
38 require_once($CFG->dirroot . '/question/type/questiontypebase.php');
42 /// CONSTANTS ///////////////////////////////////
44 /**#@+
45 * The core question types.
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.
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.
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'.
78 define("QUESTION_NUMANS_ADD", 3);
80 /**
81 * Move one question type in a list of question types. If you try to move one element
82 * off of the end, nothing will change.
84 * @param array $sortedqtypes An array $qtype => anything.
85 * @param string $tomove one of the keys from $sortedqtypes
86 * @param integer $direction +1 or -1
87 * @return array an array $index => $qtype, with $index from 0 to n in order, and
88 * the $qtypes in the same order as $sortedqtypes, except that $tomove will
89 * have been moved one place.
91 function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
92 $neworder = array_keys($sortedqtypes);
93 // Find the element to move.
94 $key = array_search($tomove, $neworder);
95 if ($key === false) {
96 return $neworder;
98 // Work out the other index.
99 $otherkey = $key + $direction;
100 if (!isset($neworder[$otherkey])) {
101 return $neworder;
103 // Do the swap.
104 $swap = $neworder[$otherkey];
105 $neworder[$otherkey] = $neworder[$key];
106 $neworder[$key] = $swap;
107 return $neworder;
111 * Save a new question type order to the config_plugins table.
112 * @global object
113 * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
114 * @param $config get_config('question'), if you happen to have it around, to save one DB query.
116 function question_save_qtype_order($neworder, $config = null) {
117 global $DB;
119 if (is_null($config)) {
120 $config = get_config('question');
123 foreach ($neworder as $index => $qtype) {
124 $sortvar = $qtype . '_sortorder';
125 if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
126 set_config($sortvar, $index + 1, 'question');
131 /// FUNCTIONS //////////////////////////////////////////////////////
134 * Returns an array of names of activity modules that use this question
136 * @deprecated since Moodle 2.1. Use {@link questions_in_use} instead.
138 * @param object $questionid
139 * @return array of strings
141 function question_list_instances($questionid) {
142 throw new coding_exception('question_list_instances has been deprectated. ' .
143 'Please use questions_in_use instead.');
147 * @param array $questionids of question ids.
148 * @return boolean whether any of these questions are being used by any part of Moodle.
150 function questions_in_use($questionids) {
151 global $CFG;
153 if (question_engine::questions_in_use($questionids)) {
154 return true;
157 foreach (get_plugin_list('mod') as $module => $path) {
158 $lib = $path . '/lib.php';
159 if (is_readable($lib)) {
160 include_once($lib);
162 $fn = $module . '_questions_in_use';
163 if (function_exists($fn)) {
164 if ($fn($questionids)) {
165 return true;
167 } else {
169 // Fallback for legacy modules.
170 $fn = $module . '_question_list_instances';
171 if (function_exists($fn)) {
172 foreach ($questionids as $questionid) {
173 $instances = $fn($questionid);
174 if (!empty($instances)) {
175 return true;
183 return false;
187 * Determine whether there arey any questions belonging to this context, that is whether any of its
188 * question categories contain any questions. This will return true even if all the questions are
189 * hidden.
191 * @param mixed $context either a context object, or a context id.
192 * @return boolean whether any of the question categories beloning to this context have
193 * any questions in them.
195 function question_context_has_any_questions($context) {
196 global $DB;
197 if (is_object($context)) {
198 $contextid = $context->id;
199 } else if (is_numeric($context)) {
200 $contextid = $context;
201 } else {
202 print_error('invalidcontextinhasanyquestions', 'question');
204 return $DB->record_exists_sql("SELECT *
205 FROM {question} q
206 JOIN {question_categories} qc ON qc.id = q.category
207 WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
211 * Returns list of 'allowed' grades for grade selection
212 * formatted suitably for dropdown box function
213 * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
215 function get_grade_options() {
216 // define basic array of grades. This list comprises all fractions of the form:
217 // a. p/q for q <= 6, 0 <= p <= q
218 // b. p/10 for 0 <= p <= 10
219 // c. 1/q for 1 <= q <= 10
220 // d. 1/20
221 $grades = array(
222 1.0000000,
223 0.9000000,
224 0.8333333,
225 0.8000000,
226 0.7500000,
227 0.7000000,
228 0.6666667,
229 0.6000000,
230 0.5000000,
231 0.4000000,
232 0.3333333,
233 0.3000000,
234 0.2500000,
235 0.2000000,
236 0.1666667,
237 0.1428571,
238 0.1250000,
239 0.1111111,
240 0.1000000,
241 0.0500000,
242 0.0000000);
244 // iterate through grades generating full range of options
245 $gradeoptionsfull = array();
246 $gradeoptions = array();
247 foreach ($grades as $grade) {
248 $percentage = 100 * $grade;
249 $gradeoptions["$grade"] = $percentage . '%';
250 $gradeoptionsfull["$grade"] = $percentage . '%';
251 $gradeoptionsfull['' . (-$grade)] = (-$percentage) . '%';
253 $gradeoptionsfull['0'] = $gradeoptions['0'] = get_string('none');
255 // sort lists
256 arsort($gradeoptions, SORT_NUMERIC);
257 arsort($gradeoptionsfull, SORT_NUMERIC);
259 // construct return object
260 $grades = new stdClass();
261 $grades->gradeoptions = $gradeoptions;
262 $grades->gradeoptionsfull = $gradeoptionsfull;
264 return $grades;
268 * match grade options
269 * if no match return error or match nearest
270 * @param array $gradeoptionsfull list of valid options
271 * @param int $grade grade to be tested
272 * @param string $matchgrades 'error' or 'nearest'
273 * @return mixed either 'fixed' value or false if erro
275 function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
276 if ($matchgrades == 'error') {
277 // if we just need an error...
278 foreach ($gradeoptionsfull as $value => $option) {
279 // slightly fuzzy test, never check floats for equality :-)
280 if (abs($grade - $value) < 0.00001) {
281 return $grade;
284 // didn't find a match so that's an error
285 return false;
286 } else if ($matchgrades == 'nearest') {
287 // work out nearest value
288 $hownear = array();
289 foreach ($gradeoptionsfull as $value => $option) {
290 if ($grade==$value) {
291 return $grade;
293 $hownear[ $value ] = abs( $grade - $value );
295 // reverse sort list of deltas and grab the last (smallest)
296 asort( $hownear, SORT_NUMERIC );
297 reset( $hownear );
298 return key( $hownear );
299 } else {
300 return false;
305 * @deprecated Since Moodle 2.1. Use {@link question_category_in_use} instead.
306 * @param integer $categoryid a question category id.
307 * @param boolean $recursive whether to check child categories too.
308 * @return boolean whether any question in this category is in use.
310 function question_category_isused($categoryid, $recursive = false) {
311 throw new coding_exception('question_category_isused has been deprectated. ' .
312 'Please use question_category_in_use instead.');
316 * Tests whether any question in a category is used by any part of Moodle.
318 * @param integer $categoryid a question category id.
319 * @param boolean $recursive whether to check child categories too.
320 * @return boolean whether any question in this category is in use.
322 function question_category_in_use($categoryid, $recursive = false) {
323 global $DB;
325 //Look at each question in the category
326 if ($questions = $DB->get_records_menu('question',
327 array('category' => $categoryid), '', 'id, 1')) {
328 if (questions_in_use(array_keys($questions))) {
329 return true;
332 if (!$recursive) {
333 return false;
336 //Look under child categories recursively
337 if ($children = $DB->get_records('question_categories',
338 array('parent' => $categoryid), '', 'id, 1')) {
339 foreach ($children as $child) {
340 if (question_category_in_use($child->id, $recursive)) {
341 return true;
346 return false;
350 * Deletes question and all associated data from the database
352 * It will not delete a question if it is used by an activity module
353 * @param object $question The question being deleted
355 function question_delete_question($questionid) {
356 global $DB;
358 $question = $DB->get_record_sql('
359 SELECT q.*, qc.contextid
360 FROM {question} q
361 JOIN {question_categories} qc ON qc.id = q.category
362 WHERE q.id = ?', array($questionid));
363 if (!$question) {
364 // In some situations, for example if this was a child of a
365 // Cloze question that was previously deleted, the question may already
366 // have gone. In this case, just do nothing.
367 return;
370 // Do not delete a question if it is used by an activity module
371 if (questions_in_use(array($questionid))) {
372 return;
375 // Check permissions.
376 question_require_capability_on($question, 'edit');
378 $dm = new question_engine_data_mapper();
379 $dm->delete_previews($questionid);
381 // delete questiontype-specific data
382 question_bank::get_qtype($question->qtype, false)->delete_question(
383 $questionid, $question->contextid);
385 // Now recursively delete all child questions
386 if ($children = $DB->get_records('question',
387 array('parent' => $questionid), '', 'id, qtype')) {
388 foreach ($children as $child) {
389 if ($child->id != $questionid) {
390 question_delete_question($child->id);
395 // Finally delete the question record itself
396 $DB->delete_records('question', array('id' => $questionid));
400 * All question categories and their questions are deleted for this course.
402 * @param object $mod an object representing the activity
403 * @param boolean $feedback to specify if the process must output a summary of its work
404 * @return boolean
406 function question_delete_course($course, $feedback=true) {
407 global $DB, $OUTPUT;
409 //To store feedback to be showed at the end of the process
410 $feedbackdata = array();
412 //Cache some strings
413 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
414 $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
415 $categoriescourse = $DB->get_records('question_categories',
416 array('contextid' => $coursecontext->id), 'parent', 'id, parent, name, contextid');
418 if ($categoriescourse) {
420 //Sort categories following their tree (parent-child) relationships
421 //this will make the feedback more readable
422 $categoriescourse = sort_categories_by_tree($categoriescourse);
424 foreach ($categoriescourse as $category) {
426 //Delete it completely (questions and category itself)
427 //deleting questions
428 if ($questions = $DB->get_records('question',
429 array('category' => $category->id), '', 'id,qtype')) {
430 foreach ($questions as $question) {
431 question_delete_question($question->id);
433 $DB->delete_records("question", array("category" => $category->id));
435 //delete the category
436 $DB->delete_records('question_categories', array('id' => $category->id));
438 //Fill feedback
439 $feedbackdata[] = array($category->name, $strcatdeleted);
441 //Inform about changes performed if feedback is enabled
442 if ($feedback) {
443 $table = new html_table();
444 $table->head = array(get_string('category', 'quiz'), get_string('action'));
445 $table->data = $feedbackdata;
446 echo html_writer::table($table);
449 return true;
453 * Category is about to be deleted,
454 * 1/ All question categories and their questions are deleted for this course category.
455 * 2/ All questions are moved to new category
457 * @param object $category course category object
458 * @param object $newcategory empty means everything deleted, otherwise id of
459 * category where content moved
460 * @param boolean $feedback to specify if the process must output a summary of its work
461 * @return boolean
463 function question_delete_course_category($category, $newcategory, $feedback=true) {
464 global $DB, $OUTPUT;
466 $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
467 if (empty($newcategory)) {
468 $feedbackdata = array(); // To store feedback to be showed at the end of the process
469 $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
470 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
472 // Loop over question categories.
473 if ($categories = $DB->get_records('question_categories',
474 array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
475 foreach ($categories as $category) {
477 // Deal with any questions in the category.
478 if ($questions = $DB->get_records('question',
479 array('category' => $category->id), '', 'id,qtype')) {
481 // Try to delete each question.
482 foreach ($questions as $question) {
483 question_delete_question($question->id);
486 // Check to see if there were any questions that were kept because
487 // they are still in use somehow, even though quizzes in courses
488 // in this category will already have been deteted. This could
489 // happen, for example, if questions are added to a course,
490 // and then that course is moved to another category (MDL-14802).
491 $questionids = $DB->get_records_menu('question',
492 array('category'=>$category->id), '', 'id, 1');
493 if (!empty($questionids)) {
494 if (!$rescueqcategory = question_save_from_deletion(
495 array_keys($questionids), get_parent_contextid($context),
496 print_context_name($context), $rescueqcategory)) {
497 return false;
499 $feedbackdata[] = array($category->name,
500 get_string('questionsmovedto', 'question', $rescueqcategory->name));
504 // Now delete the category.
505 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
506 return false;
508 $feedbackdata[] = array($category->name, $strcatdeleted);
510 } // End loop over categories.
513 // Output feedback if requested.
514 if ($feedback and $feedbackdata) {
515 $table = new html_table();
516 $table->head = array(get_string('questioncategory', 'question'), get_string('action'));
517 $table->data = $feedbackdata;
518 echo html_writer::table($table);
521 } else {
522 // Move question categories ot the new context.
523 if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
524 return false;
526 $DB->set_field('question_categories', 'contextid', $newcontext->id,
527 array('contextid'=>$context->id));
528 if ($feedback) {
529 $a = new stdClass();
530 $a->oldplace = print_context_name($context);
531 $a->newplace = print_context_name($newcontext);
532 echo $OUTPUT->notification(
533 get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
537 return true;
541 * Enter description here...
543 * @param string $questionids list of questionids
544 * @param object $newcontext the context to create the saved category in.
545 * @param string $oldplace a textual description of the think being deleted,
546 * e.g. from get_context_name
547 * @param object $newcategory
548 * @return mixed false on
550 function question_save_from_deletion($questionids, $newcontextid, $oldplace,
551 $newcategory = null) {
552 global $DB;
554 // Make a category in the parent context to move the questions to.
555 if (is_null($newcategory)) {
556 $newcategory = new stdClass();
557 $newcategory->parent = 0;
558 $newcategory->contextid = $newcontextid;
559 $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
560 $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
561 $newcategory->sortorder = 999;
562 $newcategory->stamp = make_unique_id_code();
563 $newcategory->id = $DB->insert_record('question_categories', $newcategory);
566 // Move any remaining questions to the 'saved' category.
567 if (!question_move_questions_to_category($questionids, $newcategory->id)) {
568 return false;
570 return $newcategory;
574 * All question categories and their questions are deleted for this activity.
576 * @param object $cm the course module object representing the activity
577 * @param boolean $feedback to specify if the process must output a summary of its work
578 * @return boolean
580 function question_delete_activity($cm, $feedback=true) {
581 global $DB, $OUTPUT;
583 //To store feedback to be showed at the end of the process
584 $feedbackdata = array();
586 //Cache some strings
587 $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
588 $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
589 if ($categoriesmods = $DB->get_records('question_categories',
590 array('contextid' => $modcontext->id), 'parent', 'id, parent, name, contextid')) {
591 //Sort categories following their tree (parent-child) relationships
592 //this will make the feedback more readable
593 $categoriesmods = sort_categories_by_tree($categoriesmods);
595 foreach ($categoriesmods as $category) {
597 //Delete it completely (questions and category itself)
598 //deleting questions
599 if ($questions = $DB->get_records('question',
600 array('category' => $category->id), '', 'id,qtype')) {
601 foreach ($questions as $question) {
602 question_delete_question($question->id);
604 $DB->delete_records("question", array("category"=>$category->id));
606 //delete the category
607 $DB->delete_records('question_categories', array('id'=>$category->id));
609 //Fill feedback
610 $feedbackdata[] = array($category->name, $strcatdeleted);
612 //Inform about changes performed if feedback is enabled
613 if ($feedback) {
614 $table = new html_table();
615 $table->head = array(get_string('category', 'quiz'), get_string('action'));
616 $table->data = $feedbackdata;
617 echo html_writer::table($table);
620 return true;
624 * This function should be considered private to the question bank, it is called from
625 * question/editlib.php question/contextmoveq.php and a few similar places to to the
626 * work of acutally moving questions and associated data. However, callers of this
627 * function also have to do other work, which is why you should not call this method
628 * directly from outside the questionbank.
630 * @param string $questionids a comma-separated list of question ids.
631 * @param integer $newcategoryid the id of the category to move to.
633 function question_move_questions_to_category($questionids, $newcategoryid) {
634 global $DB;
636 $newcontextid = $DB->get_field('question_categories', 'contextid',
637 array('id' => $newcategoryid));
638 list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
639 $questions = $DB->get_records_sql("
640 SELECT q.id, q.qtype, qc.contextid
641 FROM {question} q
642 JOIN {question_categories} qc ON q.category = qc.id
643 WHERE q.id $questionidcondition", $params);
644 foreach ($questions as $question) {
645 if ($newcontextid != $question->contextid) {
646 question_bank::get_qtype($question->qtype)->move_files(
647 $question->id, $question->contextid, $newcontextid);
651 // Move the questions themselves.
652 $DB->set_field_select('question', 'category', $newcategoryid,
653 "id $questionidcondition", $params);
655 // Move any subquestions belonging to them.
656 $DB->set_field_select('question', 'category', $newcategoryid,
657 "parent $questionidcondition", $params);
659 // TODO Deal with datasets.
661 return true;
665 * This function helps move a question cateogry to a new context by moving all
666 * the files belonging to all the questions to the new context.
667 * Also moves subcategories.
668 * @param integer $categoryid the id of the category being moved.
669 * @param integer $oldcontextid the old context id.
670 * @param integer $newcontextid the new context id.
672 function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) {
673 global $DB;
675 $questionids = $DB->get_records_menu('question',
676 array('category' => $categoryid), '', 'id,qtype');
677 foreach ($questionids as $questionid => $qtype) {
678 question_bank::get_qtype($qtype)->move_files(
679 $questionid, $oldcontextid, $newcontextid);
682 $subcatids = $DB->get_records_menu('question_categories',
683 array('parent' => $categoryid), '', 'id,1');
684 foreach ($subcatids as $subcatid => $notused) {
685 $DB->set_field('question_categories', 'contextid', $newcontextid,
686 array('id' => $subcatid));
687 question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
692 * Generate the URL for starting a new preview of a given question with the given options.
693 * @param integer $questionid the question to preview.
694 * @param string $preferredbehaviour the behaviour to use for the preview.
695 * @param float $maxmark the maximum to mark the question out of.
696 * @param question_display_options $displayoptions the display options to use.
697 * @param int $variant the variant of the question to preview. If null, one will
698 * be picked randomly.
699 * @return string the URL.
701 function question_preview_url($questionid, $preferredbehaviour = null,
702 $maxmark = null, $displayoptions = null, $variant = null) {
704 $params = array('id' => $questionid);
706 if (!is_null($preferredbehaviour)) {
707 $params['behaviour'] = $preferredbehaviour;
710 if (!is_null($maxmark)) {
711 $params['maxmark'] = $maxmark;
714 if (!is_null($displayoptions)) {
715 $params['correctness'] = $displayoptions->correctness;
716 $params['marks'] = $displayoptions->marks;
717 $params['markdp'] = $displayoptions->markdp;
718 $params['feedback'] = (bool) $displayoptions->feedback;
719 $params['generalfeedback'] = (bool) $displayoptions->generalfeedback;
720 $params['rightanswer'] = (bool) $displayoptions->rightanswer;
721 $params['history'] = (bool) $displayoptions->history;
724 if ($variant) {
725 $params['variant'] = $variant;
728 return new moodle_url('/question/preview.php', $params);
732 * @return array that can be passed as $params to the {@link popup_action} constructor.
734 function question_preview_popup_params() {
735 return array(
736 'height' => 600,
737 'width' => 800,
742 * Given a list of ids, load the basic information about a set of questions from
743 * the questions table. The $join and $extrafields arguments can be used together
744 * to pull in extra data. See, for example, the usage in mod/quiz/attemptlib.php, and
745 * read the code below to see how the SQL is assembled. Throws exceptions on error.
747 * @global object
748 * @global object
749 * @param array $questionids array of question ids.
750 * @param string $extrafields extra SQL code to be added to the query.
751 * @param string $join extra SQL code to be added to the query.
752 * @param array $extraparams values for any placeholders in $join.
753 * You are strongly recommended to use named placeholder.
755 * @return array partially complete question objects. You need to call get_question_options
756 * on them before they can be properly used.
758 function question_preload_questions($questionids, $extrafields = '', $join = '',
759 $extraparams = array()) {
760 global $DB;
761 if (empty($questionids)) {
762 return array();
764 if ($join) {
765 $join = ' JOIN '.$join;
767 if ($extrafields) {
768 $extrafields = ', ' . $extrafields;
770 list($questionidcondition, $params) = $DB->get_in_or_equal(
771 $questionids, SQL_PARAMS_NAMED, 'qid0000');
772 $sql = 'SELECT q.*, qc.contextid' . $extrafields . ' FROM {question} q
773 JOIN {question_categories} qc ON q.category = qc.id' .
774 $join .
775 ' WHERE q.id ' . $questionidcondition;
777 // Load the questions
778 if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
779 return array();
782 foreach ($questions as $question) {
783 $question->_partiallyloaded = true;
786 // Note, a possible optimisation here would be to not load the TEXT fields
787 // (that is, questiontext and generalfeedback) here, and instead load them in
788 // question_load_questions. That would add one DB query, but reduce the amount
789 // of data transferred most of the time. I am not going to do this optimisation
790 // until it is shown to be worthwhile.
792 return $questions;
796 * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
797 * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
798 * read the code below to see how the SQL is assembled. Throws exceptions on error.
800 * @param array $questionids array of question ids.
801 * @param string $extrafields extra SQL code to be added to the query.
802 * @param string $join extra SQL code to be added to the query.
803 * @param array $extraparams values for any placeholders in $join.
804 * You are strongly recommended to use named placeholder.
806 * @return array question objects.
808 function question_load_questions($questionids, $extrafields = '', $join = '') {
809 $questions = question_preload_questions($questionids, $extrafields, $join);
811 // Load the question type specific information
812 if (!get_question_options($questions)) {
813 return 'Could not load the question options';
816 return $questions;
820 * Private function to factor common code out of get_question_options().
822 * @param object $question the question to tidy.
823 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
825 function _tidy_question($question, $loadtags = false) {
826 global $CFG;
827 if (!question_bank::is_qtype_installed($question->qtype)) {
828 $question->questiontext = html_writer::tag('p', get_string('warningmissingtype',
829 'qtype_missingtype')) . $question->questiontext;
831 question_bank::get_qtype($question->qtype)->get_question_options($question);
832 if (isset($question->_partiallyloaded)) {
833 unset($question->_partiallyloaded);
835 if ($loadtags && !empty($CFG->usetags)) {
836 require_once($CFG->dirroot . '/tag/lib.php');
837 $question->tags = tag_get_tags_array('question', $question->id);
842 * Updates the question objects with question type specific
843 * information by calling {@link get_question_options()}
845 * Can be called either with an array of question objects or with a single
846 * question object.
848 * @param mixed $questions Either an array of question objects to be updated
849 * or just a single question object
850 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
851 * @return bool Indicates success or failure.
853 function get_question_options(&$questions, $loadtags = false) {
854 if (is_array($questions)) { // deal with an array of questions
855 foreach ($questions as $i => $notused) {
856 _tidy_question($questions[$i], $loadtags);
858 } else { // deal with single question
859 _tidy_question($questions, $loadtags);
861 return true;
865 * Print the icon for the question type
867 * @param object $question The question object for which the icon is required.
868 * Only $question->qtype is used.
869 * @return string the HTML for the img tag.
871 function print_question_icon($question) {
872 global $OUTPUT;
874 $qtype = question_bank::get_qtype($question->qtype, false);
875 $namestr = $qtype->menu_name();
877 // TODO convert to return a moodle_icon object, or whatever the class is.
878 $html = '<img src="' . $OUTPUT->pix_url('icon', $qtype->plugin_name()) . '" alt="' .
879 $namestr . '" title="' . $namestr . '" />';
881 return $html;
885 * Creates a stamp that uniquely identifies this version of the question
887 * In future we want this to use a hash of the question data to guarantee that
888 * identical versions have the same version stamp.
890 * @param object $question
891 * @return string A unique version stamp
893 function question_hash($question) {
894 return make_unique_id_code();
897 /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
899 * Get anything that needs to be included in the head of the question editing page
900 * for a particular question type. This function is called by question/question.php.
902 * @param $question A question object. Only $question->qtype is used.
903 * @return string Deprecated. Some HTML code that can go inside the head tag.
905 function question_get_editing_head_contributions($question) {
906 question_bank::get_qtype($question->qtype, false)->get_editing_head_contributions();
910 * Saves question options
912 * Simply calls the question type specific save_question_options() method.
914 function save_question_options($question) {
915 question_bank::get_qtype($question->qtype)->save_question_options($question);
918 /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
921 * returns the categories with their names ordered following parent-child relationships
922 * finally it tries to return pending categories (those being orphaned, whose parent is
923 * incorrect) to avoid missing any category from original array.
925 function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
926 global $DB;
928 $children = array();
929 $keys = array_keys($categories);
931 foreach ($keys as $key) {
932 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
933 $children[$key] = $categories[$key];
934 $categories[$key]->processed = true;
935 $children = $children + sort_categories_by_tree(
936 $categories, $children[$key]->id, $level+1);
939 //If level = 1, we have finished, try to look for non processed categories
940 // (bad parent) and sort them too
941 if ($level == 1) {
942 foreach ($keys as $key) {
943 // If not processed and it's a good candidate to start (because its
944 // parent doesn't exist in the course)
945 if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories',
946 array('contextid' => $categories[$key]->contextid,
947 'id' => $categories[$key]->parent))) {
948 $children[$key] = $categories[$key];
949 $categories[$key]->processed = true;
950 $children = $children + sort_categories_by_tree(
951 $categories, $children[$key]->id, $level + 1);
955 return $children;
959 * Private method, only for the use of add_indented_names().
961 * Recursively adds an indentedname field to each category, starting with the category
962 * with id $id, and dealing with that category and all its children, and
963 * return a new array, with those categories in the right order.
965 * @param array $categories an array of categories which has had childids
966 * fields added by flatten_category_tree(). Passed by reference for
967 * performance only. It is not modfied.
968 * @param int $id the category to start the indenting process from.
969 * @param int $depth the indent depth. Used in recursive calls.
970 * @return array a new array of categories, in the right order for the tree.
972 function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
974 // Indent the name of this category.
975 $newcategories = array();
976 $newcategories[$id] = $categories[$id];
977 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) .
978 $categories[$id]->name;
980 // Recursively indent the children.
981 foreach ($categories[$id]->childids as $childid) {
982 if ($childid != $nochildrenof) {
983 $newcategories = $newcategories + flatten_category_tree(
984 $categories, $childid, $depth + 1, $nochildrenof);
988 // Remove the childids array that were temporarily added.
989 unset($newcategories[$id]->childids);
991 return $newcategories;
995 * Format categories into an indented list reflecting the tree structure.
997 * @param array $categories An array of category objects, for example from the.
998 * @return array The formatted list of categories.
1000 function add_indented_names($categories, $nochildrenof = -1) {
1002 // Add an array to each category to hold the child category ids. This array
1003 // will be removed again by flatten_category_tree(). It should not be used
1004 // outside these two functions.
1005 foreach (array_keys($categories) as $id) {
1006 $categories[$id]->childids = array();
1009 // Build the tree structure, and record which categories are top-level.
1010 // We have to be careful, because the categories array may include published
1011 // categories from other courses, but not their parents.
1012 $toplevelcategoryids = array();
1013 foreach (array_keys($categories) as $id) {
1014 if (!empty($categories[$id]->parent) &&
1015 array_key_exists($categories[$id]->parent, $categories)) {
1016 $categories[$categories[$id]->parent]->childids[] = $id;
1017 } else {
1018 $toplevelcategoryids[] = $id;
1022 // Flatten the tree to and add the indents.
1023 $newcategories = array();
1024 foreach ($toplevelcategoryids as $id) {
1025 $newcategories = $newcategories + flatten_category_tree(
1026 $categories, $id, 0, $nochildrenof);
1029 return $newcategories;
1033 * Output a select menu of question categories.
1035 * Categories from this course and (optionally) published categories from other courses
1036 * are included. Optionally, only categories the current user may edit can be included.
1038 * @param integer $courseid the id of the course to get the categories for.
1039 * @param integer $published if true, include publised categories from other courses.
1040 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
1041 * @param integer $selected optionally, the id of a category to be selected by
1042 * default in the dropdown.
1044 function question_category_select_menu($contexts, $top = false, $currentcat = 0,
1045 $selected = "", $nochildrenof = -1) {
1046 global $OUTPUT;
1047 $categoriesarray = question_category_options($contexts, $top, $currentcat,
1048 false, $nochildrenof);
1049 if ($selected) {
1050 $choose = '';
1051 } else {
1052 $choose = 'choosedots';
1054 $options = array();
1055 foreach ($categoriesarray as $group => $opts) {
1056 $options[] = array($group => $opts);
1059 echo html_writer::select($options, 'category', $selected, $choose);
1063 * @param integer $contextid a context id.
1064 * @return object the default question category for that context, or false if none.
1066 function question_get_default_category($contextid) {
1067 global $DB;
1068 $category = $DB->get_records('question_categories',
1069 array('contextid' => $contextid), 'id', '*', 0, 1);
1070 if (!empty($category)) {
1071 return reset($category);
1072 } else {
1073 return false;
1078 * Gets the default category in the most specific context.
1079 * If no categories exist yet then default ones are created in all contexts.
1081 * @param array $contexts The context objects for this context and all parent contexts.
1082 * @return object The default category - the category in the course context
1084 function question_make_default_categories($contexts) {
1085 global $DB;
1086 static $preferredlevels = array(
1087 CONTEXT_COURSE => 4,
1088 CONTEXT_MODULE => 3,
1089 CONTEXT_COURSECAT => 2,
1090 CONTEXT_SYSTEM => 1,
1093 $toreturn = null;
1094 $preferredness = 0;
1095 // If it already exists, just return it.
1096 foreach ($contexts as $key => $context) {
1097 if (!$exists = $DB->record_exists("question_categories",
1098 array('contextid' => $context->id))) {
1099 // Otherwise, we need to make one
1100 $category = new stdClass();
1101 $contextname = print_context_name($context, false, true);
1102 $category->name = get_string('defaultfor', 'question', $contextname);
1103 $category->info = get_string('defaultinfofor', 'question', $contextname);
1104 $category->contextid = $context->id;
1105 $category->parent = 0;
1106 // By default, all categories get this number, and are sorted alphabetically.
1107 $category->sortorder = 999;
1108 $category->stamp = make_unique_id_code();
1109 $category->id = $DB->insert_record('question_categories', $category);
1110 } else {
1111 $category = question_get_default_category($context->id);
1113 if ($preferredlevels[$context->contextlevel] > $preferredness && has_any_capability(
1114 array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
1115 $toreturn = $category;
1116 $preferredness = $preferredlevels[$context->contextlevel];
1120 if (!is_null($toreturn)) {
1121 $toreturn = clone($toreturn);
1123 return $toreturn;
1127 * Get all the category objects, including a count of the number of questions in that category,
1128 * for all the categories in the lists $contexts.
1130 * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
1131 * @param string $sortorder used as the ORDER BY clause in the select statement.
1132 * @return array of category objects.
1134 function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
1135 global $DB;
1136 return $DB->get_records_sql("
1137 SELECT c.*, (SELECT count(1) FROM {question} q
1138 WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
1139 FROM {question_categories} c
1140 WHERE c.contextid IN ($contexts)
1141 ORDER BY $sortorder");
1145 * Output an array of question categories.
1147 function question_category_options($contexts, $top = false, $currentcat = 0,
1148 $popupform = false, $nochildrenof = -1) {
1149 global $CFG;
1150 $pcontexts = array();
1151 foreach ($contexts as $context) {
1152 $pcontexts[] = $context->id;
1154 $contextslist = join($pcontexts, ', ');
1156 $categories = get_categories_for_contexts($contextslist);
1158 $categories = question_add_context_in_key($categories);
1160 if ($top) {
1161 $categories = question_add_tops($categories, $pcontexts);
1163 $categories = add_indented_names($categories, $nochildrenof);
1165 // sort cats out into different contexts
1166 $categoriesarray = array();
1167 foreach ($pcontexts as $pcontext) {
1168 $contextstring = print_context_name(
1169 get_context_instance_by_id($pcontext), true, true);
1170 foreach ($categories as $category) {
1171 if ($category->contextid == $pcontext) {
1172 $cid = $category->id;
1173 if ($currentcat != $cid || $currentcat == 0) {
1174 $countstring = !empty($category->questioncount) ?
1175 " ($category->questioncount)" : '';
1176 $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
1181 if ($popupform) {
1182 $popupcats = array();
1183 foreach ($categoriesarray as $contextstring => $optgroup) {
1184 $group = array();
1185 foreach ($optgroup as $key => $value) {
1186 $key = str_replace($CFG->wwwroot, '', $key);
1187 $group[$key] = $value;
1189 $popupcats[] = array($contextstring => $group);
1191 return $popupcats;
1192 } else {
1193 return $categoriesarray;
1197 function question_add_context_in_key($categories) {
1198 $newcatarray = array();
1199 foreach ($categories as $id => $category) {
1200 $category->parent = "$category->parent,$category->contextid";
1201 $category->id = "$category->id,$category->contextid";
1202 $newcatarray["$id,$category->contextid"] = $category;
1204 return $newcatarray;
1207 function question_add_tops($categories, $pcontexts) {
1208 $topcats = array();
1209 foreach ($pcontexts as $context) {
1210 $newcat = new stdClass();
1211 $newcat->id = "0,$context";
1212 $newcat->name = get_string('top');
1213 $newcat->parent = -1;
1214 $newcat->contextid = $context;
1215 $topcats["0,$context"] = $newcat;
1217 //put topcats in at beginning of array - they'll be sorted into different contexts later.
1218 return array_merge($topcats, $categories);
1222 * @return array of question category ids of the category and all subcategories.
1224 function question_categorylist($categoryid) {
1225 global $DB;
1227 $subcategories = $DB->get_records('question_categories',
1228 array('parent' => $categoryid), 'sortorder ASC', 'id, 1');
1230 $categorylist = array($categoryid);
1231 foreach ($subcategories as $subcategory) {
1232 $categorylist = array_merge($categorylist, question_categorylist($subcategory->id));
1235 return $categorylist;
1238 //===========================
1239 // Import/Export Functions
1240 //===========================
1243 * Get list of available import or export formats
1244 * @param string $type 'import' if import list, otherwise export list assumed
1245 * @return array sorted list of import/export formats available
1247 function get_import_export_formats($type) {
1248 global $CFG;
1250 $fileformats = get_plugin_list('qformat');
1252 $fileformatname = array();
1253 require_once($CFG->dirroot . '/question/format.php');
1254 foreach ($fileformats as $fileformat => $fdir) {
1255 $formatfile = $fdir . '/format.php';
1256 if (is_readable($formatfile)) {
1257 include_once($formatfile);
1258 } else {
1259 continue;
1262 $classname = 'qformat_' . $fileformat;
1263 $formatclass = new $classname();
1264 if ($type == 'import') {
1265 $provided = $formatclass->provide_import();
1266 } else {
1267 $provided = $formatclass->provide_export();
1270 if ($provided) {
1271 $fileformatnames[$fileformat] = get_string($fileformat, 'qformat_' . $fileformat);
1275 textlib_get_instance()->asort($fileformatnames);
1276 return $fileformatnames;
1281 * Create a reasonable default file name for exporting questions from a particular
1282 * category.
1283 * @param object $course the course the questions are in.
1284 * @param object $category the question category.
1285 * @return string the filename.
1287 function question_default_export_filename($course, $category) {
1288 // We build a string that is an appropriate name (questions) from the lang pack,
1289 // then the corse shortname, then the question category name, then a timestamp.
1291 $base = clean_filename(get_string('exportfilename', 'question'));
1293 $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question'));
1294 $timestamp = clean_filename(userdate(time(), $dateformat, 99, false));
1296 $shortname = clean_filename($course->shortname);
1297 if ($shortname == '' || $shortname == '_' ) {
1298 $shortname = $course->id;
1301 $categoryname = clean_filename(format_string($category->name));
1303 return "{$base}-{$shortname}-{$categoryname}-{$timestamp}";
1305 return $export_name;
1309 * Converts contextlevels to strings and back to help with reading/writing contexts
1310 * to/from import/export files.
1312 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
1313 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1315 class context_to_string_translator{
1317 * @var array used to translate between contextids and strings for this context.
1319 protected $contexttostringarray = array();
1321 public function __construct($contexts) {
1322 $this->generate_context_to_string_array($contexts);
1325 public function context_to_string($contextid) {
1326 return $this->contexttostringarray[$contextid];
1329 public function string_to_context($contextname) {
1330 $contextid = array_search($contextname, $this->contexttostringarray);
1331 return $contextid;
1334 protected function generate_context_to_string_array($contexts) {
1335 if (!$this->contexttostringarray) {
1336 $catno = 1;
1337 foreach ($contexts as $context) {
1338 switch ($context->contextlevel) {
1339 case CONTEXT_MODULE :
1340 $contextstring = 'module';
1341 break;
1342 case CONTEXT_COURSE :
1343 $contextstring = 'course';
1344 break;
1345 case CONTEXT_COURSECAT :
1346 $contextstring = "cat$catno";
1347 $catno++;
1348 break;
1349 case CONTEXT_SYSTEM :
1350 $contextstring = 'system';
1351 break;
1353 $this->contexttostringarray[$context->id] = $contextstring;
1361 * Check capability on category
1363 * @param mixed $question object or id
1364 * @param string $cap 'add', 'edit', 'view', 'use', 'move'
1365 * @param integer $cachecat useful to cache all question records in a category
1366 * @return boolean this user has the capability $cap for this question $question?
1368 function question_has_capability_on($question, $cap, $cachecat = -1) {
1369 global $USER, $DB;
1371 // these are capabilities on existing questions capabilties are
1372 //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
1373 $question_questioncaps = array('edit', 'view', 'use', 'move');
1374 static $questions = array();
1375 static $categories = array();
1376 static $cachedcat = array();
1377 if ($cachecat != -1 && array_search($cachecat, $cachedcat) === false) {
1378 $questions += $DB->get_records('question', array('category' => $cachecat));
1379 $cachedcat[] = $cachecat;
1381 if (!is_object($question)) {
1382 if (!isset($questions[$question])) {
1383 if (!$questions[$question] = $DB->get_record('question',
1384 array('id' => $question), 'id,category,createdby')) {
1385 print_error('questiondoesnotexist', 'question');
1388 $question = $questions[$question];
1390 if (!isset($categories[$question->category])) {
1391 if (!$categories[$question->category] = $DB->get_record('question_categories',
1392 array('id'=>$question->category))) {
1393 print_error('invalidcategory', 'quiz');
1396 $category = $categories[$question->category];
1397 $context = get_context_instance_by_id($category->contextid);
1399 if (array_search($cap, $question_questioncaps)!== false) {
1400 if (!has_capability('moodle/question:' . $cap . 'all', $context)) {
1401 if ($question->createdby == $USER->id) {
1402 return has_capability('moodle/question:' . $cap . 'mine', $context);
1403 } else {
1404 return false;
1406 } else {
1407 return true;
1409 } else {
1410 return has_capability('moodle/question:' . $cap, $context);
1416 * Require capability on question.
1418 function question_require_capability_on($question, $cap) {
1419 if (!question_has_capability_on($question, $cap)) {
1420 print_error('nopermissions', '', '', $cap);
1422 return true;
1426 * Get the real state - the correct question id and answer - for a random
1427 * question.
1428 * @param object $state with property answer.
1429 * @return mixed return integer real question id or false if there was an
1430 * error..
1432 function question_get_real_state($state) {
1433 global $OUTPUT;
1434 $realstate = clone($state);
1435 $matches = array();
1436 if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)) {
1437 echo $OUTPUT->notification(get_string('errorrandom', 'quiz_statistics'));
1438 return false;
1439 } else {
1440 $realstate->question = $matches[1];
1441 $realstate->answer = $matches[2];
1442 return $realstate;
1447 * @param object $context a context
1448 * @return string A URL for editing questions in this context.
1450 function question_edit_url($context) {
1451 global $CFG, $SITE;
1452 if (!has_any_capability(question_get_question_capabilities(), $context)) {
1453 return false;
1455 $baseurl = $CFG->wwwroot . '/question/edit.php?';
1456 $defaultcategory = question_get_default_category($context->id);
1457 if ($defaultcategory) {
1458 $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
1460 switch ($context->contextlevel) {
1461 case CONTEXT_SYSTEM:
1462 return $baseurl . 'courseid=' . $SITE->id;
1463 case CONTEXT_COURSECAT:
1464 // This is nasty, becuase we can only edit questions in a course
1465 // context at the moment, so for now we just return false.
1466 return false;
1467 case CONTEXT_COURSE:
1468 return $baseurl . 'courseid=' . $context->instanceid;
1469 case CONTEXT_MODULE:
1470 return $baseurl . 'cmid=' . $context->instanceid;
1476 * Adds question bank setting links to the given navigation node if caps are met.
1478 * @param navigation_node $navigationnode The navigation node to add the question branch to
1479 * @param object $context
1480 * @return navigation_node Returns the question branch that was added
1482 function question_extend_settings_navigation(navigation_node $navigationnode, $context) {
1483 global $PAGE;
1485 if ($context->contextlevel == CONTEXT_COURSE) {
1486 $params = array('courseid'=>$context->instanceid);
1487 } else if ($context->contextlevel == CONTEXT_MODULE) {
1488 $params = array('cmid'=>$context->instanceid);
1489 } else {
1490 return;
1493 $questionnode = $navigationnode->add(get_string('questionbank', 'question'),
1494 new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER);
1496 $contexts = new question_edit_contexts($context);
1497 if ($contexts->have_one_edit_tab_cap('questions')) {
1498 $questionnode->add(get_string('questions', 'quiz'), new moodle_url(
1499 '/question/edit.php', $params), navigation_node::TYPE_SETTING);
1501 if ($contexts->have_one_edit_tab_cap('categories')) {
1502 $questionnode->add(get_string('categories', 'quiz'), new moodle_url(
1503 '/question/category.php', $params), navigation_node::TYPE_SETTING);
1505 if ($contexts->have_one_edit_tab_cap('import')) {
1506 $questionnode->add(get_string('import', 'quiz'), new moodle_url(
1507 '/question/import.php', $params), navigation_node::TYPE_SETTING);
1509 if ($contexts->have_one_edit_tab_cap('export')) {
1510 $questionnode->add(get_string('export', 'quiz'), new moodle_url(
1511 '/question/export.php', $params), navigation_node::TYPE_SETTING);
1514 return $questionnode;
1518 * @return array all the capabilities that relate to accessing particular questions.
1520 function question_get_question_capabilities() {
1521 return array(
1522 'moodle/question:add',
1523 'moodle/question:editmine',
1524 'moodle/question:editall',
1525 'moodle/question:viewmine',
1526 'moodle/question:viewall',
1527 'moodle/question:usemine',
1528 'moodle/question:useall',
1529 'moodle/question:movemine',
1530 'moodle/question:moveall',
1535 * @return array all the question bank capabilities.
1537 function question_get_all_capabilities() {
1538 $caps = question_get_question_capabilities();
1539 $caps[] = 'moodle/question:managecategory';
1540 $caps[] = 'moodle/question:flag';
1541 return $caps;
1544 class question_edit_contexts {
1546 public static $caps = array(
1547 'editq' => array('moodle/question:add',
1548 'moodle/question:editmine',
1549 'moodle/question:editall',
1550 'moodle/question:viewmine',
1551 'moodle/question:viewall',
1552 'moodle/question:usemine',
1553 'moodle/question:useall',
1554 'moodle/question:movemine',
1555 'moodle/question:moveall'),
1556 'questions'=>array('moodle/question:add',
1557 'moodle/question:editmine',
1558 'moodle/question:editall',
1559 'moodle/question:viewmine',
1560 'moodle/question:viewall',
1561 'moodle/question:movemine',
1562 'moodle/question:moveall'),
1563 'categories'=>array('moodle/question:managecategory'),
1564 'import'=>array('moodle/question:add'),
1565 'export'=>array('moodle/question:viewall', 'moodle/question:viewmine'));
1567 protected $allcontexts;
1570 * @param current context
1572 public function __construct($thiscontext) {
1573 $pcontextids = get_parent_contexts($thiscontext);
1574 $contexts = array($thiscontext);
1575 foreach ($pcontextids as $pcontextid) {
1576 $contexts[] = get_context_instance_by_id($pcontextid);
1578 $this->allcontexts = $contexts;
1581 * @return array all parent contexts
1583 public function all() {
1584 return $this->allcontexts;
1587 * @return object lowest context which must be either the module or course context
1589 public function lowest() {
1590 return $this->allcontexts[0];
1593 * @param string $cap capability
1594 * @return array parent contexts having capability, zero based index
1596 public function having_cap($cap) {
1597 $contextswithcap = array();
1598 foreach ($this->allcontexts as $context) {
1599 if (has_capability($cap, $context)) {
1600 $contextswithcap[] = $context;
1603 return $contextswithcap;
1606 * @param array $caps capabilities
1607 * @return array parent contexts having at least one of $caps, zero based index
1609 public function having_one_cap($caps) {
1610 $contextswithacap = array();
1611 foreach ($this->allcontexts as $context) {
1612 foreach ($caps as $cap) {
1613 if (has_capability($cap, $context)) {
1614 $contextswithacap[] = $context;
1615 break; //done with caps loop
1619 return $contextswithacap;
1622 * @param string $tabname edit tab name
1623 * @return array parent contexts having at least one of $caps, zero based index
1625 public function having_one_edit_tab_cap($tabname) {
1626 return $this->having_one_cap(self::$caps[$tabname]);
1629 * Has at least one parent context got the cap $cap?
1631 * @param string $cap capability
1632 * @return boolean
1634 public function have_cap($cap) {
1635 return (count($this->having_cap($cap)));
1639 * Has at least one parent context got one of the caps $caps?
1641 * @param array $caps capability
1642 * @return boolean
1644 public function have_one_cap($caps) {
1645 foreach ($caps as $cap) {
1646 if ($this->have_cap($cap)) {
1647 return true;
1650 return false;
1654 * Has at least one parent context got one of the caps for actions on $tabname
1656 * @param string $tabname edit tab name
1657 * @return boolean
1659 public function have_one_edit_tab_cap($tabname) {
1660 return $this->have_one_cap(self::$caps[$tabname]);
1664 * Throw error if at least one parent context hasn't got the cap $cap
1666 * @param string $cap capability
1668 public function require_cap($cap) {
1669 if (!$this->have_cap($cap)) {
1670 print_error('nopermissions', '', '', $cap);
1675 * Throw error if at least one parent context hasn't got one of the caps $caps
1677 * @param array $cap capabilities
1679 public function require_one_cap($caps) {
1680 if (!$this->have_one_cap($caps)) {
1681 $capsstring = join($caps, ', ');
1682 print_error('nopermissions', '', '', $capsstring);
1687 * Throw error if at least one parent context hasn't got one of the caps $caps
1689 * @param string $tabname edit tab name
1691 public function require_one_edit_tab_cap($tabname) {
1692 if (!$this->have_one_edit_tab_cap($tabname)) {
1693 print_error('nopermissions', '', '', 'access question edit tab '.$tabname);
1699 * Rewrite question url, file_rewrite_pluginfile_urls always build url by
1700 * $file/$contextid/$component/$filearea/$itemid/$pathname_in_text, so we cannot add
1701 * extra questionid and attempted in url by it, so we create quiz_rewrite_question_urls
1702 * to build url here
1704 * @param string $text text being processed
1705 * @param string $file the php script used to serve files
1706 * @param int $contextid
1707 * @param string $component component
1708 * @param string $filearea filearea
1709 * @param array $ids other IDs will be used to check file permission
1710 * @param int $itemid
1711 * @param array $options
1712 * @return string
1714 function question_rewrite_question_urls($text, $file, $contextid, $component,
1715 $filearea, array $ids, $itemid, array $options=null) {
1716 global $CFG;
1718 $options = (array)$options;
1719 if (!isset($options['forcehttps'])) {
1720 $options['forcehttps'] = false;
1723 if (!$CFG->slasharguments) {
1724 $file = $file . '?file=';
1727 $baseurl = "$CFG->wwwroot/$file/$contextid/$component/$filearea/";
1729 if (!empty($ids)) {
1730 $baseurl .= (implode('/', $ids) . '/');
1733 if ($itemid !== null) {
1734 $baseurl .= "$itemid/";
1737 if ($options['forcehttps']) {
1738 $baseurl = str_replace('http://', 'https://', $baseurl);
1741 return str_replace('@@PLUGINFILE@@/', $baseurl, $text);
1745 * Called by pluginfile.php to serve files related to the 'question' core
1746 * component and for files belonging to qtypes.
1748 * For files that relate to questions in a question_attempt, then we delegate to
1749 * a function in the component that owns the attempt (for example in the quiz,
1750 * or in core question preview) to get necessary inforation.
1752 * (Note that, at the moment, all question file areas relate to questions in
1753 * attempts, so the If at the start of the last paragraph is always true.)
1755 * Does not return, either calls send_file_not_found(); or serves the file.
1757 * @param object $course course settings object
1758 * @param object $context context object
1759 * @param string $component the name of the component we are serving files for.
1760 * @param string $filearea the name of the file area.
1761 * @param array $args the remaining bits of the file path.
1762 * @param bool $forcedownload whether the user must be forced to download the file.
1764 function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload) {
1765 global $DB, $CFG;
1767 list($context, $course, $cm) = get_context_info_array($context->id);
1768 require_login($course, false, $cm);
1770 if ($filearea === 'export') {
1771 require_once($CFG->dirroot . '/question/editlib.php');
1772 $contexts = new question_edit_contexts($context);
1773 // check export capability
1774 $contexts->require_one_edit_tab_cap('export');
1775 $category_id = (int)array_shift($args);
1776 $format = array_shift($args);
1777 $cattofile = array_shift($args);
1778 $contexttofile = array_shift($args);
1779 $filename = array_shift($args);
1781 // load parent class for import/export
1782 require_once($CFG->dirroot . '/question/format.php');
1783 require_once($CFG->dirroot . '/question/editlib.php');
1784 require_once($CFG->dirroot . '/question/format/' . $format . '/format.php');
1786 $classname = 'qformat_' . $format;
1787 if (!class_exists($classname)) {
1788 send_file_not_found();
1791 $qformat = new $classname();
1793 if (!$category = $DB->get_record('question_categories', array('id' => $category_id))) {
1794 send_file_not_found();
1797 $qformat->setCategory($category);
1798 $qformat->setContexts($contexts->having_one_edit_tab_cap('export'));
1799 $qformat->setCourse($course);
1801 if ($cattofile == 'withcategories') {
1802 $qformat->setCattofile(true);
1803 } else {
1804 $qformat->setCattofile(false);
1807 if ($contexttofile == 'withcontexts') {
1808 $qformat->setContexttofile(true);
1809 } else {
1810 $qformat->setContexttofile(false);
1813 if (!$qformat->exportpreprocess()) {
1814 send_file_not_found();
1815 print_error('exporterror', 'question', $thispageurl->out());
1818 // export data to moodle file pool
1819 if (!$content = $qformat->exportprocess(true)) {
1820 send_file_not_found();
1823 send_file($content, $filename, 0, 0, true, true, $qformat->mime_type());
1826 $qubaid = (int)array_shift($args);
1827 $slot = (int)array_shift($args);
1829 $module = $DB->get_field('question_usages', 'component',
1830 array('id' => $qubaid));
1832 if ($module === 'core_question_preview') {
1833 require_once($CFG->dirroot . '/question/previewlib.php');
1834 return question_preview_question_pluginfile($course, $context,
1835 $component, $filearea, $qubaid, $slot, $args, $forcedownload);
1837 } else {
1838 $dir = get_component_directory($module);
1839 if (!file_exists("$dir/lib.php")) {
1840 send_file_not_found();
1842 include_once("$dir/lib.php");
1844 $filefunction = $module . '_question_pluginfile';
1845 if (!function_exists($filefunction)) {
1846 send_file_not_found();
1849 $filefunction($course, $context, $component, $filearea, $qubaid, $slot,
1850 $args, $forcedownload);
1852 send_file_not_found();
1857 * Create url for question export
1859 * @param int $contextid, current context
1860 * @param int $categoryid, categoryid
1861 * @param string $format
1862 * @param string $withcategories
1863 * @param string $ithcontexts
1864 * @param moodle_url export file url
1866 function question_make_export_url($contextid, $categoryid, $format, $withcategories,
1867 $withcontexts, $filename) {
1868 global $CFG;
1869 $urlbase = "$CFG->httpswwwroot/pluginfile.php";
1870 return moodle_url::make_file_url($urlbase,
1871 "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}" .
1872 "/{$withcontexts}/{$filename}", true);