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