PageRenderTime 57ms CodeModel.GetById 16ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/questionlib.php

https://bitbucket.org/kudutest1/moodlegit
PHP | 1349 lines | 740 code | 163 blank | 446 comment | 118 complexity | 717d4bc8403a2b763f4f98fefd91680b MD5 | raw file
  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. * Code for handling and processing questions
  18. *
  19. * This is code that is module independent, i.e., can be used by any module that
  20. * uses questions, like quiz, lesson, ..
  21. * This script also loads the questiontype classes
  22. * Code for handling the editing of questions is in {@link question/editlib.php}
  23. *
  24. * TODO: separate those functions which form part of the API
  25. * from the helper functions.
  26. *
  27. * @package moodlecore
  28. * @subpackage questionbank
  29. * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
  30. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  31. */
  32. defined('MOODLE_INTERNAL') || die();
  33. require_once($CFG->dirroot . '/question/engine/lib.php');
  34. require_once($CFG->dirroot . '/question/type/questiontypebase.php');
  35. /// CONSTANTS ///////////////////////////////////
  36. /**
  37. * Constant determines the number of answer boxes supplied in the editing
  38. * form for multiple choice and similar question types.
  39. */
  40. define("QUESTION_NUMANS", 10);
  41. /**
  42. * Constant determines the number of answer boxes supplied in the editing
  43. * form for multiple choice and similar question types to start with, with
  44. * the option of adding QUESTION_NUMANS_ADD more answers.
  45. */
  46. define("QUESTION_NUMANS_START", 3);
  47. /**
  48. * Constant determines the number of answer boxes to add in the editing
  49. * form for multiple choice and similar question types when the user presses
  50. * 'add form fields button'.
  51. */
  52. define("QUESTION_NUMANS_ADD", 3);
  53. /**
  54. * Move one question type in a list of question types. If you try to move one element
  55. * off of the end, nothing will change.
  56. *
  57. * @param array $sortedqtypes An array $qtype => anything.
  58. * @param string $tomove one of the keys from $sortedqtypes
  59. * @param integer $direction +1 or -1
  60. * @return array an array $index => $qtype, with $index from 0 to n in order, and
  61. * the $qtypes in the same order as $sortedqtypes, except that $tomove will
  62. * have been moved one place.
  63. */
  64. function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
  65. $neworder = array_keys($sortedqtypes);
  66. // Find the element to move.
  67. $key = array_search($tomove, $neworder);
  68. if ($key === false) {
  69. return $neworder;
  70. }
  71. // Work out the other index.
  72. $otherkey = $key + $direction;
  73. if (!isset($neworder[$otherkey])) {
  74. return $neworder;
  75. }
  76. // Do the swap.
  77. $swap = $neworder[$otherkey];
  78. $neworder[$otherkey] = $neworder[$key];
  79. $neworder[$key] = $swap;
  80. return $neworder;
  81. }
  82. /**
  83. * Save a new question type order to the config_plugins table.
  84. * @global object
  85. * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
  86. * @param $config get_config('question'), if you happen to have it around, to save one DB query.
  87. */
  88. function question_save_qtype_order($neworder, $config = null) {
  89. global $DB;
  90. if (is_null($config)) {
  91. $config = get_config('question');
  92. }
  93. foreach ($neworder as $index => $qtype) {
  94. $sortvar = $qtype . '_sortorder';
  95. if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
  96. set_config($sortvar, $index + 1, 'question');
  97. }
  98. }
  99. }
  100. /// FUNCTIONS //////////////////////////////////////////////////////
  101. /**
  102. * Returns an array of names of activity modules that use this question
  103. *
  104. * @deprecated since Moodle 2.1. Use {@link questions_in_use} instead.
  105. *
  106. * @param object $questionid
  107. * @return array of strings
  108. */
  109. function question_list_instances($questionid) {
  110. throw new coding_exception('question_list_instances has been deprectated. ' .
  111. 'Please use questions_in_use instead.');
  112. }
  113. /**
  114. * @param array $questionids of question ids.
  115. * @return boolean whether any of these questions are being used by any part of Moodle.
  116. */
  117. function questions_in_use($questionids) {
  118. global $CFG;
  119. if (question_engine::questions_in_use($questionids)) {
  120. return true;
  121. }
  122. foreach (get_plugin_list('mod') as $module => $path) {
  123. $lib = $path . '/lib.php';
  124. if (is_readable($lib)) {
  125. include_once($lib);
  126. $fn = $module . '_questions_in_use';
  127. if (function_exists($fn)) {
  128. if ($fn($questionids)) {
  129. return true;
  130. }
  131. } else {
  132. // Fallback for legacy modules.
  133. $fn = $module . '_question_list_instances';
  134. if (function_exists($fn)) {
  135. foreach ($questionids as $questionid) {
  136. $instances = $fn($questionid);
  137. if (!empty($instances)) {
  138. return true;
  139. }
  140. }
  141. }
  142. }
  143. }
  144. }
  145. return false;
  146. }
  147. /**
  148. * Determine whether there arey any questions belonging to this context, that is whether any of its
  149. * question categories contain any questions. This will return true even if all the questions are
  150. * hidden.
  151. *
  152. * @param mixed $context either a context object, or a context id.
  153. * @return boolean whether any of the question categories beloning to this context have
  154. * any questions in them.
  155. */
  156. function question_context_has_any_questions($context) {
  157. global $DB;
  158. if (is_object($context)) {
  159. $contextid = $context->id;
  160. } else if (is_numeric($context)) {
  161. $contextid = $context;
  162. } else {
  163. print_error('invalidcontextinhasanyquestions', 'question');
  164. }
  165. return $DB->record_exists_sql("SELECT *
  166. FROM {question} q
  167. JOIN {question_categories} qc ON qc.id = q.category
  168. WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
  169. }
  170. /**
  171. * Returns list of 'allowed' grades for grade selection
  172. * formatted suitably for dropdown box function
  173. *
  174. * @deprecated since 2.1. Use {@link question_bank::fraction_options()} or
  175. * {@link question_bank::fraction_options_full()} instead.
  176. *
  177. * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
  178. */
  179. function get_grade_options() {
  180. $grades = new stdClass();
  181. $grades->gradeoptions = question_bank::fraction_options();
  182. $grades->gradeoptionsfull = question_bank::fraction_options_full();
  183. return $grades;
  184. }
  185. /**
  186. * Check whether a given grade is one of a list of allowed options. If not,
  187. * depending on $matchgrades, either return the nearest match, or return false
  188. * to signal an error.
  189. * @param array $gradeoptionsfull list of valid options
  190. * @param int $grade grade to be tested
  191. * @param string $matchgrades 'error' or 'nearest'
  192. * @return mixed either 'fixed' value or false if error.
  193. */
  194. function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') {
  195. if ($matchgrades == 'error') {
  196. // (Almost) exact match, or an error.
  197. foreach ($gradeoptionsfull as $value => $option) {
  198. // Slightly fuzzy test, never check floats for equality.
  199. if (abs($grade - $value) < 0.00001) {
  200. return $value; // Be sure the return the proper value.
  201. }
  202. }
  203. // Didn't find a match so that's an error.
  204. return false;
  205. } else if ($matchgrades == 'nearest') {
  206. // Work out nearest value
  207. $best = false;
  208. $bestmismatch = 2;
  209. foreach ($gradeoptionsfull as $value => $option) {
  210. $newmismatch = abs($grade - $value);
  211. if ($newmismatch < $bestmismatch) {
  212. $best = $value;
  213. $bestmismatch = $newmismatch;
  214. }
  215. }
  216. return $best;
  217. } else {
  218. // Unknow option passed.
  219. throw new coding_exception('Unknown $matchgrades ' . $matchgrades .
  220. ' passed to match_grade_options');
  221. }
  222. }
  223. /**
  224. * @deprecated Since Moodle 2.1. Use {@link question_category_in_use} instead.
  225. * @param integer $categoryid a question category id.
  226. * @param boolean $recursive whether to check child categories too.
  227. * @return boolean whether any question in this category is in use.
  228. */
  229. function question_category_isused($categoryid, $recursive = false) {
  230. throw new coding_exception('question_category_isused has been deprectated. ' .
  231. 'Please use question_category_in_use instead.');
  232. }
  233. /**
  234. * Tests whether any question in a category is used by any part of Moodle.
  235. *
  236. * @param integer $categoryid a question category id.
  237. * @param boolean $recursive whether to check child categories too.
  238. * @return boolean whether any question in this category is in use.
  239. */
  240. function question_category_in_use($categoryid, $recursive = false) {
  241. global $DB;
  242. //Look at each question in the category
  243. if ($questions = $DB->get_records_menu('question',
  244. array('category' => $categoryid), '', 'id, 1')) {
  245. if (questions_in_use(array_keys($questions))) {
  246. return true;
  247. }
  248. }
  249. if (!$recursive) {
  250. return false;
  251. }
  252. //Look under child categories recursively
  253. if ($children = $DB->get_records('question_categories',
  254. array('parent' => $categoryid), '', 'id, 1')) {
  255. foreach ($children as $child) {
  256. if (question_category_in_use($child->id, $recursive)) {
  257. return true;
  258. }
  259. }
  260. }
  261. return false;
  262. }
  263. /**
  264. * Deletes question and all associated data from the database
  265. *
  266. * It will not delete a question if it is used by an activity module
  267. * @param object $question The question being deleted
  268. */
  269. function question_delete_question($questionid) {
  270. global $DB;
  271. $question = $DB->get_record_sql('
  272. SELECT q.*, qc.contextid
  273. FROM {question} q
  274. JOIN {question_categories} qc ON qc.id = q.category
  275. WHERE q.id = ?', array($questionid));
  276. if (!$question) {
  277. // In some situations, for example if this was a child of a
  278. // Cloze question that was previously deleted, the question may already
  279. // have gone. In this case, just do nothing.
  280. return;
  281. }
  282. // Do not delete a question if it is used by an activity module
  283. if (questions_in_use(array($questionid))) {
  284. return;
  285. }
  286. // Check permissions.
  287. question_require_capability_on($question, 'edit');
  288. $dm = new question_engine_data_mapper();
  289. $dm->delete_previews($questionid);
  290. // delete questiontype-specific data
  291. question_bank::get_qtype($question->qtype, false)->delete_question(
  292. $questionid, $question->contextid);
  293. // Now recursively delete all child questions
  294. if ($children = $DB->get_records('question',
  295. array('parent' => $questionid), '', 'id, qtype')) {
  296. foreach ($children as $child) {
  297. if ($child->id != $questionid) {
  298. question_delete_question($child->id);
  299. }
  300. }
  301. }
  302. // Finally delete the question record itself
  303. $DB->delete_records('question', array('id' => $questionid));
  304. question_bank::notify_question_edited($questionid);
  305. }
  306. /**
  307. * All question categories and their questions are deleted for this course.
  308. *
  309. * @param stdClass $course an object representing the activity
  310. * @param boolean $feedback to specify if the process must output a summary of its work
  311. * @return boolean
  312. */
  313. function question_delete_course($course, $feedback=true) {
  314. global $DB, $OUTPUT;
  315. //To store feedback to be showed at the end of the process
  316. $feedbackdata = array();
  317. //Cache some strings
  318. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  319. $coursecontext = context_course::instance($course->id);
  320. $categoriescourse = $DB->get_records('question_categories',
  321. array('contextid' => $coursecontext->id), 'parent', 'id, parent, name, contextid');
  322. if ($categoriescourse) {
  323. //Sort categories following their tree (parent-child) relationships
  324. //this will make the feedback more readable
  325. $categoriescourse = sort_categories_by_tree($categoriescourse);
  326. foreach ($categoriescourse as $category) {
  327. //Delete it completely (questions and category itself)
  328. //deleting questions
  329. if ($questions = $DB->get_records('question',
  330. array('category' => $category->id), '', 'id,qtype')) {
  331. foreach ($questions as $question) {
  332. question_delete_question($question->id);
  333. }
  334. $DB->delete_records("question", array("category" => $category->id));
  335. }
  336. //delete the category
  337. $DB->delete_records('question_categories', array('id' => $category->id));
  338. //Fill feedback
  339. $feedbackdata[] = array($category->name, $strcatdeleted);
  340. }
  341. //Inform about changes performed if feedback is enabled
  342. if ($feedback) {
  343. $table = new html_table();
  344. $table->head = array(get_string('category', 'quiz'), get_string('action'));
  345. $table->data = $feedbackdata;
  346. echo html_writer::table($table);
  347. }
  348. }
  349. return true;
  350. }
  351. /**
  352. * Category is about to be deleted,
  353. * 1/ All question categories and their questions are deleted for this course category.
  354. * 2/ All questions are moved to new category
  355. *
  356. * @param object|coursecat $category course category object
  357. * @param object|coursecat $newcategory empty means everything deleted, otherwise id of
  358. * category where content moved
  359. * @param boolean $feedback to specify if the process must output a summary of its work
  360. * @return boolean
  361. */
  362. function question_delete_course_category($category, $newcategory, $feedback=true) {
  363. global $DB, $OUTPUT;
  364. $context = context_coursecat::instance($category->id);
  365. if (empty($newcategory)) {
  366. $feedbackdata = array(); // To store feedback to be showed at the end of the process
  367. $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
  368. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  369. // Loop over question categories.
  370. if ($categories = $DB->get_records('question_categories',
  371. array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
  372. foreach ($categories as $category) {
  373. // Deal with any questions in the category.
  374. if ($questions = $DB->get_records('question',
  375. array('category' => $category->id), '', 'id,qtype')) {
  376. // Try to delete each question.
  377. foreach ($questions as $question) {
  378. question_delete_question($question->id);
  379. }
  380. // Check to see if there were any questions that were kept because
  381. // they are still in use somehow, even though quizzes in courses
  382. // in this category will already have been deteted. This could
  383. // happen, for example, if questions are added to a course,
  384. // and then that course is moved to another category (MDL-14802).
  385. $questionids = $DB->get_records_menu('question',
  386. array('category'=>$category->id), '', 'id, 1');
  387. if (!empty($questionids)) {
  388. if (!$rescueqcategory = question_save_from_deletion(
  389. array_keys($questionids), get_parent_contextid($context),
  390. print_context_name($context), $rescueqcategory)) {
  391. return false;
  392. }
  393. $feedbackdata[] = array($category->name,
  394. get_string('questionsmovedto', 'question', $rescueqcategory->name));
  395. }
  396. }
  397. // Now delete the category.
  398. if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
  399. return false;
  400. }
  401. $feedbackdata[] = array($category->name, $strcatdeleted);
  402. } // End loop over categories.
  403. }
  404. // Output feedback if requested.
  405. if ($feedback and $feedbackdata) {
  406. $table = new html_table();
  407. $table->head = array(get_string('questioncategory', 'question'), get_string('action'));
  408. $table->data = $feedbackdata;
  409. echo html_writer::table($table);
  410. }
  411. } else {
  412. // Move question categories ot the new context.
  413. if (!$newcontext = context_coursecat::instance($newcategory->id)) {
  414. return false;
  415. }
  416. $DB->set_field('question_categories', 'contextid', $newcontext->id,
  417. array('contextid'=>$context->id));
  418. if ($feedback) {
  419. $a = new stdClass();
  420. $a->oldplace = print_context_name($context);
  421. $a->newplace = print_context_name($newcontext);
  422. echo $OUTPUT->notification(
  423. get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
  424. }
  425. }
  426. return true;
  427. }
  428. /**
  429. * Enter description here...
  430. *
  431. * @param array $questionids of question ids
  432. * @param object $newcontext the context to create the saved category in.
  433. * @param string $oldplace a textual description of the think being deleted,
  434. * e.g. from get_context_name
  435. * @param object $newcategory
  436. * @return mixed false on
  437. */
  438. function question_save_from_deletion($questionids, $newcontextid, $oldplace,
  439. $newcategory = null) {
  440. global $DB;
  441. // Make a category in the parent context to move the questions to.
  442. if (is_null($newcategory)) {
  443. $newcategory = new stdClass();
  444. $newcategory->parent = 0;
  445. $newcategory->contextid = $newcontextid;
  446. $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
  447. $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
  448. $newcategory->sortorder = 999;
  449. $newcategory->stamp = make_unique_id_code();
  450. $newcategory->id = $DB->insert_record('question_categories', $newcategory);
  451. }
  452. // Move any remaining questions to the 'saved' category.
  453. if (!question_move_questions_to_category($questionids, $newcategory->id)) {
  454. return false;
  455. }
  456. return $newcategory;
  457. }
  458. /**
  459. * All question categories and their questions are deleted for this activity.
  460. *
  461. * @param object $cm the course module object representing the activity
  462. * @param boolean $feedback to specify if the process must output a summary of its work
  463. * @return boolean
  464. */
  465. function question_delete_activity($cm, $feedback=true) {
  466. global $DB, $OUTPUT;
  467. //To store feedback to be showed at the end of the process
  468. $feedbackdata = array();
  469. //Cache some strings
  470. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  471. $modcontext = context_module::instance($cm->id);
  472. if ($categoriesmods = $DB->get_records('question_categories',
  473. array('contextid' => $modcontext->id), 'parent', 'id, parent, name, contextid')) {
  474. //Sort categories following their tree (parent-child) relationships
  475. //this will make the feedback more readable
  476. $categoriesmods = sort_categories_by_tree($categoriesmods);
  477. foreach ($categoriesmods as $category) {
  478. //Delete it completely (questions and category itself)
  479. //deleting questions
  480. if ($questions = $DB->get_records('question',
  481. array('category' => $category->id), '', 'id,qtype')) {
  482. foreach ($questions as $question) {
  483. question_delete_question($question->id);
  484. }
  485. $DB->delete_records("question", array("category"=>$category->id));
  486. }
  487. //delete the category
  488. $DB->delete_records('question_categories', array('id'=>$category->id));
  489. //Fill feedback
  490. $feedbackdata[] = array($category->name, $strcatdeleted);
  491. }
  492. //Inform about changes performed if feedback is enabled
  493. if ($feedback) {
  494. $table = new html_table();
  495. $table->head = array(get_string('category', 'quiz'), get_string('action'));
  496. $table->data = $feedbackdata;
  497. echo html_writer::table($table);
  498. }
  499. }
  500. return true;
  501. }
  502. /**
  503. * This function should be considered private to the question bank, it is called from
  504. * question/editlib.php question/contextmoveq.php and a few similar places to to the
  505. * work of acutally moving questions and associated data. However, callers of this
  506. * function also have to do other work, which is why you should not call this method
  507. * directly from outside the questionbank.
  508. *
  509. * @param array $questionids of question ids.
  510. * @param integer $newcategoryid the id of the category to move to.
  511. */
  512. function question_move_questions_to_category($questionids, $newcategoryid) {
  513. global $DB;
  514. $newcontextid = $DB->get_field('question_categories', 'contextid',
  515. array('id' => $newcategoryid));
  516. list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
  517. $questions = $DB->get_records_sql("
  518. SELECT q.id, q.qtype, qc.contextid
  519. FROM {question} q
  520. JOIN {question_categories} qc ON q.category = qc.id
  521. WHERE q.id $questionidcondition", $params);
  522. foreach ($questions as $question) {
  523. if ($newcontextid != $question->contextid) {
  524. question_bank::get_qtype($question->qtype)->move_files(
  525. $question->id, $question->contextid, $newcontextid);
  526. }
  527. }
  528. // Move the questions themselves.
  529. $DB->set_field_select('question', 'category', $newcategoryid,
  530. "id $questionidcondition", $params);
  531. // Move any subquestions belonging to them.
  532. $DB->set_field_select('question', 'category', $newcategoryid,
  533. "parent $questionidcondition", $params);
  534. // TODO Deal with datasets.
  535. // Purge these questions from the cache.
  536. foreach ($questions as $question) {
  537. question_bank::notify_question_edited($question->id);
  538. }
  539. return true;
  540. }
  541. /**
  542. * This function helps move a question cateogry to a new context by moving all
  543. * the files belonging to all the questions to the new context.
  544. * Also moves subcategories.
  545. * @param integer $categoryid the id of the category being moved.
  546. * @param integer $oldcontextid the old context id.
  547. * @param integer $newcontextid the new context id.
  548. */
  549. function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) {
  550. global $DB;
  551. $questionids = $DB->get_records_menu('question',
  552. array('category' => $categoryid), '', 'id,qtype');
  553. foreach ($questionids as $questionid => $qtype) {
  554. question_bank::get_qtype($qtype)->move_files(
  555. $questionid, $oldcontextid, $newcontextid);
  556. // Purge this question from the cache.
  557. question_bank::notify_question_edited($questionid);
  558. }
  559. $subcatids = $DB->get_records_menu('question_categories',
  560. array('parent' => $categoryid), '', 'id,1');
  561. foreach ($subcatids as $subcatid => $notused) {
  562. $DB->set_field('question_categories', 'contextid', $newcontextid,
  563. array('id' => $subcatid));
  564. question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
  565. }
  566. }
  567. /**
  568. * Generate the URL for starting a new preview of a given question with the given options.
  569. * @param integer $questionid the question to preview.
  570. * @param string $preferredbehaviour the behaviour to use for the preview.
  571. * @param float $maxmark the maximum to mark the question out of.
  572. * @param question_display_options $displayoptions the display options to use.
  573. * @param int $variant the variant of the question to preview. If null, one will
  574. * be picked randomly.
  575. * @param object $context context to run the preview in (affects things like
  576. * filter settings, theme, lang, etc.) Defaults to $PAGE->context.
  577. * @return string the URL.
  578. */
  579. function question_preview_url($questionid, $preferredbehaviour = null,
  580. $maxmark = null, $displayoptions = null, $variant = null, $context = null) {
  581. $params = array('id' => $questionid);
  582. if (is_null($context)) {
  583. global $PAGE;
  584. $context = $PAGE->context;
  585. }
  586. if ($context->contextlevel == CONTEXT_MODULE) {
  587. $params['cmid'] = $context->instanceid;
  588. } else if ($context->contextlevel == CONTEXT_COURSE) {
  589. $params['courseid'] = $context->instanceid;
  590. }
  591. if (!is_null($preferredbehaviour)) {
  592. $params['behaviour'] = $preferredbehaviour;
  593. }
  594. if (!is_null($maxmark)) {
  595. $params['maxmark'] = $maxmark;
  596. }
  597. if (!is_null($displayoptions)) {
  598. $params['correctness'] = $displayoptions->correctness;
  599. $params['marks'] = $displayoptions->marks;
  600. $params['markdp'] = $displayoptions->markdp;
  601. $params['feedback'] = (bool) $displayoptions->feedback;
  602. $params['generalfeedback'] = (bool) $displayoptions->generalfeedback;
  603. $params['rightanswer'] = (bool) $displayoptions->rightanswer;
  604. $params['history'] = (bool) $displayoptions->history;
  605. }
  606. if ($variant) {
  607. $params['variant'] = $variant;
  608. }
  609. return new moodle_url('/question/preview.php', $params);
  610. }
  611. /**
  612. * @return array that can be passed as $params to the {@link popup_action} constructor.
  613. */
  614. function question_preview_popup_params() {
  615. return array(
  616. 'height' => 600,
  617. 'width' => 800,
  618. );
  619. }
  620. /**
  621. * Given a list of ids, load the basic information about a set of questions from
  622. * the questions table. The $join and $extrafields arguments can be used together
  623. * to pull in extra data. See, for example, the usage in mod/quiz/attemptlib.php, and
  624. * read the code below to see how the SQL is assembled. Throws exceptions on error.
  625. *
  626. * @global object
  627. * @global object
  628. * @param array $questionids array of question ids.
  629. * @param string $extrafields extra SQL code to be added to the query.
  630. * @param string $join extra SQL code to be added to the query.
  631. * @param array $extraparams values for any placeholders in $join.
  632. * You are strongly recommended to use named placeholder.
  633. *
  634. * @return array partially complete question objects. You need to call get_question_options
  635. * on them before they can be properly used.
  636. */
  637. function question_preload_questions($questionids, $extrafields = '', $join = '',
  638. $extraparams = array()) {
  639. global $DB;
  640. if (empty($questionids)) {
  641. return array();
  642. }
  643. if ($join) {
  644. $join = ' JOIN '.$join;
  645. }
  646. if ($extrafields) {
  647. $extrafields = ', ' . $extrafields;
  648. }
  649. list($questionidcondition, $params) = $DB->get_in_or_equal(
  650. $questionids, SQL_PARAMS_NAMED, 'qid0000');
  651. $sql = 'SELECT q.*, qc.contextid' . $extrafields . ' FROM {question} q
  652. JOIN {question_categories} qc ON q.category = qc.id' .
  653. $join .
  654. ' WHERE q.id ' . $questionidcondition;
  655. // Load the questions
  656. if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
  657. return array();
  658. }
  659. foreach ($questions as $question) {
  660. $question->_partiallyloaded = true;
  661. }
  662. // Note, a possible optimisation here would be to not load the TEXT fields
  663. // (that is, questiontext and generalfeedback) here, and instead load them in
  664. // question_load_questions. That would add one DB query, but reduce the amount
  665. // of data transferred most of the time. I am not going to do this optimisation
  666. // until it is shown to be worthwhile.
  667. return $questions;
  668. }
  669. /**
  670. * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
  671. * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
  672. * read the code below to see how the SQL is assembled. Throws exceptions on error.
  673. *
  674. * @param array $questionids array of question ids.
  675. * @param string $extrafields extra SQL code to be added to the query.
  676. * @param string $join extra SQL code to be added to the query.
  677. * @param array $extraparams values for any placeholders in $join.
  678. * You are strongly recommended to use named placeholder.
  679. *
  680. * @return array question objects.
  681. */
  682. function question_load_questions($questionids, $extrafields = '', $join = '') {
  683. $questions = question_preload_questions($questionids, $extrafields, $join);
  684. // Load the question type specific information
  685. if (!get_question_options($questions)) {
  686. return 'Could not load the question options';
  687. }
  688. return $questions;
  689. }
  690. /**
  691. * Private function to factor common code out of get_question_options().
  692. *
  693. * @param object $question the question to tidy.
  694. * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
  695. */
  696. function _tidy_question($question, $loadtags = false) {
  697. global $CFG;
  698. // Load question-type specific fields.
  699. if (!question_bank::is_qtype_installed($question->qtype)) {
  700. $question->questiontext = html_writer::tag('p', get_string('warningmissingtype',
  701. 'qtype_missingtype')) . $question->questiontext;
  702. }
  703. question_bank::get_qtype($question->qtype)->get_question_options($question);
  704. // Convert numeric fields to float. (Prevents these being displayed as 1.0000000.)
  705. $question->defaultmark += 0;
  706. $question->penalty += 0;
  707. if (isset($question->_partiallyloaded)) {
  708. unset($question->_partiallyloaded);
  709. }
  710. if ($loadtags && !empty($CFG->usetags)) {
  711. require_once($CFG->dirroot . '/tag/lib.php');
  712. $question->tags = tag_get_tags_array('question', $question->id);
  713. }
  714. }
  715. /**
  716. * Updates the question objects with question type specific
  717. * information by calling {@link get_question_options()}
  718. *
  719. * Can be called either with an array of question objects or with a single
  720. * question object.
  721. *
  722. * @param mixed $questions Either an array of question objects to be updated
  723. * or just a single question object
  724. * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
  725. * @return bool Indicates success or failure.
  726. */
  727. function get_question_options(&$questions, $loadtags = false) {
  728. if (is_array($questions)) { // deal with an array of questions
  729. foreach ($questions as $i => $notused) {
  730. _tidy_question($questions[$i], $loadtags);
  731. }
  732. } else { // deal with single question
  733. _tidy_question($questions, $loadtags);
  734. }
  735. return true;
  736. }
  737. /**
  738. * Print the icon for the question type
  739. *
  740. * @param object $question The question object for which the icon is required.
  741. * Only $question->qtype is used.
  742. * @return string the HTML for the img tag.
  743. */
  744. function print_question_icon($question) {
  745. global $PAGE;
  746. return $PAGE->get_renderer('question', 'bank')->qtype_icon($question->qtype);
  747. }
  748. /**
  749. * Creates a stamp that uniquely identifies this version of the question
  750. *
  751. * In future we want this to use a hash of the question data to guarantee that
  752. * identical versions have the same version stamp.
  753. *
  754. * @param object $question
  755. * @return string A unique version stamp
  756. */
  757. function question_hash($question) {
  758. return make_unique_id_code();
  759. }
  760. /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
  761. /**
  762. * Saves question options
  763. *
  764. * Simply calls the question type specific save_question_options() method.
  765. * @deprecated all code should now call the question type method directly.
  766. */
  767. function save_question_options($question) {
  768. debugging('Please do not call save_question_options any more. Call the question type method directly.',
  769. DEBUG_DEVELOPER);
  770. question_bank::get_qtype($question->qtype)->save_question_options($question);
  771. }
  772. /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
  773. /**
  774. * returns the categories with their names ordered following parent-child relationships
  775. * finally it tries to return pending categories (those being orphaned, whose parent is
  776. * incorrect) to avoid missing any category from original array.
  777. */
  778. function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
  779. global $DB;
  780. $children = array();
  781. $keys = array_keys($categories);
  782. foreach ($keys as $key) {
  783. if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
  784. $children[$key] = $categories[$key];
  785. $categories[$key]->processed = true;
  786. $children = $children + sort_categories_by_tree(
  787. $categories, $children[$key]->id, $level+1);
  788. }
  789. }
  790. //If level = 1, we have finished, try to look for non processed categories
  791. // (bad parent) and sort them too
  792. if ($level == 1) {
  793. foreach ($keys as $key) {
  794. // If not processed and it's a good candidate to start (because its
  795. // parent doesn't exist in the course)
  796. if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories',
  797. array('contextid' => $categories[$key]->contextid,
  798. 'id' => $categories[$key]->parent))) {
  799. $children[$key] = $categories[$key];
  800. $categories[$key]->processed = true;
  801. $children = $children + sort_categories_by_tree(
  802. $categories, $children[$key]->id, $level + 1);
  803. }
  804. }
  805. }
  806. return $children;
  807. }
  808. /**
  809. * Private method, only for the use of add_indented_names().
  810. *
  811. * Recursively adds an indentedname field to each category, starting with the category
  812. * with id $id, and dealing with that category and all its children, and
  813. * return a new array, with those categories in the right order.
  814. *
  815. * @param array $categories an array of categories which has had childids
  816. * fields added by flatten_category_tree(). Passed by reference for
  817. * performance only. It is not modfied.
  818. * @param int $id the category to start the indenting process from.
  819. * @param int $depth the indent depth. Used in recursive calls.
  820. * @return array a new array of categories, in the right order for the tree.
  821. */
  822. function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
  823. // Indent the name of this category.
  824. $newcategories = array();
  825. $newcategories[$id] = $categories[$id];
  826. $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) .
  827. $categories[$id]->name;
  828. // Recursively indent the children.
  829. foreach ($categories[$id]->childids as $childid) {
  830. if ($childid != $nochildrenof) {
  831. $newcategories = $newcategories + flatten_category_tree(
  832. $categories, $childid, $depth + 1, $nochildrenof);
  833. }
  834. }
  835. // Remove the childids array that were temporarily added.
  836. unset($newcategories[$id]->childids);
  837. return $newcategories;
  838. }
  839. /**
  840. * Format categories into an indented list reflecting the tree structure.
  841. *
  842. * @param array $categories An array of category objects, for example from the.
  843. * @return array The formatted list of categories.
  844. */
  845. function add_indented_names($categories, $nochildrenof = -1) {
  846. // Add an array to each category to hold the child category ids. This array
  847. // will be removed again by flatten_category_tree(). It should not be used
  848. // outside these two functions.
  849. foreach (array_keys($categories) as $id) {
  850. $categories[$id]->childids = array();
  851. }
  852. // Build the tree structure, and record which categories are top-level.
  853. // We have to be careful, because the categories array may include published
  854. // categories from other courses, but not their parents.
  855. $toplevelcategoryids = array();
  856. foreach (array_keys($categories) as $id) {
  857. if (!empty($categories[$id]->parent) &&
  858. array_key_exists($categories[$id]->parent, $categories)) {
  859. $categories[$categories[$id]->parent]->childids[] = $id;
  860. } else {
  861. $toplevelcategoryids[] = $id;
  862. }
  863. }
  864. // Flatten the tree to and add the indents.
  865. $newcategories = array();
  866. foreach ($toplevelcategoryids as $id) {
  867. $newcategories = $newcategories + flatten_category_tree(
  868. $categories, $id, 0, $nochildrenof);
  869. }
  870. return $newcategories;
  871. }
  872. /**
  873. * Output a select menu of question categories.
  874. *
  875. * Categories from this course and (optionally) published categories from other courses
  876. * are included. Optionally, only categories the current user may edit can be included.
  877. *
  878. * @param integer $courseid the id of the course to get the categories for.
  879. * @param integer $published if true, include publised categories from other courses.
  880. * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
  881. * @param integer $selected optionally, the id of a category to be selected by
  882. * default in the dropdown.
  883. */
  884. function question_category_select_menu($contexts, $top = false, $currentcat = 0,
  885. $selected = "", $nochildrenof = -1) {
  886. global $OUTPUT;
  887. $categoriesarray = question_category_options($contexts, $top, $currentcat,
  888. false, $nochildrenof);
  889. if ($selected) {
  890. $choose = '';
  891. } else {
  892. $choose = 'choosedots';
  893. }
  894. $options = array();
  895. foreach ($categoriesarray as $group => $opts) {
  896. $options[] = array($group => $opts);
  897. }
  898. echo html_writer::label($selected, 'menucategory', false, array('class' => 'accesshide'));
  899. echo html_writer::select($options, 'category', $selected, $choose);
  900. }
  901. /**
  902. * @param integer $contextid a context id.
  903. * @return object the default question category for that context, or false if none.
  904. */
  905. function question_get_default_category($contextid) {
  906. global $DB;
  907. $category = $DB->get_records('question_categories',
  908. array('contextid' => $contextid), 'id', '*', 0, 1);
  909. if (!empty($category)) {
  910. return reset($category);
  911. } else {
  912. return false;
  913. }
  914. }
  915. /**
  916. * Gets the default category in the most specific context.
  917. * If no categories exist yet then default ones are created in all contexts.
  918. *
  919. * @param array $contexts The context objects for this context and all parent contexts.
  920. * @return object The default category - the category in the course context
  921. */
  922. function question_make_default_categories($contexts) {
  923. global $DB;
  924. static $preferredlevels = array(
  925. CONTEXT_COURSE => 4,
  926. CONTEXT_MODULE => 3,
  927. CONTEXT_COURSECAT => 2,
  928. CONTEXT_SYSTEM => 1,
  929. );
  930. $toreturn = null;
  931. $preferredness = 0;
  932. // If it already exists, just return it.
  933. foreach ($contexts as $key => $context) {
  934. if (!$exists = $DB->record_exists("question_categories",
  935. array('contextid' => $context->id))) {
  936. // Otherwise, we need to make one
  937. $category = new stdClass();
  938. $contextname = print_context_name($context, false, true);
  939. $category->name = get_string('defaultfor', 'question', $contextname);
  940. $category->info = get_string('defaultinfofor', 'question', $contextname);
  941. $category->contextid = $context->id;
  942. $category->parent = 0;
  943. // By default, all categories get this number, and are sorted alphabetically.
  944. $category->sortorder = 999;
  945. $category->stamp = make_unique_id_code();
  946. $category->id = $DB->insert_record('question_categories', $category);
  947. } else {
  948. $category = question_get_default_category($context->id);
  949. }
  950. $thispreferredness = $preferredlevels[$context->contextlevel];
  951. if (has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
  952. $thispreferredness += 10;
  953. }
  954. if ($thispreferredness > $preferredness) {
  955. $toreturn = $category;
  956. $preferredness = $thispreferredness;
  957. }
  958. }
  959. if (!is_null($toreturn)) {
  960. $toreturn = clone($toreturn);
  961. }
  962. return $toreturn;
  963. }
  964. /**
  965. * Get all the category objects, including a count of the number of questions in that category,
  966. * for all the categories in the lists $contexts.
  967. *
  968. * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
  969. * @param string $sortorder used as the ORDER BY clause in the select statement.
  970. * @return array of category objects.
  971. */
  972. function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
  973. global $DB;
  974. return $DB->get_records_sql("
  975. SELECT c.*, (SELECT count(1) FROM {question} q
  976. WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
  977. FROM {question_categories} c
  978. WHERE c.contextid IN ($contexts)
  979. ORDER BY $sortorder");
  980. }
  981. /**
  982. * Output an array of question categories.
  983. */
  984. function question_category_options($contexts, $top = false, $currentcat = 0,
  985. $popupform = false, $nochildrenof = -1) {
  986. global $CFG;
  987. $pcontexts = array();
  988. foreach ($contexts as $context) {
  989. $pcontexts[] = $context->id;
  990. }
  991. $contextslist = join($pcontexts, ', ');
  992. $categories = get_categories_for_contexts($contextslist);
  993. $categories = question_add_context_in_key($categories);
  994. if ($top) {
  995. $categories = question_add_tops($categories, $pcontexts);
  996. }
  997. $categories = add_indented_names($categories, $nochildrenof);
  998. // sort cats out into different contexts
  999. $categoriesarray = array();
  1000. foreach ($pcontexts as $contextid) {
  1001. $context = context::instance_by_id($contextid);
  1002. $contextstring = $context->get_context_name(true, true);
  1003. foreach ($categories as $category) {
  1004. if ($category->contextid == $contextid) {
  1005. $cid = $category->id;
  1006. if ($currentcat != $cid || $currentcat == 0) {
  1007. $countstring = !empty($category->questioncount) ?
  1008. " ($category->questioncount)" : '';
  1009. $categoriesarray[$contextstring][$cid] =
  1010. format_string($category->indentedname, true,
  1011. array('context' => $context)) . $countstring;
  1012. }
  1013. }
  1014. }
  1015. }
  1016. if ($popupform) {
  1017. $popupcats = array();
  1018. foreach ($categoriesarray as $contextstring => $optgroup) {
  1019. $group = array();
  1020. foreach ($optgroup as $key => $value) {
  1021. $key = str_replace($CFG->wwwroot, '', $key);
  1022. $group[$key] = $value;
  1023. }
  1024. $popupcats[] = array($contextstring => $group);
  1025. }
  1026. return $popupcats;
  1027. } else {
  1028. return $categoriesarray;
  1029. }
  1030. }
  1031. function question_add_context_in_key($categories) {
  1032. $newcatarray = array();
  1033. foreach ($categories as $id => $category) {
  1034. $category->parent = "$category->parent,$category->contextid";
  1035. $category->id = "$category->id,$category->contextid";
  1036. $newcatarray["$id,$category->contextid"] = $category;
  1037. }
  1038. return $newcatarray;
  1039. }
  1040. function question_add_tops($categories, $pcontexts) {
  1041. $topcats = array();
  1042. foreach ($pcontexts as $context) {
  1043. $newcat = new stdClass();
  1044. $newcat->id = "0,$context";
  1045. $newcat->name = get_string('top');
  1046. $newcat->parent = -1;
  1047. $newcat->contextid = $context;
  1048. $topcats["0,$context"] = $newcat;
  1049. }
  1050. //put topcats in at beginning of array - they'll be sorted into different contexts later.
  1051. return array_merge($topcats, $categories);
  1052. }
  1053. /**
  1054. * @return array of question category ids of the category and all subcategories.
  1055. */
  1056. function question_categorylist($categoryid) {
  1057. global $DB;
  1058. // final list of category IDs
  1059. $categorylist = array();
  1060. // a list of category IDs to check for any sub-categories
  1061. $subcategories = array($categoryid);
  1062. while ($subcategories) {
  1063. foreach ($subcategories as $subcategory) {
  1064. // if anything from the temporary list was added already, then we have a loop
  1065. if (isset($categorylist[$subcategory])) {
  1066. throw new coding_exception("Category id=$subcategory is already on the list - loop of categories detected.");
  1067. }
  1068. $categorylist[$subcategory] = $subcategory;
  1069. }
  1070. list ($in, $params) = $DB->get_in_or_equal($subcategories);
  1071. $subcategories = $DB->get_records_select_menu('question_categories',
  1072. "parent $in", $params, NULL, 'id,id AS id2');
  1073. }
  1074. return $categorylist;
  1075. }
  1076. //===========================
  1077. // Import/Export Functions
  1078. //===========================
  1079. /**
  1080. * Get list of available import or export formats
  1081. * @param string $type 'import' if import list, otherwise export list assumed
  1082. * @return array sorted list of import/export formats available
  1083. */
  1084. function get_import_export_formats($type) {
  1085. global $CFG;
  1086. require_once($CFG->dirroot . '/question/format.php');
  1087. $formatclasses = get_plugin_list_with_class('qformat', '', 'format.php');
  1088. $fileformatname = array();
  1089. foreach ($formatclasses as $component => $formatclass) {
  1090. $format = new $formatclass();
  1091. if ($type == 'import') {
  1092. $provided = $format->provide_import();
  1093. } else {
  1094. $provided = $format->provide_export();
  1095. }
  1096. if ($provided) {
  1097. list($notused, $fileformat) = explode('_', $component, 2);
  1098. $fileformatnames[$fileformat] = get_string('pluginname', $component);
  1099. }
  1100. }
  1101. collatorlib::asort($fileformatnames);
  1102. return $fileformatnames;
  1103. }
  1104. /**
  1105. * Create a reasonable default file name for exporting questions from a particular
  1106. * category.
  1107. * @param object $course the course the questions are in.
  1108. * @param object $category the question category.
  1109. * @return string the filename.
  1110. */
  1111. function question_default_export_filename($course, $category) {
  1112. // We build a string that is an appropriate name (questions) from the lang pack,
  1113. // then the corse shortname, then the question category name, then a timestamp.
  1114. $base = clean_filename(get_string('exportfilename', 'question'));
  1115. $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question'));
  1116. $timestamp = clean_filename(userdate(time(), $dateformat, 99, false));
  1117. $shortname = clean_filename($course->shortname);
  1118. if ($shortname == '' || $shortname == '_' ) {
  1119. $shortname = $course->id;
  1120. }
  1121. $categoryname = clean_filename(format_string($category->name));
  1122. return "{$base}-{$shortname}-{$categoryname}-{$timestamp}";
  1123. return $export_name;
  1124. }
  1125. /**
  1126. * Converts contextlevels to strings and back to help with reading/writing contexts
  1127. * to/from import/export files.
  1128. *
  1129. * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
  1130. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  1131. */
  1132. class context_to_string_translator{
  1133. /**
  1134. * @var array used to translate between contextids and strings for this context.
  1135. */
  1136. protected $contexttostringarray = array();
  1137. public function __construct($contexts) {
  1138. $this->generate_context_to_string_array($contexts);
  1139. }
  1140. public function context_to_string($contextid) {
  1141. return $this->contexttostringarray[$contextid];
  1142. }
  1143. public function string_to_context($contextname) {
  1144. $contextid = array_search($contextname, $this->contexttostringarray);
  1145. return $contextid;
  1146. }
  1147. protected function generate_context_to_string_array($contexts) {
  1148. if (!$this->contexttostringarray) {
  1149. $catno = 1;
  1150. foreach ($contexts as $context) {
  1151. switch ($context->contextlevel) {
  1152. case CONTEXT_MODULE :
  1153. $contextstring = 'module';
  1154. break;
  1155. case CONTEXT_COURSE :
  1156. $contextstring = 'course';
  1157. break;
  1158. case CONTEXT_COURSECAT :
  1159. $contextstring = "cat$catno";
  1160. $catno++;
  1161. break;
  1162. case CONTEXT_SYSTEM :
  1163. $contextstring = 'system';
  1164. break;
  1165. }
  1166. $this->contexttostringarray[$context->id] = $contextstring;
  1167. }
  1168. }
  1169. }
  1170. }
  1171. /**
  1172. * Check capability on category
  1173. *
  1174. * @param mixed $question object or id
  1175. * @param string $cap 'add', 'edit', 'view', 'use', 'move'
  1176. * @param integer $cachecat useful to cache all question records in a category
  1177. * @return boolean this user has the capability $cap for this question $question?
  1178. */
  1179. function question_has_capability_on($question, $cap, $cachecat = -1) {
  1180. global $USER, $DB;
  1181. // these are capabilities on existing questions capabilties are
  1182. //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
  1183. $question_questioncaps = array('edit', 'view', 'use', 'move');
  1184. static $questions = array();
  1185. static $categories = array();
  1186. static $cachedcat = arr