PageRenderTime 68ms CodeModel.GetById 28ms RepoModel.GetById 0ms app.codeStats 1ms

/lib/questionlib.php

https://github.com/DeanLennard/Moodle-Question-Engine-2
PHP | 1800 lines | 1018 code | 205 blank | 577 comment | 161 complexity | 90dfcaef56e3f2fdd9cd25e9c3e140be MD5 | raw file

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

Large files files are truncated, but you can click here to view the full file