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