MDL-49366 questions: avoid PHP notices from bad pluginfile URLs
[moodle.git] / lib / questionlib.php
CommitLineData
50fcb1d8 1<?php
50fcb1d8 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.
13//
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/>.
16
516cf3eb 17/**
6b11a0e8 18 * Code for handling and processing questions
19 *
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}
24 *
25 * TODO: separate those functions which form part of the API
26 * from the helper functions.
27 *
f29aeb5a
TH
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
6b11a0e8 32 */
720be6f2 33
8b92c1e3 34
9c197f44
TH
35defined('MOODLE_INTERNAL') || die();
36
f29aeb5a 37require_once($CFG->dirroot . '/question/engine/lib.php');
afe24f85 38require_once($CFG->dirroot . '/question/type/questiontypebase.php');
8b92c1e3 39
8b92c1e3 40
b55797b8 41
f29aeb5a 42/// CONSTANTS ///////////////////////////////////
e56a08dc 43
6b11a0e8 44/**
45 * Constant determines the number of answer boxes supplied in the editing
46 * form for multiple choice and similar question types.
47 */
f29aeb5a 48define("QUESTION_NUMANS", 10);
e56a08dc 49
271ffe3f 50/**
51 * Constant determines the number of answer boxes supplied in the editing
52 * form for multiple choice and similar question types to start with, with
53 * the option of adding QUESTION_NUMANS_ADD more answers.
54 */
55define("QUESTION_NUMANS_START", 3);
56
57/**
58 * Constant determines the number of answer boxes to add in the editing
59 * form for multiple choice and similar question types when the user presses
60 * 'add form fields button'.
61 */
62define("QUESTION_NUMANS_ADD", 3);
63
af52ecee 64/**
65 * Move one question type in a list of question types. If you try to move one element
66 * off of the end, nothing will change.
117bd748 67 *
af52ecee 68 * @param array $sortedqtypes An array $qtype => anything.
69 * @param string $tomove one of the keys from $sortedqtypes
70 * @param integer $direction +1 or -1
71 * @return array an array $index => $qtype, with $index from 0 to n in order, and
72 * the $qtypes in the same order as $sortedqtypes, except that $tomove will
73 * have been moved one place.
74 */
75function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
76 $neworder = array_keys($sortedqtypes);
77 // Find the element to move.
78 $key = array_search($tomove, $neworder);
79 if ($key === false) {
80 return $neworder;
81 }
82 // Work out the other index.
83 $otherkey = $key + $direction;
84 if (!isset($neworder[$otherkey])) {
85 return $neworder;
86 }
87 // Do the swap.
88 $swap = $neworder[$otherkey];
89 $neworder[$otherkey] = $neworder[$key];
90 $neworder[$key] = $swap;
91 return $neworder;
92}
93
94/**
95 * Save a new question type order to the config_plugins table.
50fcb1d8 96 * @global object
af52ecee 97 * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
98 * @param $config get_config('question'), if you happen to have it around, to save one DB query.
99 */
100function question_save_qtype_order($neworder, $config = null) {
101 global $DB;
102
103 if (is_null($config)) {
104 $config = get_config('question');
105 }
106
107 foreach ($neworder as $index => $qtype) {
108 $sortvar = $qtype . '_sortorder';
109 if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
110 set_config($sortvar, $index + 1, 'question');
111 }
112 }
113}
114
516cf3eb 115/// FUNCTIONS //////////////////////////////////////////////////////
116
f29aeb5a
TH
117/**
118 * @param array $questionids of question ids.
119 * @return boolean whether any of these questions are being used by any part of Moodle.
120 */
121function questions_in_use($questionids) {
122 global $CFG;
123
124 if (question_engine::questions_in_use($questionids)) {
125 return true;
126 }
127
bd3b3bba 128 foreach (core_component::get_plugin_list('mod') as $module => $path) {
f29aeb5a
TH
129 $lib = $path . '/lib.php';
130 if (is_readable($lib)) {
131 include_once($lib);
132
133 $fn = $module . '_questions_in_use';
7453df70 134 if (function_exists($fn)) {
f29aeb5a
TH
135 if ($fn($questionids)) {
136 return true;
137 }
138 } else {
139
140 // Fallback for legacy modules.
141 $fn = $module . '_question_list_instances';
142 if (function_exists($fn)) {
143 foreach ($questionids as $questionid) {
144 $instances = $fn($questionid);
145 if (!empty($instances)) {
146 return true;
147 }
148 }
149 }
7453df70 150 }
90c3f310 151 }
152 }
f29aeb5a
TH
153
154 return false;
90c3f310 155}
516cf3eb 156
e2b347e9 157/**
158 * Determine whether there arey any questions belonging to this context, that is whether any of its
159 * question categories contain any questions. This will return true even if all the questions are
160 * hidden.
161 *
162 * @param mixed $context either a context object, or a context id.
163 * @return boolean whether any of the question categories beloning to this context have
164 * any questions in them.
165 */
166function question_context_has_any_questions($context) {
eb84a826 167 global $DB;
e2b347e9 168 if (is_object($context)) {
169 $contextid = $context->id;
170 } else if (is_numeric($context)) {
171 $contextid = $context;
172 } else {
173 print_error('invalidcontextinhasanyquestions', 'question');
174 }
eb84a826 175 return $DB->record_exists_sql("SELECT *
176 FROM {question} q
177 JOIN {question_categories} qc ON qc.id = q.category
178 WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
e2b347e9 179}
180
8511669c 181/**
aa5f0511
TH
182 * Check whether a given grade is one of a list of allowed options. If not,
183 * depending on $matchgrades, either return the nearest match, or return false
184 * to signal an error.
8511669c 185 * @param array $gradeoptionsfull list of valid options
186 * @param int $grade grade to be tested
187 * @param string $matchgrades 'error' or 'nearest'
aa5f0511 188 * @return mixed either 'fixed' value or false if error.
8511669c 189 */
aa5f0511
TH
190function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') {
191
aac80ff3 192 if ($matchgrades == 'error') {
aa5f0511 193 // (Almost) exact match, or an error.
aac80ff3 194 foreach ($gradeoptionsfull as $value => $option) {
aa5f0511 195 // Slightly fuzzy test, never check floats for equality.
aac80ff3 196 if (abs($grade - $value) < 0.00001) {
aa5f0511 197 return $value; // Be sure the return the proper value.
8511669c 198 }
199 }
aa5f0511 200 // Didn't find a match so that's an error.
8511669c 201 return false;
aa5f0511 202
aac80ff3 203 } else if ($matchgrades == 'nearest') {
aa5f0511
TH
204 // Work out nearest value
205 $best = false;
206 $bestmismatch = 2;
aac80ff3 207 foreach ($gradeoptionsfull as $value => $option) {
aa5f0511
TH
208 $newmismatch = abs($grade - $value);
209 if ($newmismatch < $bestmismatch) {
210 $best = $value;
211 $bestmismatch = $newmismatch;
8511669c 212 }
8511669c 213 }
aa5f0511
TH
214 return $best;
215
aac80ff3 216 } else {
aa5f0511
TH
217 // Unknow option passed.
218 throw new coding_exception('Unknown $matchgrades ' . $matchgrades .
219 ' passed to match_grade_options');
271ffe3f 220 }
8511669c 221}
222
f29aeb5a
TH
223/**
224 * Tests whether any question in a category is used by any part of Moodle.
225 *
226 * @param integer $categoryid a question category id.
227 * @param boolean $recursive whether to check child categories too.
228 * @return boolean whether any question in this category is in use.
229 */
230function question_category_in_use($categoryid, $recursive = false) {
eb84a826 231 global $DB;
f67172b6 232
233 //Look at each question in the category
aac80ff3
TH
234 if ($questions = $DB->get_records_menu('question',
235 array('category' => $categoryid), '', 'id, 1')) {
f29aeb5a
TH
236 if (questions_in_use(array_keys($questions))) {
237 return true;
f67172b6 238 }
239 }
f29aeb5a
TH
240 if (!$recursive) {
241 return false;
242 }
f67172b6 243
244 //Look under child categories recursively
aac80ff3
TH
245 if ($children = $DB->get_records('question_categories',
246 array('parent' => $categoryid), '', 'id, 1')) {
f29aeb5a
TH
247 foreach ($children as $child) {
248 if (question_category_in_use($child->id, $recursive)) {
249 return true;
f67172b6 250 }
251 }
252 }
253
254 return false;
255}
256
516cf3eb 257/**
6b11a0e8 258 * Deletes question and all associated data from the database
259 *
90c3f310 260 * It will not delete a question if it is used by an activity module
6b11a0e8 261 * @param object $question The question being deleted
262 */
f29aeb5a 263function question_delete_question($questionid) {
f9b0500f 264 global $DB;
271ffe3f 265
9203b705
TH
266 $question = $DB->get_record_sql('
267 SELECT q.*, qc.contextid
268 FROM {question} q
269 JOIN {question_categories} qc ON qc.id = q.category
270 WHERE q.id = ?', array($questionid));
271 if (!$question) {
3c573960 272 // In some situations, for example if this was a child of a
273 // Cloze question that was previously deleted, the question may already
274 // have gone. In this case, just do nothing.
275 return;
276 }
277
90c3f310 278 // Do not delete a question if it is used by an activity module
f29aeb5a 279 if (questions_in_use(array($questionid))) {
90c3f310 280 return;
281 }
282
f29aeb5a
TH
283 $dm = new question_engine_data_mapper();
284 $dm->delete_previews($questionid);
0429cd86 285
f29aeb5a
TH
286 // delete questiontype-specific data
287 question_bank::get_qtype($question->qtype, false)->delete_question(
288 $questionid, $question->contextid);
90c3f310 289
cc033d48
MN
290 // Delete all tag instances.
291 $DB->delete_records('tag_instance', array('component' => 'core_question', 'itemid' => $question->id));
292
90c3f310 293 // Now recursively delete all child questions
aac80ff3
TH
294 if ($children = $DB->get_records('question',
295 array('parent' => $questionid), '', 'id, qtype')) {
516cf3eb 296 foreach ($children as $child) {
1d723a16 297 if ($child->id != $questionid) {
7ee214ff 298 question_delete_question($child->id);
1d723a16 299 }
516cf3eb 300 }
301 }
271ffe3f 302
90c3f310 303 // Finally delete the question record itself
f29aeb5a 304 $DB->delete_records('question', array('id' => $questionid));
a560d636 305 question_bank::notify_question_edited($questionid);
516cf3eb 306}
307
f67172b6 308/**
3bee1ead 309 * All question categories and their questions are deleted for this course.
f67172b6 310 *
16ef46e7 311 * @param stdClass $course an object representing the activity
f67172b6 312 * @param boolean $feedback to specify if the process must output a summary of its work
313 * @return boolean
314 */
315function question_delete_course($course, $feedback=true) {
642816a6 316 global $DB, $OUTPUT;
eb84a826 317
f67172b6 318 //To store feedback to be showed at the end of the process
319 $feedbackdata = array();
320
321 //Cache some strings
e0c22222 322 $strcatdeleted = get_string('unusedcategorydeleted', 'question');
b0c6dc1c 323 $coursecontext = context_course::instance($course->id);
aac80ff3
TH
324 $categoriescourse = $DB->get_records('question_categories',
325 array('contextid' => $coursecontext->id), 'parent', 'id, parent, name, contextid');
f67172b6 326
3bee1ead 327 if ($categoriescourse) {
f67172b6 328
329 //Sort categories following their tree (parent-child) relationships
3bee1ead 330 //this will make the feedback more readable
331 $categoriescourse = sort_categories_by_tree($categoriescourse);
332
333 foreach ($categoriescourse as $category) {
334
335 //Delete it completely (questions and category itself)
336 //deleting questions
aac80ff3
TH
337 if ($questions = $DB->get_records('question',
338 array('category' => $category->id), '', 'id,qtype')) {
3bee1ead 339 foreach ($questions as $question) {
7ee214ff 340 question_delete_question($question->id);
f67172b6 341 }
aac80ff3 342 $DB->delete_records("question", array("category" => $category->id));
3bee1ead 343 }
344 //delete the category
aac80ff3 345 $DB->delete_records('question_categories', array('id' => $category->id));
f67172b6 346
3bee1ead 347 //Fill feedback
348 $feedbackdata[] = array($category->name, $strcatdeleted);
349 }
350 //Inform about changes performed if feedback is enabled
351 if ($feedback) {
642816a6 352 $table = new html_table();
e0c22222 353 $table->head = array(get_string('category', 'question'), get_string('action'));
3bee1ead 354 $table->data = $feedbackdata;
16be8974 355 echo html_writer::table($table);
3bee1ead 356 }
357 }
358 return true;
359}
f67172b6 360
e2b347e9 361/**
362 * Category is about to be deleted,
363 * 1/ All question categories and their questions are deleted for this course category.
364 * 2/ All questions are moved to new category
365 *
b33389d2
MG
366 * @param object|coursecat $category course category object
367 * @param object|coursecat $newcategory empty means everything deleted, otherwise id of
aac80ff3 368 * category where content moved
e2b347e9 369 * @param boolean $feedback to specify if the process must output a summary of its work
370 * @return boolean
371 */
372function question_delete_course_category($category, $newcategory, $feedback=true) {
aa9a6867 373 global $DB, $OUTPUT;
534792cd 374
b0c6dc1c 375 $context = context_coursecat::instance($category->id);
e2b347e9 376 if (empty($newcategory)) {
377 $feedbackdata = array(); // To store feedback to be showed at the end of the process
378 $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
e0c22222 379 $strcatdeleted = get_string('unusedcategorydeleted', 'question');
e2b347e9 380
381 // Loop over question categories.
aac80ff3
TH
382 if ($categories = $DB->get_records('question_categories',
383 array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
e2b347e9 384 foreach ($categories as $category) {
b9bd6da4 385
e2b347e9 386 // Deal with any questions in the category.
aac80ff3
TH
387 if ($questions = $DB->get_records('question',
388 array('category' => $category->id), '', 'id,qtype')) {
e2b347e9 389
390 // Try to delete each question.
391 foreach ($questions as $question) {
7ee214ff 392 question_delete_question($question->id);
e2b347e9 393 }
394
aac80ff3
TH
395 // Check to see if there were any questions that were kept because
396 // they are still in use somehow, even though quizzes in courses
cc033d48 397 // in this category will already have been deleted. This could
aac80ff3
TH
398 // happen, for example, if questions are added to a course,
399 // and then that course is moved to another category (MDL-14802).
400 $questionids = $DB->get_records_menu('question',
401 array('category'=>$category->id), '', 'id, 1');
e2b347e9 402 if (!empty($questionids)) {
7f5b51c4
RT
403 $parentcontextid = false;
404 $parentcontext = $context->get_parent_context();
405 if ($parentcontext) {
406 $parentcontextid = $parentcontext->id;
407 }
aac80ff3 408 if (!$rescueqcategory = question_save_from_deletion(
7f5b51c4 409 array_keys($questionids), $parentcontextid,
329846f1 410 $context->get_context_name(), $rescueqcategory)) {
e2b347e9 411 return false;
aac80ff3
TH
412 }
413 $feedbackdata[] = array($category->name,
414 get_string('questionsmovedto', 'question', $rescueqcategory->name));
e2b347e9 415 }
416 }
417
418 // Now delete the category.
eb84a826 419 if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
e2b347e9 420 return false;
421 }
422 $feedbackdata[] = array($category->name, $strcatdeleted);
423
424 } // End loop over categories.
425 }
426
427 // Output feedback if requested.
428 if ($feedback and $feedbackdata) {
642816a6 429 $table = new html_table();
aac80ff3 430 $table->head = array(get_string('questioncategory', 'question'), get_string('action'));
e2b347e9 431 $table->data = $feedbackdata;
16be8974 432 echo html_writer::table($table);
e2b347e9 433 }
434
435 } else {
cc033d48 436 // Move question categories to the new context.
b0c6dc1c 437 if (!$newcontext = context_coursecat::instance($newcategory->id)) {
e2b347e9 438 return false;
439 }
cc033d48
MN
440
441 // Update the contextid for any tag instances for questions in the old context.
442 $DB->set_field('tag_instance', 'contextid', $newcontext->id, array('component' => 'core_question',
443 'contextid' => $context->id));
444
445 $DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid' => $context->id));
446
e2b347e9 447 if ($feedback) {
0ff4bd08 448 $a = new stdClass();
329846f1
AG
449 $a->oldplace = $context->get_context_name();
450 $a->newplace = $newcontext->get_context_name();
aac80ff3
TH
451 echo $OUTPUT->notification(
452 get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
e2b347e9 453 }
454 }
455
456 return true;
457}
458
459/**
460 * Enter description here...
461 *
9d0ac0ff 462 * @param array $questionids of question ids
e2b347e9 463 * @param object $newcontext the context to create the saved category in.
aac80ff3
TH
464 * @param string $oldplace a textual description of the think being deleted,
465 * e.g. from get_context_name
e2b347e9 466 * @param object $newcategory
b9bd6da4 467 * @return mixed false on
e2b347e9 468 */
aac80ff3
TH
469function question_save_from_deletion($questionids, $newcontextid, $oldplace,
470 $newcategory = null) {
eb84a826 471 global $DB;
472
e2b347e9 473 // Make a category in the parent context to move the questions to.
474 if (is_null($newcategory)) {
365a5941 475 $newcategory = new stdClass();
e2b347e9 476 $newcategory->parent = 0;
477 $newcategory->contextid = $newcontextid;
eb84a826 478 $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
479 $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
e2b347e9 480 $newcategory->sortorder = 999;
481 $newcategory->stamp = make_unique_id_code();
a8f3a651 482 $newcategory->id = $DB->insert_record('question_categories', $newcategory);
e2b347e9 483 }
484
485 // Move any remaining questions to the 'saved' category.
486 if (!question_move_questions_to_category($questionids, $newcategory->id)) {
487 return false;
488 }
489 return $newcategory;
490}
491
3bee1ead 492/**
493 * All question categories and their questions are deleted for this activity.
494 *
495 * @param object $cm the course module object representing the activity
496 * @param boolean $feedback to specify if the process must output a summary of its work
497 * @return boolean
498 */
499function question_delete_activity($cm, $feedback=true) {
642816a6 500 global $DB, $OUTPUT;
eb84a826 501
3bee1ead 502 //To store feedback to be showed at the end of the process
503 $feedbackdata = array();
f67172b6 504
3bee1ead 505 //Cache some strings
e0c22222 506 $strcatdeleted = get_string('unusedcategorydeleted', 'question');
b0c6dc1c 507 $modcontext = context_module::instance($cm->id);
aac80ff3
TH
508 if ($categoriesmods = $DB->get_records('question_categories',
509 array('contextid' => $modcontext->id), 'parent', 'id, parent, name, contextid')) {
3bee1ead 510 //Sort categories following their tree (parent-child) relationships
511 //this will make the feedback more readable
512 $categoriesmods = sort_categories_by_tree($categoriesmods);
f67172b6 513
3bee1ead 514 foreach ($categoriesmods as $category) {
f67172b6 515
3bee1ead 516 //Delete it completely (questions and category itself)
517 //deleting questions
aac80ff3
TH
518 if ($questions = $DB->get_records('question',
519 array('category' => $category->id), '', 'id,qtype')) {
3bee1ead 520 foreach ($questions as $question) {
7ee214ff 521 question_delete_question($question->id);
3bee1ead 522 }
eb84a826 523 $DB->delete_records("question", array("category"=>$category->id));
f67172b6 524 }
3bee1ead 525 //delete the category
eb84a826 526 $DB->delete_records('question_categories', array('id'=>$category->id));
3bee1ead 527
528 //Fill feedback
529 $feedbackdata[] = array($category->name, $strcatdeleted);
f67172b6 530 }
531 //Inform about changes performed if feedback is enabled
532 if ($feedback) {
642816a6 533 $table = new html_table();
e0c22222 534 $table->head = array(get_string('category', 'question'), get_string('action'));
f67172b6 535 $table->data = $feedbackdata;
16be8974 536 echo html_writer::table($table);
f67172b6 537 }
538 }
539 return true;
540}
7fb1b88d 541
542/**
543 * This function should be considered private to the question bank, it is called from
aac80ff3
TH
544 * question/editlib.php question/contextmoveq.php and a few similar places to to the
545 * work of acutally moving questions and associated data. However, callers of this
546 * function also have to do other work, which is why you should not call this method
547 * directly from outside the questionbank.
7fb1b88d 548 *
9d0ac0ff 549 * @param array $questionids of question ids.
fe6ce234 550 * @param integer $newcategoryid the id of the category to move to.
7fb1b88d 551 */
fe6ce234 552function question_move_questions_to_category($questionids, $newcategoryid) {
d649fb02 553 global $DB;
f685e830 554
5d548d3e
TH
555 $newcontextid = $DB->get_field('question_categories', 'contextid',
556 array('id' => $newcategoryid));
557 list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
558 $questions = $DB->get_records_sql("
559 SELECT q.id, q.qtype, qc.contextid
560 FROM {question} q
561 JOIN {question_categories} qc ON q.category = qc.id
a3f92b2e 562 WHERE q.id $questionidcondition", $params);
5d548d3e
TH
563 foreach ($questions as $question) {
564 if ($newcontextid != $question->contextid) {
d649fb02
TH
565 question_bank::get_qtype($question->qtype)->move_files(
566 $question->id, $question->contextid, $newcontextid);
5d548d3e 567 }
fe6ce234
DC
568 }
569
7fb1b88d 570 // Move the questions themselves.
aac80ff3
TH
571 $DB->set_field_select('question', 'category', $newcategoryid,
572 "id $questionidcondition", $params);
7fb1b88d 573
574 // Move any subquestions belonging to them.
aac80ff3
TH
575 $DB->set_field_select('question', 'category', $newcategoryid,
576 "parent $questionidcondition", $params);
7fb1b88d 577
cc033d48
MN
578 // Update the contextid for any tag instances that may exist for these questions.
579 $DB->set_field_select('tag_instance', 'contextid', $newcontextid,
580 "component = 'core_question' AND itemid $questionidcondition", $params);
581
7fb1b88d 582 // TODO Deal with datasets.
583
a560d636
TH
584 // Purge these questions from the cache.
585 foreach ($questions as $question) {
586 question_bank::notify_question_edited($question->id);
587 }
588
f685e830 589 return true;
7fb1b88d 590}
591
5d548d3e
TH
592/**
593 * This function helps move a question cateogry to a new context by moving all
594 * the files belonging to all the questions to the new context.
595 * Also moves subcategories.
596 * @param integer $categoryid the id of the category being moved.
597 * @param integer $oldcontextid the old context id.
598 * @param integer $newcontextid the new context id.
599 */
600function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) {
d649fb02 601 global $DB;
5d548d3e
TH
602
603 $questionids = $DB->get_records_menu('question',
604 array('category' => $categoryid), '', 'id,qtype');
605 foreach ($questionids as $questionid => $qtype) {
aac80ff3
TH
606 question_bank::get_qtype($qtype)->move_files(
607 $questionid, $oldcontextid, $newcontextid);
a560d636
TH
608 // Purge this question from the cache.
609 question_bank::notify_question_edited($questionid);
5d548d3e
TH
610 }
611
cc033d48
MN
612 if ($questionids) {
613 // Update the contextid for any tag instances that may exist for these questions.
614 list($questionids, $params) = $DB->get_in_or_equal(array_keys($questionids));
615 $DB->set_field_select('tag_instance', 'contextid', $newcontextid,
616 "component = 'core_question' AND itemid $questionids", $params);
617 }
618
5d548d3e
TH
619 $subcatids = $DB->get_records_menu('question_categories',
620 array('parent' => $categoryid), '', 'id,1');
621 foreach ($subcatids as $subcatid => $notused) {
aac80ff3
TH
622 $DB->set_field('question_categories', 'contextid', $newcontextid,
623 array('id' => $subcatid));
5d548d3e
TH
624 question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
625 }
626}
627
f29aeb5a
TH
628/**
629 * Generate the URL for starting a new preview of a given question with the given options.
630 * @param integer $questionid the question to preview.
631 * @param string $preferredbehaviour the behaviour to use for the preview.
632 * @param float $maxmark the maximum to mark the question out of.
633 * @param question_display_options $displayoptions the display options to use.
c014b989
TH
634 * @param int $variant the variant of the question to preview. If null, one will
635 * be picked randomly.
56a4ae46
TH
636 * @param object $context context to run the preview in (affects things like
637 * filter settings, theme, lang, etc.) Defaults to $PAGE->context.
8011be18 638 * @return moodle_url the URL.
f29aeb5a 639 */
612106b3 640function question_preview_url($questionid, $preferredbehaviour = null,
56a4ae46 641 $maxmark = null, $displayoptions = null, $variant = null, $context = null) {
612106b3
TH
642
643 $params = array('id' => $questionid);
644
56a4ae46
TH
645 if (is_null($context)) {
646 global $PAGE;
647 $context = $PAGE->context;
648 }
649 if ($context->contextlevel == CONTEXT_MODULE) {
650 $params['cmid'] = $context->instanceid;
651 } else if ($context->contextlevel == CONTEXT_COURSE) {
652 $params['courseid'] = $context->instanceid;
653 }
654
612106b3
TH
655 if (!is_null($preferredbehaviour)) {
656 $params['behaviour'] = $preferredbehaviour;
657 }
658
659 if (!is_null($maxmark)) {
660 $params['maxmark'] = $maxmark;
661 }
662
663 if (!is_null($displayoptions)) {
664 $params['correctness'] = $displayoptions->correctness;
665 $params['marks'] = $displayoptions->marks;
666 $params['markdp'] = $displayoptions->markdp;
667 $params['feedback'] = (bool) $displayoptions->feedback;
668 $params['generalfeedback'] = (bool) $displayoptions->generalfeedback;
669 $params['rightanswer'] = (bool) $displayoptions->rightanswer;
670 $params['history'] = (bool) $displayoptions->history;
671 }
672
c014b989
TH
673 if ($variant) {
674 $params['variant'] = $variant;
675 }
612106b3 676
c014b989 677 return new moodle_url('/question/preview.php', $params);
f29aeb5a
TH
678}
679
612106b3
TH
680/**
681 * @return array that can be passed as $params to the {@link popup_action} constructor.
682 */
683function question_preview_popup_params() {
684 return array(
685 'height' => 600,
686 'width' => 800,
687 );
688}
689
5a2a5331 690/**
aac80ff3
TH
691 * Given a list of ids, load the basic information about a set of questions from
692 * the questions table. The $join and $extrafields arguments can be used together
693 * to pull in extra data. See, for example, the usage in mod/quiz/attemptlib.php, and
78e7a3dd 694 * read the code below to see how the SQL is assembled. Throws exceptions on error.
5a2a5331 695 *
ccba5b88
TH
696 * @param array $questionids array of question ids to load. If null, then all
697 * questions matched by $join will be loaded.
78e7a3dd 698 * @param string $extrafields extra SQL code to be added to the query.
699 * @param string $join extra SQL code to be added to the query.
700 * @param array $extraparams values for any placeholders in $join.
ccba5b88
TH
701 * You must use named placeholders.
702 * @param string $orderby what to order the results by. Optional, default is unspecified order.
5a2a5331 703 *
78e7a3dd 704 * @return array partially complete question objects. You need to call get_question_options
705 * on them before they can be properly used.
5a2a5331 706 */
ccba5b88
TH
707function question_preload_questions($questionids = null, $extrafields = '', $join = '',
708 $extraparams = array(), $orderby = '') {
f29aeb5a 709 global $DB;
ccba5b88
TH
710
711 if ($questionids === null) {
712 $where = '';
713 $params = array();
714 } else {
715 if (empty($questionids)) {
716 return array();
717 }
718
719 list($questionidcondition, $params) = $DB->get_in_or_equal(
720 $questionids, SQL_PARAMS_NAMED, 'qid0000');
721 $where = 'WHERE q.id ' . $questionidcondition;
4089d9ec 722 }
ccba5b88 723
5a2a5331 724 if ($join) {
ccba5b88 725 $join = 'JOIN ' . $join;
5a2a5331 726 }
ccba5b88 727
5a2a5331 728 if ($extrafields) {
729 $extrafields = ', ' . $extrafields;
730 }
5a2a5331 731
ccba5b88
TH
732 if ($orderby) {
733 $orderby = 'ORDER BY ' . $orderby;
5a2a5331 734 }
735
ccba5b88
TH
736 $sql = "SELECT q.*, qc.contextid{$extrafields}
737 FROM {question} q
738 JOIN {question_categories} qc ON q.category = qc.id
739 {$join}
740 {$where}
741 {$orderby}";
742
743 // Load the questions.
744 $questions = $DB->get_records_sql($sql, $extraparams + $params);
78e7a3dd 745 foreach ($questions as $question) {
746 $question->_partiallyloaded = true;
747 }
748
749 return $questions;
750}
751
752/**
753 * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
754 * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
755 * read the code below to see how the SQL is assembled. Throws exceptions on error.
756 *
757 * @param array $questionids array of question ids.
758 * @param string $extrafields extra SQL code to be added to the query.
759 * @param string $join extra SQL code to be added to the query.
760 * @param array $extraparams values for any placeholders in $join.
761 * You are strongly recommended to use named placeholder.
762 *
763 * @return array question objects.
764 */
765function question_load_questions($questionids, $extrafields = '', $join = '') {
766 $questions = question_preload_questions($questionids, $extrafields, $join);
767
5a2a5331 768 // Load the question type specific information
769 if (!get_question_options($questions)) {
770 return 'Could not load the question options';
771 }
772
773 return $questions;
774}
775
516cf3eb 776/**
d7444d44 777 * Private function to factor common code out of get_question_options().
271ffe3f 778 *
d7444d44 779 * @param object $question the question to tidy.
c599a682 780 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
d7444d44 781 */
c76145d3 782function _tidy_question($question, $loadtags = false) {
d649fb02 783 global $CFG;
d2acbd1a
TH
784
785 // Load question-type specific fields.
2daffca5 786 if (!question_bank::is_qtype_installed($question->qtype)) {
d649fb02
TH
787 $question->questiontext = html_writer::tag('p', get_string('warningmissingtype',
788 'qtype_missingtype')) . $question->questiontext;
d7444d44 789 }
d649fb02 790 question_bank::get_qtype($question->qtype)->get_question_options($question);
d2acbd1a
TH
791
792 // Convert numeric fields to float. (Prevents these being displayed as 1.0000000.)
793 $question->defaultmark += 0;
794 $question->penalty += 0;
795
a8a8ec51
TH
796 if (isset($question->_partiallyloaded)) {
797 unset($question->_partiallyloaded);
78e7a3dd 798 }
d2acbd1a 799
c599a682 800 if ($loadtags && !empty($CFG->usetags)) {
801 require_once($CFG->dirroot . '/tag/lib.php');
802 $question->tags = tag_get_tags_array('question', $question->id);
803 }
d7444d44 804}
516cf3eb 805
d7444d44 806/**
807 * Updates the question objects with question type specific
808 * information by calling {@link get_question_options()}
809 *
810 * Can be called either with an array of question objects or with a single
811 * question object.
271ffe3f 812 *
d7444d44 813 * @param mixed $questions Either an array of question objects to be updated
814 * or just a single question object
c599a682 815 * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
d7444d44 816 * @return bool Indicates success or failure.
817 */
c599a682 818function get_question_options(&$questions, $loadtags = false) {
516cf3eb 819 if (is_array($questions)) { // deal with an array of questions
d7444d44 820 foreach ($questions as $i => $notused) {
a8a8ec51 821 _tidy_question($questions[$i], $loadtags);
516cf3eb 822 }
516cf3eb 823 } else { // deal with single question
a8a8ec51 824 _tidy_question($questions, $loadtags);
516cf3eb 825 }
a8a8ec51 826 return true;
516cf3eb 827}
828
829/**
aac80ff3
TH
830 * Print the icon for the question type
831 *
832 * @param object $question The question object for which the icon is required.
833 * Only $question->qtype is used.
834 * @return string the HTML for the img tag.
835 */
f29aeb5a 836function print_question_icon($question) {
5cc021a0
TH
837 global $PAGE;
838 return $PAGE->get_renderer('question', 'bank')->qtype_icon($question->qtype);
81d833ad 839}
840
841/**
f29aeb5a 842 * Creates a stamp that uniquely identifies this version of the question
81d833ad 843 *
f29aeb5a
TH
844 * In future we want this to use a hash of the question data to guarantee that
845 * identical versions have the same version stamp.
846 *
847 * @param object $question
848 * @return string A unique version stamp
81d833ad 849 */
f29aeb5a
TH
850function question_hash($question) {
851 return make_unique_id_code();
81d833ad 852}
853
f29aeb5a
TH
854/// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
855
516cf3eb 856/**
f29aeb5a
TH
857 * returns the categories with their names ordered following parent-child relationships
858 * finally it tries to return pending categories (those being orphaned, whose parent is
859 * incorrect) to avoid missing any category from original array.
860 */
861function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
862 global $DB;
5e9170b4 863
f29aeb5a
TH
864 $children = array();
865 $keys = array_keys($categories);
516cf3eb 866
f29aeb5a
TH
867 foreach ($keys as $key) {
868 if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
869 $children[$key] = $categories[$key];
870 $categories[$key]->processed = true;
aac80ff3
TH
871 $children = $children + sort_categories_by_tree(
872 $categories, $children[$key]->id, $level+1);
516cf3eb 873 }
874 }
aac80ff3
TH
875 //If level = 1, we have finished, try to look for non processed categories
876 // (bad parent) and sort them too
f29aeb5a
TH
877 if ($level == 1) {
878 foreach ($keys as $key) {
aac80ff3
TH
879 // If not processed and it's a good candidate to start (because its
880 // parent doesn't exist in the course)
881 if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories',
882 array('contextid' => $categories[$key]->contextid,
883 'id' => $categories[$key]->parent))) {
f29aeb5a
TH
884 $children[$key] = $categories[$key];
885 $categories[$key]->processed = true;
aac80ff3
TH
886 $children = $children + sort_categories_by_tree(
887 $categories, $children[$key]->id, $level + 1);
f29aeb5a
TH
888 }
889 }
516cf3eb 890 }
f29aeb5a 891 return $children;
6cb4910c 892}
893
894/**
062a7522 895 * Private method, only for the use of add_indented_names().
271ffe3f 896 *
062a7522 897 * Recursively adds an indentedname field to each category, starting with the category
271ffe3f 898 * with id $id, and dealing with that category and all its children, and
062a7522 899 * return a new array, with those categories in the right order.
900 *
271ffe3f 901 * @param array $categories an array of categories which has had childids
062a7522 902 * fields added by flatten_category_tree(). Passed by reference for
903 * performance only. It is not modfied.
904 * @param int $id the category to start the indenting process from.
905 * @param int $depth the indent depth. Used in recursive calls.
906 * @return array a new array of categories, in the right order for the tree.
6cb4910c 907 */
3bee1ead 908function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
271ffe3f 909
062a7522 910 // Indent the name of this category.
911 $newcategories = array();
912 $newcategories[$id] = $categories[$id];
aac80ff3
TH
913 $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) .
914 $categories[$id]->name;
271ffe3f 915
062a7522 916 // Recursively indent the children.
917 foreach ($categories[$id]->childids as $childid) {
aac80ff3
TH
918 if ($childid != $nochildrenof) {
919 $newcategories = $newcategories + flatten_category_tree(
920 $categories, $childid, $depth + 1, $nochildrenof);
3bee1ead 921 }
6cb4910c 922 }
271ffe3f 923
062a7522 924 // Remove the childids array that were temporarily added.
925 unset($newcategories[$id]->childids);
271ffe3f 926
062a7522 927 return $newcategories;
6cb4910c 928}
929
930/**
062a7522 931 * Format categories into an indented list reflecting the tree structure.
271ffe3f 932 *
062a7522 933 * @param array $categories An array of category objects, for example from the.
934 * @return array The formatted list of categories.
6cb4910c 935 */
3bee1ead 936function add_indented_names($categories, $nochildrenof = -1) {
6cb4910c 937
aac80ff3
TH
938 // Add an array to each category to hold the child category ids. This array
939 // will be removed again by flatten_category_tree(). It should not be used
940 // outside these two functions.
062a7522 941 foreach (array_keys($categories) as $id) {
942 $categories[$id]->childids = array();
6cb4910c 943 }
944
062a7522 945 // Build the tree structure, and record which categories are top-level.
271ffe3f 946 // We have to be careful, because the categories array may include published
062a7522 947 // categories from other courses, but not their parents.
948 $toplevelcategoryids = array();
949 foreach (array_keys($categories) as $id) {
aac80ff3
TH
950 if (!empty($categories[$id]->parent) &&
951 array_key_exists($categories[$id]->parent, $categories)) {
062a7522 952 $categories[$categories[$id]->parent]->childids[] = $id;
953 } else {
954 $toplevelcategoryids[] = $id;
6cb4910c 955 }
956 }
957
062a7522 958 // Flatten the tree to and add the indents.
959 $newcategories = array();
960 foreach ($toplevelcategoryids as $id) {
aac80ff3
TH
961 $newcategories = $newcategories + flatten_category_tree(
962 $categories, $id, 0, $nochildrenof);
6cb4910c 963 }
964
062a7522 965 return $newcategories;
6cb4910c 966}
2dd6d66b 967
516cf3eb 968/**
062a7522 969 * Output a select menu of question categories.
271ffe3f 970 *
062a7522 971 * Categories from this course and (optionally) published categories from other courses
271ffe3f 972 * are included. Optionally, only categories the current user may edit can be included.
062a7522 973 *
974 * @param integer $courseid the id of the course to get the categories for.
975 * @param integer $published if true, include publised categories from other courses.
976 * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
aac80ff3
TH
977 * @param integer $selected optionally, the id of a category to be selected by
978 * default in the dropdown.
062a7522 979 */
aac80ff3
TH
980function question_category_select_menu($contexts, $top = false, $currentcat = 0,
981 $selected = "", $nochildrenof = -1) {
641454c6 982 global $OUTPUT;
aac80ff3
TH
983 $categoriesarray = question_category_options($contexts, $top, $currentcat,
984 false, $nochildrenof);
3ed998f3 985 if ($selected) {
6770330d 986 $choose = '';
3ed998f3 987 } else {
6770330d 988 $choose = 'choosedots';
516cf3eb 989 }
6770330d 990 $options = array();
aac80ff3
TH
991 foreach ($categoriesarray as $group => $opts) {
992 $options[] = array($group => $opts);
6770330d 993 }
9422f6ee
MG
994 echo html_writer::label(get_string('questioncategory', 'core_question'), 'id_movetocategory', false, array('class' => 'accesshide'));
995 echo html_writer::select($options, 'category', $selected, $choose, array('id' => 'id_movetocategory'));
516cf3eb 996}
997
cca6e300 998/**
999 * @param integer $contextid a context id.
1000 * @return object the default question category for that context, or false if none.
1001 */
1002function question_get_default_category($contextid) {
1003 global $DB;
aac80ff3
TH
1004 $category = $DB->get_records('question_categories',
1005 array('contextid' => $contextid), 'id', '*', 0, 1);
cca6e300 1006 if (!empty($category)) {
1007 return reset($category);
1008 } else {
1009 return false;
1010 }
1011}
1012
8f0f605d 1013/**
aac80ff3
TH
1014 * Gets the default category in the most specific context.
1015 * If no categories exist yet then default ones are created in all contexts.
1016 *
1017 * @param array $contexts The context objects for this context and all parent contexts.
1018 * @return object The default category - the category in the course context
1019 */
8f0f605d 1020function question_make_default_categories($contexts) {
eb84a826 1021 global $DB;
353b2d70 1022 static $preferredlevels = array(
1023 CONTEXT_COURSE => 4,
1024 CONTEXT_MODULE => 3,
1025 CONTEXT_COURSECAT => 2,
1026 CONTEXT_SYSTEM => 1,
1027 );
eb84a826 1028
8f0f605d 1029 $toreturn = null;
353b2d70 1030 $preferredness = 0;
8f0f605d 1031 // If it already exists, just return it.
1032 foreach ($contexts as $key => $context) {
aac80ff3
TH
1033 if (!$exists = $DB->record_exists("question_categories",
1034 array('contextid' => $context->id))) {
eb84a826 1035 // Otherwise, we need to make one
0ff4bd08 1036 $category = new stdClass();
329846f1 1037 $contextname = $context->get_context_name(false, true);
eb84a826 1038 $category->name = get_string('defaultfor', 'question', $contextname);
1039 $category->info = get_string('defaultinfofor', 'question', $contextname);
1040 $category->contextid = $context->id;
1041 $category->parent = 0;
aac80ff3
TH
1042 // By default, all categories get this number, and are sorted alphabetically.
1043 $category->sortorder = 999;
eb84a826 1044 $category->stamp = make_unique_id_code();
bf8e93d7 1045 $category->id = $DB->insert_record('question_categories', $category);
b9bd6da4 1046 } else {
cca6e300 1047 $category = question_get_default_category($context->id);
8f0f605d 1048 }
76cf77e4
TH
1049 $thispreferredness = $preferredlevels[$context->contextlevel];
1050 if (has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
1051 $thispreferredness += 10;
1052 }
1053 if ($thispreferredness > $preferredness) {
353b2d70 1054 $toreturn = $category;
76cf77e4 1055 $preferredness = $thispreferredness;
8f0f605d 1056 }
1057 }
1058
353b2d70 1059 if (!is_null($toreturn)) {
1060 $toreturn = clone($toreturn);
1061 }
8f0f605d 1062 return $toreturn;
1063}
1064
375ed78a 1065/**
3bee1ead 1066 * Get all the category objects, including a count of the number of questions in that category,
1067 * for all the categories in the lists $contexts.
375ed78a 1068 *
3bee1ead 1069 * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
1070 * @param string $sortorder used as the ORDER BY clause in the select statement.
1071 * @return array of category objects.
375ed78a 1072 */
3bee1ead 1073function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
eb84a826 1074 global $DB;
1075 return $DB->get_records_sql("
630a3dc3 1076 SELECT c.*, (SELECT count(1) FROM {question} q
eb84a826 1077 WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
1078 FROM {question_categories} c
1079 WHERE c.contextid IN ($contexts)
1080 ORDER BY $sortorder");
3bee1ead 1081}
3a3c454e 1082
3bee1ead 1083/**
1084 * Output an array of question categories.
1085 */
aac80ff3
TH
1086function question_category_options($contexts, $top = false, $currentcat = 0,
1087 $popupform = false, $nochildrenof = -1) {
3bee1ead 1088 global $CFG;
1089 $pcontexts = array();
aac80ff3 1090 foreach ($contexts as $context) {
3bee1ead 1091 $pcontexts[] = $context->id;
61d9b9fc 1092 }
3bee1ead 1093 $contextslist = join($pcontexts, ', ');
1094
1095 $categories = get_categories_for_contexts($contextslist);
375ed78a 1096
3bee1ead 1097 $categories = question_add_context_in_key($categories);
1098
aac80ff3 1099 if ($top) {
3bee1ead 1100 $categories = question_add_tops($categories, $pcontexts);
1101 }
1102 $categories = add_indented_names($categories, $nochildrenof);
3ed998f3 1103
aac80ff3 1104 // sort cats out into different contexts
375ed78a 1105 $categoriesarray = array();
fc51c2bc
TH
1106 foreach ($pcontexts as $contextid) {
1107 $context = context::instance_by_id($contextid);
1108 $contextstring = $context->get_context_name(true, true);
3bee1ead 1109 foreach ($categories as $category) {
fc51c2bc 1110 if ($category->contextid == $contextid) {
3bee1ead 1111 $cid = $category->id;
aac80ff3
TH
1112 if ($currentcat != $cid || $currentcat == 0) {
1113 $countstring = !empty($category->questioncount) ?
1114 " ($category->questioncount)" : '';
fc51c2bc
TH
1115 $categoriesarray[$contextstring][$cid] =
1116 format_string($category->indentedname, true,
1117 array('context' => $context)) . $countstring;
3bee1ead 1118 }
1119 }
375ed78a 1120 }
1121 }
aac80ff3 1122 if ($popupform) {
3bee1ead 1123 $popupcats = array();
aac80ff3 1124 foreach ($categoriesarray as $contextstring => $optgroup) {
94fa6be4 1125 $group = array();
aac80ff3 1126 foreach ($optgroup as $key => $value) {
78bfb562
PS
1127 $key = str_replace($CFG->wwwroot, '', $key);
1128 $group[$key] = $value;
94fa6be4 1129 }
aac80ff3 1130 $popupcats[] = array($contextstring => $group);
3bee1ead 1131 }
1132 return $popupcats;
1133 } else {
1134 return $categoriesarray;
1135 }
375ed78a 1136}
1137
aac80ff3 1138function question_add_context_in_key($categories) {
3bee1ead 1139 $newcatarray = array();
1140 foreach ($categories as $id => $category) {
1141 $category->parent = "$category->parent,$category->contextid";
1142 $category->id = "$category->id,$category->contextid";
1143 $newcatarray["$id,$category->contextid"] = $category;
516cf3eb 1144 }
3bee1ead 1145 return $newcatarray;
1146}
f29aeb5a 1147
aac80ff3 1148function question_add_tops($categories, $pcontexts) {
3bee1ead 1149 $topcats = array();
aac80ff3 1150 foreach ($pcontexts as $context) {
365a5941 1151 $newcat = new stdClass();
3bee1ead 1152 $newcat->id = "0,$context";
1153 $newcat->name = get_string('top');
1154 $newcat->parent = -1;
1155 $newcat->contextid = $context;
1156 $topcats["0,$context"] = $newcat;
1157 }
1158 //put topcats in at beginning of array - they'll be sorted into different contexts later.
1159 return array_merge($topcats, $categories);
516cf3eb 1160}
1161
516cf3eb 1162/**
2daffca5 1163 * @return array of question category ids of the category and all subcategories.
b20ea669 1164 */
dc1f00de 1165function question_categorylist($categoryid) {
eb84a826 1166 global $DB;
1167
e80c64ab 1168 // final list of category IDs
0f76ac4f
TM
1169 $categorylist = array();
1170
e80c64ab
TM
1171 // a list of category IDs to check for any sub-categories
1172 $subcategories = array($categoryid);
0f76ac4f 1173
e80c64ab 1174 while ($subcategories) {
0f76ac4f 1175 foreach ($subcategories as $subcategory) {
e80c64ab
TM
1176 // if anything from the temporary list was added already, then we have a loop
1177 if (isset($categorylist[$subcategory])) {
1178 throw new coding_exception("Category id=$subcategory is already on the list - loop of categories detected.");
1179 }
1180 $categorylist[$subcategory] = $subcategory;
0f76ac4f
TM
1181 }
1182
e80c64ab
TM
1183 list ($in, $params) = $DB->get_in_or_equal($subcategories);
1184
1185 $subcategories = $DB->get_records_select_menu('question_categories',
1186 "parent $in", $params, NULL, 'id,id AS id2');
516cf3eb 1187 }
2daffca5 1188
516cf3eb 1189 return $categorylist;
1190}
1191
947217d7 1192//===========================
1193// Import/Export Functions
1194//===========================
1195
ff4b6492 1196/**
1197 * Get list of available import or export formats
1198 * @param string $type 'import' if import list, otherwise export list assumed
271ffe3f 1199 * @return array sorted list of import/export formats available
50fcb1d8 1200 */
f29aeb5a 1201function get_import_export_formats($type) {
ff4b6492 1202 global $CFG;
44a7f384 1203 require_once($CFG->dirroot . '/question/format.php');
f7c1dfaf 1204
059e08ed 1205 $formatclasses = core_component::get_plugin_list_with_class('qformat', '', 'format.php');
ff4b6492 1206
f7c1dfaf 1207 $fileformatname = array();
44a7f384 1208 foreach ($formatclasses as $component => $formatclass) {
f29aeb5a 1209
44a7f384 1210 $format = new $formatclass();
f29aeb5a 1211 if ($type == 'import') {
44a7f384 1212 $provided = $format->provide_import();
f29aeb5a 1213 } else {
44a7f384 1214 $provided = $format->provide_export();
ff4b6492 1215 }
f29aeb5a 1216
ff4b6492 1217 if ($provided) {
44a7f384 1218 list($notused, $fileformat) = explode('_', $component, 2);
64679179 1219 $fileformatnames[$fileformat] = get_string('pluginname', $component);
ff4b6492 1220 }
1221 }
ff4b6492 1222
2f1e464a 1223 core_collator::asort($fileformatnames);
ff4b6492 1224 return $fileformatnames;
1225}
feb60a07 1226
1227
1228/**
aac80ff3
TH
1229 * Create a reasonable default file name for exporting questions from a particular
1230 * category.
1231 * @param object $course the course the questions are in.
1232 * @param object $category the question category.
1233 * @return string the filename.
1234 */
b80d424c
TH
1235function question_default_export_filename($course, $category) {
1236 // We build a string that is an appropriate name (questions) from the lang pack,
0ff4bd08 1237 // then the corse shortname, then the question category name, then a timestamp.
b80d424c
TH
1238
1239 $base = clean_filename(get_string('exportfilename', 'question'));
1240
1241 $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question'));
1242 $timestamp = clean_filename(userdate(time(), $dateformat, 99, false));
1243
1244 $shortname = clean_filename($course->shortname);
1245 if ($shortname == '' || $shortname == '_' ) {
1246 $shortname = $course->id;
1247 }
1248
1249 $categoryname = clean_filename(format_string($category->name));
1250
1251 return "{$base}-{$shortname}-{$categoryname}-{$timestamp}";
feb60a07 1252
1253 return $export_name;
1254}
50fcb1d8 1255
1256/**
f29aeb5a
TH
1257 * Converts contextlevels to strings and back to help with reading/writing contexts
1258 * to/from import/export files.
1259 *
50fcb1d8 1260 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
1261 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1262 */
3bee1ead 1263class context_to_string_translator{
1264 /**
1265 * @var array used to translate between contextids and strings for this context.
1266 */
f29aeb5a 1267 protected $contexttostringarray = array();
3bee1ead 1268
f29aeb5a 1269 public function __construct($contexts) {
3bee1ead 1270 $this->generate_context_to_string_array($contexts);
1271 }
1272
f29aeb5a 1273 public function context_to_string($contextid) {
3bee1ead 1274 return $this->contexttostringarray[$contextid];
1275 }
1276
f29aeb5a 1277 public function string_to_context($contextname) {
3bee1ead 1278 $contextid = array_search($contextname, $this->contexttostringarray);
1279 return $contextid;
1280 }
1281
f29aeb5a 1282 protected function generate_context_to_string_array($contexts) {
aac80ff3 1283 if (!$this->contexttostringarray) {
3bee1ead 1284 $catno = 1;
aac80ff3
TH
1285 foreach ($contexts as $context) {
1286 switch ($context->contextlevel) {
3bee1ead 1287 case CONTEXT_MODULE :
1288 $contextstring = 'module';
1289 break;
1290 case CONTEXT_COURSE :
1291 $contextstring = 'course';
1292 break;
1293 case CONTEXT_COURSECAT :
1294 $contextstring = "cat$catno";
1295 $catno++;
1296 break;
1297 case CONTEXT_SYSTEM :
1298 $contextstring = 'system';
1299 break;
1300 }
1301 $this->contexttostringarray[$context->id] = $contextstring;
1302 }
1303 }
1304 }
1305
1306}
feb60a07 1307
3bee1ead 1308/**
1309 * Check capability on category
50fcb1d8 1310 *
3bee1ead 1311 * @param mixed $question object or id
1312 * @param string $cap 'add', 'edit', 'view', 'use', 'move'
1313 * @param integer $cachecat useful to cache all question records in a category
1314 * @return boolean this user has the capability $cap for this question $question?
1315 */
aac80ff3 1316function question_has_capability_on($question, $cap, $cachecat = -1) {
eb84a826 1317 global $USER, $DB;
1318
3bee1ead 1319 // these are capabilities on existing questions capabilties are
1320 //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
1321 $question_questioncaps = array('edit', 'view', 'use', 'move');
3bee1ead 1322 static $questions = array();
1323 static $categories = array();
1324 static $cachedcat = array();
9203b705 1325 if ($cachecat != -1 && array_search($cachecat, $cachedcat) === false) {
fe0fbfae 1326 $questions += $DB->get_records('question', array('category' => $cachecat), '', 'id,category,createdby');
3bee1ead 1327 $cachedcat[] = $cachecat;
1328 }
aac80ff3
TH
1329 if (!is_object($question)) {
1330 if (!isset($questions[$question])) {
1331 if (!$questions[$question] = $DB->get_record('question',
1332 array('id' => $question), 'id,category,createdby')) {
0f6475b3 1333 print_error('questiondoesnotexist', 'question');
3bee1ead 1334 }
1335 }
1336 $question = $questions[$question];
1337 }
2a73f863
TH
1338 if (empty($question->category)) {
1339 // This can happen when we have created a fake 'missingtype' question to
1340 // take the place of a deleted question.
1341 return false;
1342 }
aac80ff3
TH
1343 if (!isset($categories[$question->category])) {
1344 if (!$categories[$question->category] = $DB->get_record('question_categories',
1345 array('id'=>$question->category))) {
3bee1ead 1346 print_error('invalidcategory', 'quiz');
1347 }
1348 }
1349 $category = $categories[$question->category];
d197ea43 1350 $context = context::instance_by_id($category->contextid);
3bee1ead 1351
aac80ff3
TH
1352 if (array_search($cap, $question_questioncaps)!== false) {
1353 if (!has_capability('moodle/question:' . $cap . 'all', $context)) {
1354 if ($question->createdby == $USER->id) {
1355 return has_capability('moodle/question:' . $cap . 'mine', $context);
3bee1ead 1356 } else {
1357 return false;
1358 }
1359 } else {
1360 return true;
1361 }
1362 } else {
aac80ff3 1363 return has_capability('moodle/question:' . $cap, $context);
3bee1ead 1364 }
1365
1366}
1367
1368/**
1369 * Require capability on question.
1370 */
aac80ff3
TH
1371function question_require_capability_on($question, $cap) {
1372 if (!question_has_capability_on($question, $cap)) {
3bee1ead 1373 print_error('nopermissions', '', '', $cap);
1374 }
1375 return true;
1376}
1377
62e76c67 1378/**
f29aeb5a
TH
1379 * @param object $context a context
1380 * @return string A URL for editing questions in this context.
f62040ed 1381 */
f29aeb5a
TH
1382function question_edit_url($context) {
1383 global $CFG, $SITE;
1384 if (!has_any_capability(question_get_question_capabilities(), $context)) {
1385 return false;
f62040ed 1386 }
f29aeb5a
TH
1387 $baseurl = $CFG->wwwroot . '/question/edit.php?';
1388 $defaultcategory = question_get_default_category($context->id);
1389 if ($defaultcategory) {
1390 $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
f62040ed 1391 }
f29aeb5a
TH
1392 switch ($context->contextlevel) {
1393 case CONTEXT_SYSTEM:
1394 return $baseurl . 'courseid=' . $SITE->id;
1395 case CONTEXT_COURSECAT:
1396 // This is nasty, becuase we can only edit questions in a course
1397 // context at the moment, so for now we just return false.
1398 return false;
1399 case CONTEXT_COURSE:
1400 return $baseurl . 'courseid=' . $context->instanceid;
1401 case CONTEXT_MODULE:
1402 return $baseurl . 'cmid=' . $context->instanceid;
62e76c67 1403 }
f29aeb5a 1404
62e76c67 1405}
56ed242b
SH
1406
1407/**
792881f0 1408 * Adds question bank setting links to the given navigation node if caps are met.
56ed242b
SH
1409 *
1410 * @param navigation_node $navigationnode The navigation node to add the question branch to
0ff4bd08 1411 * @param object $context
56ed242b
SH
1412 * @return navigation_node Returns the question branch that was added
1413 */
1414function question_extend_settings_navigation(navigation_node $navigationnode, $context) {
1415 global $PAGE;
1416
1417 if ($context->contextlevel == CONTEXT_COURSE) {
1418 $params = array('courseid'=>$context->instanceid);
1419 } else if ($context->contextlevel == CONTEXT_MODULE) {
1420 $params = array('cmid'=>$context->instanceid);
1421 } else {
1422 return;
1423 }
1424
8356e6cf
TH
1425 if (($cat = $PAGE->url->param('cat')) && preg_match('~\d+,\d+~', $cat)) {
1426 $params['cat'] = $cat;
1427 }
1428
aac80ff3
TH
1429 $questionnode = $navigationnode->add(get_string('questionbank', 'question'),
1430 new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER);
56ed242b
SH
1431
1432 $contexts = new question_edit_contexts($context);
1433 if ($contexts->have_one_edit_tab_cap('questions')) {
e0c22222 1434 $questionnode->add(get_string('questions', 'question'), new moodle_url(
aac80ff3 1435 '/question/edit.php', $params), navigation_node::TYPE_SETTING);
56ed242b
SH
1436 }
1437 if ($contexts->have_one_edit_tab_cap('categories')) {
e0c22222 1438 $questionnode->add(get_string('categories', 'question'), new moodle_url(
aac80ff3 1439 '/question/category.php', $params), navigation_node::TYPE_SETTING);
56ed242b
SH
1440 }
1441 if ($contexts->have_one_edit_tab_cap('import')) {
e0c22222 1442 $questionnode->add(get_string('import', 'question'), new moodle_url(
aac80ff3 1443 '/question/import.php', $params), navigation_node::TYPE_SETTING);
56ed242b
SH
1444 }
1445 if ($contexts->have_one_edit_tab_cap('export')) {
e0c22222 1446 $questionnode->add(get_string('export', 'question'), new moodle_url(
aac80ff3 1447 '/question/export.php', $params), navigation_node::TYPE_SETTING);
56ed242b
SH
1448 }
1449
1450 return $questionnode;
1451}
1452
f29aeb5a
TH
1453/**
1454 * @return array all the capabilities that relate to accessing particular questions.
1455 */
1456function question_get_question_capabilities() {
1457 return array(
1458 'moodle/question:add',
1459 'moodle/question:editmine',
1460 'moodle/question:editall',
1461 'moodle/question:viewmine',
1462 'moodle/question:viewall',
1463 'moodle/question:usemine',
1464 'moodle/question:useall',
1465 'moodle/question:movemine',
1466 'moodle/question:moveall',
1467 );
1468}
1469
1470/**
1471 * @return array all the question bank capabilities.
1472 */
1473function question_get_all_capabilities() {
1474 $caps = question_get_question_capabilities();
1475 $caps[] = 'moodle/question:managecategory';
1476 $caps[] = 'moodle/question:flag';
1477 return $caps;
1478}
1479
6f4c71f5
TH
1480
1481/**
1482 * Tracks all the contexts related to the one where we are currently editing
1483 * questions, and provides helper methods to check permissions.
1484 *
1485 * @copyright 2007 Jamie Pratt me@jamiep.org
1486 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1487 */
56ed242b
SH
1488class question_edit_contexts {
1489
aac80ff3 1490 public static $caps = array(
56ed242b
SH
1491 'editq' => array('moodle/question:add',
1492 'moodle/question:editmine',
1493 'moodle/question:editall',
1494 'moodle/question:viewmine',
1495 'moodle/question:viewall',
1496 'moodle/question:usemine',
1497 'moodle/question:useall',
1498 'moodle/question:movemine',
1499 'moodle/question:moveall'),
1500 'questions'=>array('moodle/question:add',
1501 'moodle/question:editmine',
1502 'moodle/question:editall',
1503 'moodle/question:viewmine',
1504 'moodle/question:viewall',
1505 'moodle/question:movemine',
1506 'moodle/question:moveall'),
1507 'categories'=>array('moodle/question:managecategory'),
1508 'import'=>array('moodle/question:add'),
1509 'export'=>array('moodle/question:viewall', 'moodle/question:viewmine'));
1510
1511 protected $allcontexts;
1512
1513 /**
6f4c71f5
TH
1514 * Constructor
1515 * @param context the current context.
56ed242b 1516 */
6f4c71f5
TH
1517 public function __construct(context $thiscontext) {
1518 $this->allcontexts = array_values($thiscontext->get_parent_contexts(true));
56ed242b 1519 }
6f4c71f5 1520
56ed242b
SH
1521 /**
1522 * @return array all parent contexts
1523 */
f29aeb5a 1524 public function all() {
56ed242b
SH
1525 return $this->allcontexts;
1526 }
6f4c71f5 1527
56ed242b
SH
1528 /**
1529 * @return object lowest context which must be either the module or course context
1530 */
f29aeb5a 1531 public function lowest() {
56ed242b
SH
1532 return $this->allcontexts[0];
1533 }
6f4c71f5 1534
56ed242b
SH
1535 /**
1536 * @param string $cap capability
1537 * @return array parent contexts having capability, zero based index
1538 */
f29aeb5a 1539 public function having_cap($cap) {
56ed242b 1540 $contextswithcap = array();
aac80ff3
TH
1541 foreach ($this->allcontexts as $context) {
1542 if (has_capability($cap, $context)) {
56ed242b
SH
1543 $contextswithcap[] = $context;
1544 }
1545 }
1546 return $contextswithcap;
1547 }
6f4c71f5 1548
56ed242b
SH
1549 /**
1550 * @param array $caps capabilities
1551 * @return array parent contexts having at least one of $caps, zero based index
1552 */
f29aeb5a 1553 public function having_one_cap($caps) {
56ed242b 1554 $contextswithacap = array();
aac80ff3
TH
1555 foreach ($this->allcontexts as $context) {
1556 foreach ($caps as $cap) {
1557 if (has_capability($cap, $context)) {
56ed242b
SH
1558 $contextswithacap[] = $context;
1559 break; //done with caps loop
1560 }
1561 }
1562 }
1563 return $contextswithacap;
1564 }
6f4c71f5 1565
56ed242b
SH
1566 /**
1567 * @param string $tabname edit tab name
1568 * @return array parent contexts having at least one of $caps, zero based index
1569 */
f29aeb5a 1570 public function having_one_edit_tab_cap($tabname) {
aac80ff3 1571 return $this->having_one_cap(self::$caps[$tabname]);
56ed242b 1572 }
6f4c71f5 1573
76cf77e4
TH
1574 /**
1575 * @return those contexts where a user can add a question and then use it.
1576 */
1577 public function having_add_and_use() {
1578 $contextswithcap = array();
1579 foreach ($this->allcontexts as $context) {
1580 if (!has_capability('moodle/question:add', $context)) {
1581 continue;
1582 }
1583 if (!has_any_capability(array('moodle/question:useall', 'moodle/question:usemine'), $context)) {
1584 continue;
1585 }
1586 $contextswithcap[] = $context;
1587 }
1588 return $contextswithcap;
1589 }
1590
56ed242b
SH
1591 /**
1592 * Has at least one parent context got the cap $cap?
1593 *
1594 * @param string $cap capability
1595 * @return boolean
1596 */
f29aeb5a 1597 public function have_cap($cap) {
56ed242b
SH
1598 return (count($this->having_cap($cap)));
1599 }
1600
1601 /**
1602 * Has at least one parent context got one of the caps $caps?
1603 *
1604 * @param array $caps capability
1605 * @return boolean
1606 */
f29aeb5a 1607 public function have_one_cap($caps) {
56ed242b
SH
1608 foreach ($caps as $cap) {
1609 if ($this->have_cap($cap)) {
1610 return true;
1611 }
1612 }
1613 return false;
1614 }
f29aeb5a 1615
56ed242b
SH
1616 /**
1617 * Has at least one parent context got one of the caps for actions on $tabname
1618 *
1619 * @param string $tabname edit tab name
1620 * @return boolean
1621 */
aac80ff3
TH
1622 public function have_one_edit_tab_cap($tabname) {
1623 return $this->have_one_cap(self::$caps[$tabname]);
56ed242b 1624 }
f29aeb5a 1625
56ed242b
SH
1626 /**
1627 * Throw error if at least one parent context hasn't got the cap $cap
1628 *
1629 * @param string $cap capability
1630 */
aac80ff3
TH
1631 public function require_cap($cap) {
1632 if (!$this->have_cap($cap)) {
56ed242b
SH
1633 print_error('nopermissions', '', '', $cap);
1634 }
1635 }
f29aeb5a 1636
56ed242b
SH
1637 /**
1638 * Throw error if at least one parent context hasn't got one of the caps $caps
1639 *
1640 * @param array $cap capabilities
1641 */
aac80ff3 1642 public function require_one_cap($caps) {
56ed242b
SH
1643 if (!$this->have_one_cap($caps)) {
1644 $capsstring = join($caps, ', ');
1645 print_error('nopermissions', '', '', $capsstring);
1646 }
1647 }
1648
1649 /**
1650 * Throw error if at least one parent context hasn't got one of the caps $caps
1651 *
1652 * @param string $tabname edit tab name
1653 */
aac80ff3 1654 public function require_one_edit_tab_cap($tabname) {
56ed242b
SH
1655 if (!$this->have_one_edit_tab_cap($tabname)) {
1656 print_error('nopermissions', '', '', 'access question edit tab '.$tabname);
1657 }
1658 }
fe6ce234
DC
1659}
1660
6f4c71f5 1661
fe6ce234 1662/**
fdb5bc03 1663 * Helps call file_rewrite_pluginfile_urls with the right parameters.
fe6ce234 1664 *
d2b7803e
DC
1665 * @package core_question
1666 * @category files
fe6ce234
DC
1667 * @param string $text text being processed
1668 * @param string $file the php script used to serve files
d2b7803e 1669 * @param int $contextid context ID
fe6ce234
DC
1670 * @param string $component component
1671 * @param string $filearea filearea
1672 * @param array $ids other IDs will be used to check file permission
d2b7803e
DC
1673 * @param int $itemid item ID
1674 * @param array $options options
fe6ce234
DC
1675 * @return string
1676 */
aac80ff3
TH
1677function question_rewrite_question_urls($text, $file, $contextid, $component,
1678 $filearea, array $ids, $itemid, array $options=null) {
fe6ce234 1679
fdb5bc03
TH
1680 $idsstr = '';
1681 if (!empty($ids)) {
1682 $idsstr .= implode('/', $ids);
fe6ce234 1683 }
fdb5bc03
TH
1684 if ($itemid !== null) {
1685 $idsstr .= '/' . $itemid;
fe6ce234 1686 }
fdb5bc03
TH
1687 return file_rewrite_pluginfile_urls($text, $file, $contextid, $component,
1688 $filearea, $idsstr, $options);
1689}
fe6ce234 1690
181393aa
TH
1691/**
1692 * Rewrite the PLUGINFILE urls in part of the content of a question, for use when
1693 * viewing the question outside an attempt (for example, in the question bank
1694 * listing or in the quiz statistics report).
1695 *
1696 * @param string $text the question text.
1697 * @param int $questionid the question id.
1698 * @param int $filecontextid the context id of the question being displayed.
1699 * @param string $filecomponent the component that owns the file area.
1700 * @param string $filearea the file area name.
1701 * @param int|null $itemid the file's itemid
1702 * @param int $previewcontextid the context id where the preview is being displayed.
1703 * @param string $previewcomponent component responsible for displaying the preview.
1704 * @param array $options text and file options ('forcehttps'=>false)
1705 * @return string $questiontext with URLs rewritten.
1706 */
1707function question_rewrite_question_preview_urls($text, $questionid,
1708 $filecontextid, $filecomponent, $filearea, $itemid,
1709 $previewcontextid, $previewcomponent, $options = null) {
fe6ce234 1710
181393aa
TH
1711 $path = "preview/$previewcontextid/$previewcomponent/$questionid";
1712 if ($itemid) {
1713 $path .= '/' . $itemid;
1714 }
1715
1716 return file_rewrite_pluginfile_urls($text, 'pluginfile.php', $filecontextid,
1717 $filecomponent, $filearea, $path, $options);
fdb5bc03 1718}
fe6ce234 1719
fe6ce234
DC
1720/**
1721 * Called by pluginfile.php to serve files related to the 'question' core
1722 * component and for files belonging to qtypes.
1723 *
1724 * For files that relate to questions in a question_attempt, then we delegate to
1725 * a function in the component that owns the attempt (for example in the quiz,
1726 * or in core question preview) to get necessary inforation.
1727 *
1728 * (Note that, at the moment, all question file areas relate to questions in
1729 * attempts, so the If at the start of the last paragraph is always true.)
1730 *
1731 * Does not return, either calls send_file_not_found(); or serves the file.
1732 *
d2b7803e
DC
1733 * @package core_question
1734 * @category files
1735 * @param stdClass $course course settings object
1736 * @param stdClass $context context object
fe6ce234
DC
1737 * @param string $component the name of the component we are serving files for.
1738 * @param string $filearea the name of the file area.
1739 * @param array $args the remaining bits of the file path.
1740 * @param bool $forcedownload whether the user must be forced to download the file.
7a00d438 1741 * @param array $options additional options affecting the file serving
fe6ce234 1742 */
7a00d438 1743function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload, array $options=array()) {
fe6ce234
DC
1744 global $DB, $CFG;
1745
181393aa 1746 // Special case, sending a question bank export.
cde2709a 1747 if ($filearea === 'export') {
97f1137a
TH
1748 list($context, $course, $cm) = get_context_info_array($context->id);
1749 require_login($course, false, $cm);
1750
cde2709a
DC
1751 require_once($CFG->dirroot . '/question/editlib.php');
1752 $contexts = new question_edit_contexts($context);
1753 // check export capability
1754 $contexts->require_one_edit_tab_cap('export');
1755 $category_id = (int)array_shift($args);
1756 $format = array_shift($args);
1757 $cattofile = array_shift($args);
1758 $contexttofile = array_shift($args);
1759 $filename = array_shift($args);
1760
1761 // load parent class for import/export
1762 require_once($CFG->dirroot . '/question/format.php');
1763 require_once($CFG->dirroot . '/question/editlib.php');
1764 require_once($CFG->dirroot . '/question/format/' . $format . '/format.php');
1765
1766 $classname = 'qformat_' . $format;
1767 if (!class_exists($classname)) {
1768 send_file_not_found();
1769 }
1770
1771 $qformat = new $classname();
1772
1773 if (!$category = $DB->get_record('question_categories', array('id' => $category_id))) {
1774 send_file_not_found();
1775 }
1776
1777 $qformat->setCategory($category);
1778 $qformat->setContexts($contexts->having_one_edit_tab_cap('export'));
1779 $qformat->setCourse($course);
1780
1781 if ($cattofile == 'withcategories') {
1782 $qformat->setCattofile(true);
1783 } else {
1784 $qformat->setCattofile(false);
1785 }
1786
1787 if ($contexttofile == 'withcontexts') {
1788 $qformat->setContexttofile(true);
1789 } else {
1790 $qformat->setContexttofile(false);
1791 }
1792
1793 if (!$qformat->exportpreprocess()) {
1794 send_file_not_found();
1795 print_error('exporterror', 'question', $thispageurl->out());
1796 }
1797
1798 // export data to moodle file pool
1799 if (!$content = $qformat->exportprocess(true)) {
1800 send_file_not_found();
1801 }
1802
46732124 1803 send_file($content, $filename, 0, 0, true, true, $qformat->mime_type());
cde2709a
DC
1804 }
1805
181393aa
TH
1806 // Normal case, a file belonging to a question.
1807 $qubaidorpreview = array_shift($args);
1808
1809 // Two sub-cases: 1. A question being previewed outside an attempt/usage.
1810 if ($qubaidorpreview === 'preview') {
1811 $previewcontextid = (int)array_shift($args);
1812 $previewcomponent = array_shift($args);
1813 $questionid = (int) array_shift($args);
1814 $previewcontext = context_helper::instance_by_id($previewcontextid);
1815
1816 $result = component_callback($previewcomponent, 'question_preview_pluginfile', array(
1817 $previewcontext, $questionid,
1818 $context, $component, $filearea, $args,
1b49f31d
TH
1819 $forcedownload, $options), 'callbackmissing');
1820
1821 if ($result === 'callbackmissing') {
1822 throw new coding_exception("Component {$previewcomponent} does not define the callback " .
1823 "{$previewcomponent}_question_preview_pluginfile callback. " .
1824 "Which is required if you are using question_rewrite_question_preview_urls.", DEBUG_DEVELOPER);
181393aa
TH
1825 }
1826
1827 send_file_not_found();
1828 }
1829
1830 // 2. A question being attempted in the normal way.
1831 $qubaid = (int)$qubaidorpreview;
7a719748 1832 $slot = (int)array_shift($args);
fe6ce234 1833
7a719748
TH
1834 $module = $DB->get_field('question_usages', 'component',
1835 array('id' => $qubaid));
9c75f8dc
TH
1836 if (!$module) {
1837 send_file_not_found();
1838 }
fe6ce234 1839
7a719748 1840 if ($module === 'core_question_preview') {
fe6ce234
DC
1841 require_once($CFG->dirroot . '/question/previewlib.php');
1842 return question_preview_question_pluginfile($course, $context,
7a00d438 1843 $component, $filearea, $qubaid, $slot, $args, $forcedownload, $options);
fe6ce234
DC
1844
1845 } else {
b0d1d941 1846 $dir = core_component::get_component_directory($module);
fe6ce234
DC
1847 if (!file_exists("$dir/lib.php")) {
1848 send_file_not_found();
1849 }
1850 include_once("$dir/lib.php");
1851
1852 $filefunction = $module . '_question_pluginfile';
0dd0f963
AB
1853 if (function_exists($filefunction)) {
1854 $filefunction($course, $context, $component, $filearea, $qubaid, $slot,
1855 $args, $forcedownload, $options);
fe6ce234
DC
1856 }
1857
813f6964
DP
1858 // Okay, we're here so lets check for function without 'mod_'.
1859 if (strpos($module, 'mod_') === 0) {
38d488ce 1860 $filefunctionold = substr($module, 4) . '_question_pluginfile';
0dd0f963
AB
1861 if (function_exists($filefunctionold)) {
1862 $filefunctionold($course, $context, $component, $filearea, $qubaid, $slot,
1863 $args, $forcedownload, $options);
1864 }
1865 }
fe6ce234
DC
1866
1867 send_file_not_found();
1868 }
1869}
1870
0019a9b7
TH
1871/**
1872 * Serve questiontext files in the question text when they are displayed in this report.
d2b7803e
DC
1873 *
1874 * @package core_files
1875 * @category files
68d2f6a0
TH
1876 * @param context $previewcontext the context in which the preview is happening.
1877 * @param int $questionid the question id.
1878 * @param context $filecontext the file (question) context.
1879 * @param string $filecomponent the component the file belongs to.
1880 * @param string $filearea the file area.
1881 * @param array $args remaining file args.
1882 * @param bool $forcedownload.
1883 * @param array $options additional options affecting the file serving.
0019a9b7 1884 */
68d2f6a0
TH
1885function core_question_question_preview_pluginfile($previewcontext, $questionid,
1886 $filecontext, $filecomponent, $filearea, $args, $forcedownload, $options = array()) {
0019a9b7
TH
1887 global $DB;
1888
1889 // Verify that contextid matches the question.
1890 $question = $DB->get_record_sql('
1891 SELECT q.*, qc.contextid
1892 FROM {question} q
1893 JOIN {question_categories} qc ON qc.id = q.category
1894 WHERE q.id = :id AND qc.contextid = :contextid',
68d2f6a0 1895 array('id' => $questionid, 'contextid' => $filecontext->id), MUST_EXIST);
0019a9b7
TH
1896
1897 // Check the capability.
68d2f6a0 1898 list($context, $course, $cm) = get_context_info_array($previewcontext->id);
0019a9b7
TH
1899 require_login($course, false, $cm);
1900
1901 question_require_capability_on($question, 'use');
1902
68d2f6a0
TH
1903 $fs = get_file_storage();
1904 $relativepath = implode('/', $args);
1905 $fullpath = "/{$filecontext->id}/{$filecomponent}/{$filearea}/{$relativepath}";
1906 if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
1907 send_file_not_found();
1908 }
1909
1910 send_stored_file($file, 0, 0, $forcedownload, $options);
0019a9b7
TH
1911}
1912
cde2709a
DC
1913/**
1914 * Create url for question export
1915 *
1916 * @param int $contextid, current context
1917 * @param int $categoryid, categoryid
1918 * @param string $format
1919 * @param string $withcategories
1920 * @param string $ithcontexts
1921 * @param moodle_url export file url
1922 */
aac80ff3
TH
1923function question_make_export_url($contextid, $categoryid, $format, $withcategories,
1924 $withcontexts, $filename) {
cde2709a
DC
1925 global $CFG;
1926 $urlbase = "$CFG->httpswwwroot/pluginfile.php";
aac80ff3
TH
1927 return moodle_url::make_file_url($urlbase,
1928 "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}" .
1929 "/{$withcontexts}/{$filename}", true);
cde2709a 1930}
b1627a92
DC
1931
1932/**
1933 * Return a list of page types
1934 * @param string $pagetype current page type
1935 * @param stdClass $parentcontext Block's parent context
1936 * @param stdClass $currentcontext Current context of block
1937 */
b38e2e28 1938function question_page_type_list($pagetype, $parentcontext, $currentcontext) {
b1627a92
DC
1939 global $CFG;
1940 $types = array(
1941 'question-*'=>get_string('page-question-x', 'question'),
1942 'question-edit'=>get_string('page-question-edit', 'question'),
1943 'question-category'=>get_string('page-question-category', 'question'),
1944 'question-export'=>get_string('page-question-export', 'question'),
1945 'question-import'=>get_string('page-question-import', 'question')
1946 );
1947 if ($currentcontext->contextlevel == CONTEXT_COURSE) {
1948 require_once($CFG->dirroot . '/course/lib.php');
b38e2e28 1949 return array_merge(course_page_type_list($pagetype, $parentcontext, $currentcontext), $types);
b1627a92
DC
1950 } else {
1951 return $types;
1952 }
1953}
b07ef13b
DP
1954
1955/**
1956 * Does an activity module use the question bank?
1957 *
1958 * @param string $modname The name of the module (without mod_ prefix).
1959 * @return bool true if the module uses questions.
1960 */
1961function question_module_uses_questions($modname) {
1962 if (plugin_supports('mod', $modname, FEATURE_USES_QUESTIONS)) {
1963 return true;
1964 }
1965
1966 $component = 'mod_'.$modname;
1967 if (component_callback_exists($component, 'question_pluginfile')) {
1968 debugging("{$component} uses questions but doesn't declare FEATURE_USES_QUESTIONS", DEBUG_DEVELOPER);
1969 return true;
1970 }
1971
1972 return false;
1973}