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