PageRenderTime 64ms CodeModel.GetById 15ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/questionlib.php

https://github.com/mylescarrick/moodle
PHP | 3266 lines | 1761 code | 335 blank | 1170 comment | 312 complexity | cfcc2767f25930436a1dee24f347ec7e MD5 | raw file
Possible License(s): GPL-3.0, LGPL-2.1, BSD-3-Clause
  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. * Major Contributors
  28. * - Alex Smith, Julian Sedding and Gustav Delius {@link http://maths.york.ac.uk/serving_maths}
  29. *
  30. * @package core
  31. * @subpackage question
  32. * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
  33. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34. */
  35. defined('MOODLE_INTERNAL') || die();
  36. /// CONSTANTS ///////////////////////////////////
  37. /**#@+
  38. * The different types of events that can create question states
  39. */
  40. define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
  41. define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
  42. define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
  43. define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
  44. define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
  45. define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
  46. define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
  47. define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
  48. define('QUESTION_EVENTCLOSE', '8'); // The response has been submitted and the session has been closed, either because the student requested it or because Moodle did it (e.g. because of a timelimit). The responses have not been graded.
  49. define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
  50. define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','.
  51. QUESTION_EVENTCLOSEANDGRADE.','.
  52. QUESTION_EVENTMANUALGRADE);
  53. define('QUESTION_EVENTS_CLOSED', QUESTION_EVENTCLOSE.','.
  54. QUESTION_EVENTCLOSEANDGRADE.','.
  55. QUESTION_EVENTMANUALGRADE);
  56. define('QUESTION_EVENTS_CLOSED_OR_GRADED', QUESTION_EVENTGRADE.','.
  57. QUESTION_EVENTS_CLOSED);
  58. /**#@-*/
  59. /**#@+
  60. * The core question types.
  61. */
  62. define("SHORTANSWER", "shortanswer");
  63. define("TRUEFALSE", "truefalse");
  64. define("MULTICHOICE", "multichoice");
  65. define("RANDOM", "random");
  66. define("MATCH", "match");
  67. define("RANDOMSAMATCH", "randomsamatch");
  68. define("DESCRIPTION", "description");
  69. define("NUMERICAL", "numerical");
  70. define("MULTIANSWER", "multianswer");
  71. define("CALCULATED", "calculated");
  72. define("ESSAY", "essay");
  73. /**#@-*/
  74. /**
  75. * Constant determines the number of answer boxes supplied in the editing
  76. * form for multiple choice and similar question types.
  77. */
  78. define("QUESTION_NUMANS", "10");
  79. /**
  80. * Constant determines the number of answer boxes supplied in the editing
  81. * form for multiple choice and similar question types to start with, with
  82. * the option of adding QUESTION_NUMANS_ADD more answers.
  83. */
  84. define("QUESTION_NUMANS_START", 3);
  85. /**
  86. * Constant determines the number of answer boxes to add in the editing
  87. * form for multiple choice and similar question types when the user presses
  88. * 'add form fields button'.
  89. */
  90. define("QUESTION_NUMANS_ADD", 3);
  91. /**
  92. * The options used when popping up a question preview window in Javascript.
  93. */
  94. define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes&resizable=yes&width=700&height=540');
  95. /**#@+
  96. * Option flags for ->optionflags
  97. * The options are read out via bitwise operation using these constants
  98. */
  99. /**
  100. * Whether the questions is to be run in adaptive mode. If this is not set then
  101. * a question closes immediately after the first submission of responses. This
  102. * is how question is Moodle always worked before version 1.5
  103. */
  104. define('QUESTION_ADAPTIVE', 1);
  105. /**#@-*/
  106. /**#@+
  107. * Options for whether flags are shown/editable when rendering questions.
  108. */
  109. define('QUESTION_FLAGSHIDDEN', 0);
  110. define('QUESTION_FLAGSSHOWN', 1);
  111. define('QUESTION_FLAGSEDITABLE', 2);
  112. /**#@-*/
  113. /**
  114. * GLOBAL VARAIBLES
  115. * @global array $QTYPES
  116. * @name $QTYPES
  117. */
  118. global $QTYPES;
  119. /**
  120. * Array holding question type objects. Initialised via calls to
  121. * question_register_questiontype as the question type classes are included.
  122. */
  123. $QTYPES = array();
  124. /**
  125. * Add a new question type to the various global arrays above.
  126. *
  127. * @global object
  128. * @param object $qtype An instance of the new question type class.
  129. */
  130. function question_register_questiontype($qtype) {
  131. global $QTYPES;
  132. $name = $qtype->name();
  133. $QTYPES[$name] = $qtype;
  134. }
  135. require_once("$CFG->dirroot/question/type/questiontype.php");
  136. // Load the questiontype.php file for each question type
  137. // These files in turn call question_register_questiontype()
  138. // with a new instance of each qtype class.
  139. $qtypenames = get_plugin_list('qtype');
  140. foreach($qtypenames as $qtypename => $qdir) {
  141. // Instanciates all plug-in question types
  142. $qtypefilepath= "$qdir/questiontype.php";
  143. // echo "Loading $qtypename<br/>"; // Uncomment for debugging
  144. if (is_readable($qtypefilepath)) {
  145. require_once($qtypefilepath);
  146. }
  147. }
  148. /**
  149. * An array of question type names translated to the user's language, suitable for use when
  150. * creating a drop-down menu of options.
  151. *
  152. * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
  153. * The array returned will only hold the names of all the question types that the user should
  154. * be able to create directly. Some internal question types like random questions are excluded.
  155. *
  156. * @global object
  157. * @return array an array of question type names translated to the user's language.
  158. */
  159. function question_type_menu() {
  160. global $QTYPES;
  161. static $menuoptions = null;
  162. if (is_null($menuoptions)) {
  163. $config = get_config('question');
  164. $menuoptions = array();
  165. foreach ($QTYPES as $name => $qtype) {
  166. // Get the name if this qtype is enabled.
  167. $menuname = $qtype->menu_name();
  168. $enabledvar = $name . '_disabled';
  169. if ($menuname && !isset($config->$enabledvar)) {
  170. $menuoptions[$name] = $menuname;
  171. }
  172. }
  173. $menuoptions = question_sort_qtype_array($menuoptions, $config);
  174. }
  175. return $menuoptions;
  176. }
  177. /**
  178. * Sort an array of question type names according to the question type sort order stored in
  179. * config_plugins. Entries for which there is no xxx_sortorder defined will go
  180. * at the end, sorted according to textlib_get_instance()->asort($inarray).
  181. * @param $inarray an array $qtype => $QTYPES[$qtype]->local_name().
  182. * @param $config get_config('question'), if you happen to have it around, to save one DB query.
  183. * @return array the sorted version of $inarray.
  184. */
  185. function question_sort_qtype_array($inarray, $config = null) {
  186. if (is_null($config)) {
  187. $config = get_config('question');
  188. }
  189. $sortorder = array();
  190. foreach ($inarray as $name => $notused) {
  191. $sortvar = $name . '_sortorder';
  192. if (isset($config->$sortvar)) {
  193. $sortorder[$config->$sortvar] = $name;
  194. }
  195. }
  196. ksort($sortorder);
  197. $outarray = array();
  198. foreach ($sortorder as $name) {
  199. $outarray[$name] = $inarray[$name];
  200. unset($inarray[$name]);
  201. }
  202. textlib_get_instance()->asort($inarray);
  203. return array_merge($outarray, $inarray);
  204. }
  205. /**
  206. * Move one question type in a list of question types. If you try to move one element
  207. * off of the end, nothing will change.
  208. *
  209. * @param array $sortedqtypes An array $qtype => anything.
  210. * @param string $tomove one of the keys from $sortedqtypes
  211. * @param integer $direction +1 or -1
  212. * @return array an array $index => $qtype, with $index from 0 to n in order, and
  213. * the $qtypes in the same order as $sortedqtypes, except that $tomove will
  214. * have been moved one place.
  215. */
  216. function question_reorder_qtypes($sortedqtypes, $tomove, $direction) {
  217. $neworder = array_keys($sortedqtypes);
  218. // Find the element to move.
  219. $key = array_search($tomove, $neworder);
  220. if ($key === false) {
  221. return $neworder;
  222. }
  223. // Work out the other index.
  224. $otherkey = $key + $direction;
  225. if (!isset($neworder[$otherkey])) {
  226. return $neworder;
  227. }
  228. // Do the swap.
  229. $swap = $neworder[$otherkey];
  230. $neworder[$otherkey] = $neworder[$key];
  231. $neworder[$key] = $swap;
  232. return $neworder;
  233. }
  234. /**
  235. * Save a new question type order to the config_plugins table.
  236. * @global object
  237. * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order.
  238. * @param $config get_config('question'), if you happen to have it around, to save one DB query.
  239. */
  240. function question_save_qtype_order($neworder, $config = null) {
  241. global $DB;
  242. if (is_null($config)) {
  243. $config = get_config('question');
  244. }
  245. foreach ($neworder as $index => $qtype) {
  246. $sortvar = $qtype . '_sortorder';
  247. if (!isset($config->$sortvar) || $config->$sortvar != $index + 1) {
  248. set_config($sortvar, $index + 1, 'question');
  249. }
  250. }
  251. }
  252. /// OTHER CLASSES /////////////////////////////////////////////////////////
  253. /**
  254. * This holds the options that are set by the course module
  255. *
  256. * @package moodlecore
  257. * @subpackage question
  258. * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
  259. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  260. */
  261. class cmoptions {
  262. /**
  263. * Whether a new attempt should be based on the previous one. If true
  264. * then a new attempt will start in a state where all responses are set
  265. * to the last responses from the previous attempt.
  266. */
  267. var $attemptonlast = false;
  268. /**
  269. * Various option flags. The flags are accessed via bitwise operations
  270. * using the constants defined in the CONSTANTS section above.
  271. */
  272. var $optionflags = QUESTION_ADAPTIVE;
  273. /**
  274. * Determines whether in the calculation of the score for a question
  275. * penalties for earlier wrong responses within the same attempt will
  276. * be subtracted.
  277. */
  278. var $penaltyscheme = true;
  279. /**
  280. * The maximum time the user is allowed to answer the questions withing
  281. * an attempt. This is measured in minutes so needs to be multiplied by
  282. * 60 before compared to timestamps. If set to 0 no timelimit will be applied
  283. */
  284. var $timelimit = 0;
  285. /**
  286. * Timestamp for the closing time. Responses submitted after this time will
  287. * be saved but no credit will be given for them.
  288. */
  289. var $timeclose = 9999999999;
  290. /**
  291. * The id of the course from withing which the question is currently being used
  292. */
  293. var $course = SITEID;
  294. /**
  295. * Whether the answers in a multiple choice question should be randomly
  296. * shuffled when a new attempt is started.
  297. */
  298. var $shuffleanswers = true;
  299. /**
  300. * The number of decimals to be shown when scores are printed
  301. */
  302. var $decimalpoints = 2;
  303. }
  304. /// FUNCTIONS //////////////////////////////////////////////////////
  305. /**
  306. * Returns an array of names of activity modules that use this question
  307. *
  308. * @global object
  309. * @global object
  310. * @param object $questionid
  311. * @return array of strings
  312. */
  313. function question_list_instances($questionid) {
  314. global $CFG, $DB;
  315. $instances = array();
  316. $modules = $DB->get_records('modules');
  317. foreach ($modules as $module) {
  318. $fullmod = $CFG->dirroot . '/mod/' . $module->name;
  319. if (file_exists($fullmod . '/lib.php')) {
  320. include_once($fullmod . '/lib.php');
  321. $fn = $module->name.'_question_list_instances';
  322. if (function_exists($fn)) {
  323. $instances = $instances + $fn($questionid);
  324. }
  325. }
  326. }
  327. return $instances;
  328. }
  329. /**
  330. * Determine whether there arey any questions belonging to this context, that is whether any of its
  331. * question categories contain any questions. This will return true even if all the questions are
  332. * hidden.
  333. *
  334. * @global object
  335. * @param mixed $context either a context object, or a context id.
  336. * @return boolean whether any of the question categories beloning to this context have
  337. * any questions in them.
  338. */
  339. function question_context_has_any_questions($context) {
  340. global $DB;
  341. if (is_object($context)) {
  342. $contextid = $context->id;
  343. } else if (is_numeric($context)) {
  344. $contextid = $context;
  345. } else {
  346. print_error('invalidcontextinhasanyquestions', 'question');
  347. }
  348. return $DB->record_exists_sql("SELECT *
  349. FROM {question} q
  350. JOIN {question_categories} qc ON qc.id = q.category
  351. WHERE qc.contextid = ? AND q.parent = 0", array($contextid));
  352. }
  353. /**
  354. * Returns list of 'allowed' grades for grade selection
  355. * formatted suitably for dropdown box function
  356. * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
  357. */
  358. function get_grade_options() {
  359. // define basic array of grades. This list comprises all fractions of the form:
  360. // a. p/q for q <= 6, 0 <= p <= q
  361. // b. p/10 for 0 <= p <= 10
  362. // c. 1/q for 1 <= q <= 10
  363. // d. 1/20
  364. $grades = array(
  365. 1.0000000,
  366. 0.9000000,
  367. 0.8333333,
  368. 0.8000000,
  369. 0.7500000,
  370. 0.7000000,
  371. 0.6666667,
  372. 0.6000000,
  373. 0.5000000,
  374. 0.4000000,
  375. 0.3333333,
  376. 0.3000000,
  377. 0.2500000,
  378. 0.2000000,
  379. 0.1666667,
  380. 0.1428571,
  381. 0.1250000,
  382. 0.1111111,
  383. 0.1000000,
  384. 0.0500000,
  385. 0.0000000);
  386. // iterate through grades generating full range of options
  387. $gradeoptionsfull = array();
  388. $gradeoptions = array();
  389. foreach ($grades as $grade) {
  390. $percentage = 100 * $grade;
  391. $neggrade = -$grade;
  392. $gradeoptions["$grade"] = "$percentage %";
  393. $gradeoptionsfull["$grade"] = "$percentage %";
  394. $gradeoptionsfull["$neggrade"] = -$percentage." %";
  395. }
  396. $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
  397. // sort lists
  398. arsort($gradeoptions, SORT_NUMERIC);
  399. arsort($gradeoptionsfull, SORT_NUMERIC);
  400. // construct return object
  401. $grades = new stdClass;
  402. $grades->gradeoptions = $gradeoptions;
  403. $grades->gradeoptionsfull = $gradeoptionsfull;
  404. return $grades;
  405. }
  406. /**
  407. * match grade options
  408. * if no match return error or match nearest
  409. * @param array $gradeoptionsfull list of valid options
  410. * @param int $grade grade to be tested
  411. * @param string $matchgrades 'error' or 'nearest'
  412. * @return mixed either 'fixed' value or false if erro
  413. */
  414. function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
  415. // if we just need an error...
  416. if ($matchgrades=='error') {
  417. foreach($gradeoptionsfull as $value => $option) {
  418. // slightly fuzzy test, never check floats for equality :-)
  419. if (abs($grade-$value)<0.00001) {
  420. return $grade;
  421. }
  422. }
  423. // didn't find a match so that's an error
  424. return false;
  425. }
  426. // work out nearest value
  427. else if ($matchgrades=='nearest') {
  428. $hownear = array();
  429. foreach($gradeoptionsfull as $value => $option) {
  430. if ($grade==$value) {
  431. return $grade;
  432. }
  433. $hownear[ $value ] = abs( $grade - $value );
  434. }
  435. // reverse sort list of deltas and grab the last (smallest)
  436. asort( $hownear, SORT_NUMERIC );
  437. reset( $hownear );
  438. return key( $hownear );
  439. }
  440. else {
  441. return false;
  442. }
  443. }
  444. /**
  445. * Tests whether a category is in use by any activity module
  446. *
  447. * @global object
  448. * @return boolean
  449. * @param integer $categoryid
  450. * @param boolean $recursive Whether to examine category children recursively
  451. */
  452. function question_category_isused($categoryid, $recursive = false) {
  453. global $DB;
  454. //Look at each question in the category
  455. if ($questions = $DB->get_records('question', array('category'=>$categoryid), '', 'id,qtype')) {
  456. foreach ($questions as $question) {
  457. if (count(question_list_instances($question->id))) {
  458. return true;
  459. }
  460. }
  461. }
  462. //Look under child categories recursively
  463. if ($recursive) {
  464. if ($children = $DB->get_records('question_categories', array('parent'=>$categoryid))) {
  465. foreach ($children as $child) {
  466. if (question_category_isused($child->id, $recursive)) {
  467. return true;
  468. }
  469. }
  470. }
  471. }
  472. return false;
  473. }
  474. /**
  475. * Deletes all data associated to an attempt from the database
  476. *
  477. * @global object
  478. * @global object
  479. * @param integer $attemptid The id of the attempt being deleted
  480. */
  481. function delete_attempt($attemptid) {
  482. global $QTYPES, $DB;
  483. $states = $DB->get_records('question_states', array('attempt'=>$attemptid));
  484. if ($states) {
  485. $stateslist = implode(',', array_keys($states));
  486. // delete question-type specific data
  487. foreach ($QTYPES as $qtype) {
  488. $qtype->delete_states($stateslist);
  489. }
  490. }
  491. // delete entries from all other question tables
  492. // It is important that this is done only after calling the questiontype functions
  493. $DB->delete_records("question_states", array("attempt"=>$attemptid));
  494. $DB->delete_records("question_sessions", array("attemptid"=>$attemptid));
  495. $DB->delete_records("question_attempts", array("id"=>$attemptid));
  496. }
  497. /**
  498. * Deletes question and all associated data from the database
  499. *
  500. * It will not delete a question if it is used by an activity module
  501. *
  502. * @global object
  503. * @global object
  504. * @param object $question The question being deleted
  505. */
  506. function delete_question($questionid) {
  507. global $QTYPES, $DB;
  508. $question = $DB->get_record_sql('
  509. SELECT q.*, qc.contextid
  510. FROM {question} q
  511. JOIN {question_categories} qc ON qc.id = q.category
  512. WHERE q.id = ?', array($questionid));
  513. if (!$question) {
  514. // In some situations, for example if this was a child of a
  515. // Cloze question that was previously deleted, the question may already
  516. // have gone. In this case, just do nothing.
  517. return;
  518. }
  519. // Do not delete a question if it is used by an activity module
  520. if (count(question_list_instances($questionid))) {
  521. return;
  522. }
  523. // delete questiontype-specific data
  524. question_require_capability_on($question, 'edit');
  525. if (isset($QTYPES[$question->qtype])) {
  526. $QTYPES[$question->qtype]->delete_question($questionid, $question->contextid);
  527. }
  528. if ($states = $DB->get_records('question_states', array('question'=>$questionid))) {
  529. $stateslist = implode(',', array_keys($states));
  530. // delete questiontype-specific data
  531. foreach ($QTYPES as $qtype) {
  532. $qtype->delete_states($stateslist);
  533. }
  534. }
  535. // Delete entries from all other question tables
  536. // It is important that this is done only after calling the questiontype functions
  537. $DB->delete_records('question_answers', array('question' => $questionid));
  538. $DB->delete_records('question_states', array('question' => $questionid));
  539. $DB->delete_records('question_sessions', array('questionid' => $questionid));
  540. // Now recursively delete all child questions
  541. if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id,qtype')) {
  542. foreach ($children as $child) {
  543. if ($child->id != $questionid) {
  544. delete_question($child->id);
  545. }
  546. }
  547. }
  548. // Finally delete the question record itself
  549. $DB->delete_records('question', array('id'=>$questionid));
  550. }
  551. /**
  552. * All question categories and their questions are deleted for this course.
  553. *
  554. * @global object
  555. * @param object $mod an object representing the activity
  556. * @param boolean $feedback to specify if the process must output a summary of its work
  557. * @return boolean
  558. */
  559. function question_delete_course($course, $feedback=true) {
  560. global $DB, $OUTPUT;
  561. //To store feedback to be showed at the end of the process
  562. $feedbackdata = array();
  563. //Cache some strings
  564. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  565. $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
  566. $categoriescourse = $DB->get_records('question_categories', array('contextid'=>$coursecontext->id), 'parent', 'id, parent, name, contextid');
  567. if ($categoriescourse) {
  568. //Sort categories following their tree (parent-child) relationships
  569. //this will make the feedback more readable
  570. $categoriescourse = sort_categories_by_tree($categoriescourse);
  571. foreach ($categoriescourse as $category) {
  572. //Delete it completely (questions and category itself)
  573. //deleting questions
  574. if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
  575. foreach ($questions as $question) {
  576. delete_question($question->id);
  577. }
  578. $DB->delete_records("question", array("category"=>$category->id));
  579. }
  580. //delete the category
  581. $DB->delete_records('question_categories', array('id'=>$category->id));
  582. //Fill feedback
  583. $feedbackdata[] = array($category->name, $strcatdeleted);
  584. }
  585. //Inform about changes performed if feedback is enabled
  586. if ($feedback) {
  587. $table = new html_table();
  588. $table->head = array(get_string('category','quiz'), get_string('action'));
  589. $table->data = $feedbackdata;
  590. echo html_writer::table($table);
  591. }
  592. }
  593. return true;
  594. }
  595. /**
  596. * Category is about to be deleted,
  597. * 1/ All question categories and their questions are deleted for this course category.
  598. * 2/ All questions are moved to new category
  599. *
  600. * @global object
  601. * @param object $category course category object
  602. * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
  603. * @param boolean $feedback to specify if the process must output a summary of its work
  604. * @return boolean
  605. */
  606. function question_delete_course_category($category, $newcategory, $feedback=true) {
  607. global $DB, $OUTPUT;
  608. $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
  609. if (empty($newcategory)) {
  610. $feedbackdata = array(); // To store feedback to be showed at the end of the process
  611. $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
  612. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  613. // Loop over question categories.
  614. if ($categories = $DB->get_records('question_categories', array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
  615. foreach ($categories as $category) {
  616. // Deal with any questions in the category.
  617. if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
  618. // Try to delete each question.
  619. foreach ($questions as $question) {
  620. delete_question($question->id);
  621. }
  622. // Check to see if there were any questions that were kept because they are
  623. // still in use somehow, even though quizzes in courses in this category will
  624. // already have been deteted. This could happen, for example, if questions are
  625. // added to a course, and then that course is moved to another category (MDL-14802).
  626. $questionids = $DB->get_records_menu('question', array('category'=>$category->id), '', 'id,1');
  627. if (!empty($questionids)) {
  628. if (!$rescueqcategory = question_save_from_deletion(array_keys($questionids),
  629. get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
  630. return false;
  631. }
  632. $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
  633. }
  634. }
  635. // Now delete the category.
  636. if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
  637. return false;
  638. }
  639. $feedbackdata[] = array($category->name, $strcatdeleted);
  640. } // End loop over categories.
  641. }
  642. // Output feedback if requested.
  643. if ($feedback and $feedbackdata) {
  644. $table = new html_table();
  645. $table->head = array(get_string('questioncategory','question'), get_string('action'));
  646. $table->data = $feedbackdata;
  647. echo html_writer::table($table);
  648. }
  649. } else {
  650. // Move question categories ot the new context.
  651. if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
  652. return false;
  653. }
  654. $DB->set_field('question_categories', 'contextid', $newcontext->id, array('contextid'=>$context->id));
  655. if ($feedback) {
  656. $a = new stdClass;
  657. $a->oldplace = print_context_name($context);
  658. $a->newplace = print_context_name($newcontext);
  659. echo $OUTPUT->notification(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
  660. }
  661. }
  662. return true;
  663. }
  664. /**
  665. * Enter description here...
  666. *
  667. * @global object
  668. * @param string $questionids list of questionids
  669. * @param object $newcontext the context to create the saved category in.
  670. * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
  671. * @param object $newcategory
  672. * @return mixed false on
  673. */
  674. function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
  675. global $DB;
  676. // Make a category in the parent context to move the questions to.
  677. if (is_null($newcategory)) {
  678. $newcategory = new stdClass();
  679. $newcategory->parent = 0;
  680. $newcategory->contextid = $newcontextid;
  681. $newcategory->name = get_string('questionsrescuedfrom', 'question', $oldplace);
  682. $newcategory->info = get_string('questionsrescuedfrominfo', 'question', $oldplace);
  683. $newcategory->sortorder = 999;
  684. $newcategory->stamp = make_unique_id_code();
  685. $newcategory->id = $DB->insert_record('question_categories', $newcategory);
  686. }
  687. // Move any remaining questions to the 'saved' category.
  688. if (!question_move_questions_to_category($questionids, $newcategory->id)) {
  689. return false;
  690. }
  691. return $newcategory;
  692. }
  693. /**
  694. * All question categories and their questions are deleted for this activity.
  695. *
  696. * @global object
  697. * @param object $cm the course module object representing the activity
  698. * @param boolean $feedback to specify if the process must output a summary of its work
  699. * @return boolean
  700. */
  701. function question_delete_activity($cm, $feedback=true) {
  702. global $DB, $OUTPUT;
  703. //To store feedback to be showed at the end of the process
  704. $feedbackdata = array();
  705. //Cache some strings
  706. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  707. $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
  708. if ($categoriesmods = $DB->get_records('question_categories', array('contextid'=>$modcontext->id), 'parent', 'id, parent, name, contextid')){
  709. //Sort categories following their tree (parent-child) relationships
  710. //this will make the feedback more readable
  711. $categoriesmods = sort_categories_by_tree($categoriesmods);
  712. foreach ($categoriesmods as $category) {
  713. //Delete it completely (questions and category itself)
  714. //deleting questions
  715. if ($questions = $DB->get_records('question', array('category' => $category->id), '', 'id,qtype')) {
  716. foreach ($questions as $question) {
  717. delete_question($question->id);
  718. }
  719. $DB->delete_records("question", array("category"=>$category->id));
  720. }
  721. //delete the category
  722. $DB->delete_records('question_categories', array('id'=>$category->id));
  723. //Fill feedback
  724. $feedbackdata[] = array($category->name, $strcatdeleted);
  725. }
  726. //Inform about changes performed if feedback is enabled
  727. if ($feedback) {
  728. $table = new html_table();
  729. $table->head = array(get_string('category','quiz'), get_string('action'));
  730. $table->data = $feedbackdata;
  731. echo html_writer::table($table);
  732. }
  733. }
  734. return true;
  735. }
  736. /**
  737. * This function should be considered private to the question bank, it is called from
  738. * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
  739. * acutally moving questions and associated data. However, callers of this function also have to
  740. * do other work, which is why you should not call this method directly from outside the questionbank.
  741. *
  742. * @global object
  743. * @param string $questionids a comma-separated list of question ids.
  744. * @param integer $newcategoryid the id of the category to move to.
  745. */
  746. function question_move_questions_to_category($questionids, $newcategoryid) {
  747. global $DB, $QTYPES;
  748. $newcontextid = $DB->get_field('question_categories', 'contextid',
  749. array('id' => $newcategoryid));
  750. list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
  751. $questions = $DB->get_records_sql("
  752. SELECT q.id, q.qtype, qc.contextid
  753. FROM {question} q
  754. JOIN {question_categories} qc ON q.category = qc.id
  755. WHERE q.id $questionidcondition", $params);
  756. foreach ($questions as $question) {
  757. if ($newcontextid != $question->contextid) {
  758. $QTYPES[$question->qtype]->move_files($question->id,
  759. $question->contextid, $newcontextid);
  760. }
  761. }
  762. // Move the questions themselves.
  763. $DB->set_field_select('question', 'category', $newcategoryid, "id $questionidcondition", $params);
  764. // Move any subquestions belonging to them.
  765. $DB->set_field_select('question', 'category', $newcategoryid, "parent $questionidcondition", $params);
  766. // TODO Deal with datasets.
  767. return true;
  768. }
  769. /**
  770. * This function helps move a question cateogry to a new context by moving all
  771. * the files belonging to all the questions to the new context.
  772. * Also moves subcategories.
  773. * @param integer $categoryid the id of the category being moved.
  774. * @param integer $oldcontextid the old context id.
  775. * @param integer $newcontextid the new context id.
  776. */
  777. function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) {
  778. global $DB, $QTYPES;
  779. $questionids = $DB->get_records_menu('question',
  780. array('category' => $categoryid), '', 'id,qtype');
  781. foreach ($questionids as $questionid => $qtype) {
  782. $QTYPES[$qtype]->move_files($questionid, $oldcontextid, $newcontextid);
  783. }
  784. $subcatids = $DB->get_records_menu('question_categories',
  785. array('parent' => $categoryid), '', 'id,1');
  786. foreach ($subcatids as $subcatid => $notused) {
  787. $DB->set_field('question_categories', 'contextid', $newcontextid, array('id' => $subcatid));
  788. question_move_category_to_context($subcatid, $oldcontextid, $newcontextid);
  789. }
  790. }
  791. /**
  792. * Given a list of ids, load the basic information about a set of questions from the questions table.
  793. * The $join and $extrafields arguments can be used together to pull in extra data.
  794. * See, for example, the usage in mod/quiz/attemptlib.php, and
  795. * read the code below to see how the SQL is assembled. Throws exceptions on error.
  796. *
  797. * @global object
  798. * @global object
  799. * @param array $questionids array of question ids.
  800. * @param string $extrafields extra SQL code to be added to the query.
  801. * @param string $join extra SQL code to be added to the query.
  802. * @param array $extraparams values for any placeholders in $join.
  803. * You are strongly recommended to use named placeholder.
  804. *
  805. * @return array partially complete question objects. You need to call get_question_options
  806. * on them before they can be properly used.
  807. */
  808. function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) {
  809. global $CFG, $DB;
  810. if (empty($questionids)) {
  811. return array();
  812. }
  813. if ($join) {
  814. $join = ' JOIN '.$join;
  815. }
  816. if ($extrafields) {
  817. $extrafields = ', ' . $extrafields;
  818. }
  819. list($questionidcondition, $params) = $DB->get_in_or_equal(
  820. $questionids, SQL_PARAMS_NAMED, 'qid0000');
  821. $sql = 'SELECT q.*' . $extrafields . ' FROM {question} q' . $join .
  822. ' WHERE q.id ' . $questionidcondition;
  823. // Load the questions
  824. if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) {
  825. return 'Could not load questions.';
  826. }
  827. foreach ($questions as $question) {
  828. $question->_partiallyloaded = true;
  829. }
  830. // Note, a possible optimisation here would be to not load the TEXT fields
  831. // (that is, questiontext and generalfeedback) here, and instead load them in
  832. // question_load_questions. That would add one DB query, but reduce the amount
  833. // of data transferred most of the time. I am not going to do this optimisation
  834. // until it is shown to be worthwhile.
  835. return $questions;
  836. }
  837. /**
  838. * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used
  839. * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and
  840. * read the code below to see how the SQL is assembled. Throws exceptions on error.
  841. *
  842. * @param array $questionids array of question ids.
  843. * @param string $extrafields extra SQL code to be added to the query.
  844. * @param string $join extra SQL code to be added to the query.
  845. * @param array $extraparams values for any placeholders in $join.
  846. * You are strongly recommended to use named placeholder.
  847. *
  848. * @return array question objects.
  849. */
  850. function question_load_questions($questionids, $extrafields = '', $join = '') {
  851. $questions = question_preload_questions($questionids, $extrafields, $join);
  852. // Load the question type specific information
  853. if (!get_question_options($questions)) {
  854. return 'Could not load the question options';
  855. }
  856. return $questions;
  857. }
  858. /**
  859. * Private function to factor common code out of get_question_options().
  860. *
  861. * @global object
  862. * @global object
  863. * @param object $question the question to tidy.
  864. * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
  865. * @return boolean true if successful, else false.
  866. */
  867. function _tidy_question(&$question, $loadtags = false) {
  868. global $CFG, $QTYPES;
  869. if (!array_key_exists($question->qtype, $QTYPES)) {
  870. $question->qtype = 'missingtype';
  871. $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
  872. }
  873. $question->name_prefix = question_make_name_prefix($question->id);
  874. if ($success = $QTYPES[$question->qtype]->get_question_options($question)) {
  875. if (isset($question->_partiallyloaded)) {
  876. unset($question->_partiallyloaded);
  877. }
  878. }
  879. if ($loadtags && !empty($CFG->usetags)) {
  880. require_once($CFG->dirroot . '/tag/lib.php');
  881. $question->tags = tag_get_tags_array('question', $question->id);
  882. }
  883. return $success;
  884. }
  885. /**
  886. * Updates the question objects with question type specific
  887. * information by calling {@link get_question_options()}
  888. *
  889. * Can be called either with an array of question objects or with a single
  890. * question object.
  891. *
  892. * @param mixed $questions Either an array of question objects to be updated
  893. * or just a single question object
  894. * @param boolean $loadtags load the question tags from the tags table. Optional, default false.
  895. * @return bool Indicates success or failure.
  896. */
  897. function get_question_options(&$questions, $loadtags = false) {
  898. if (is_array($questions)) { // deal with an array of questions
  899. foreach ($questions as $i => $notused) {
  900. if (!_tidy_question($questions[$i], $loadtags)) {
  901. return false;
  902. }
  903. }
  904. return true;
  905. } else { // deal with single question
  906. return _tidy_question($questions, $loadtags);
  907. }
  908. }
  909. /**
  910. * Load the basic state information for
  911. *
  912. * @global object
  913. * @param integer $attemptid the attempt id to load the states for.
  914. * @return array an array of state data from the database, you will subsequently
  915. * need to call question_load_states to get fully loaded states that can be
  916. * used by the question types. The states here should be sufficient for
  917. * basic tasks like rendering navigation.
  918. */
  919. function question_preload_states($attemptid) {
  920. global $DB;
  921. // Note, changes here probably also need to be reflected in
  922. // regrade_question_in_attempt and question_load_specific_state.
  923. // The questionid field must be listed first so that it is used as the
  924. // array index in the array returned by $DB->get_records_sql
  925. $statefields = 'n.questionid as question, s.id, s.attempt, ' .
  926. 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' .
  927. 's.penalty, n.sumpenalty, n.manualcomment, n.manualcommentformat, ' .
  928. 'n.flagged, n.id as questionsessionid';
  929. // Load the newest states for the questions
  930. $sql = "SELECT $statefields
  931. FROM {question_states} s, {question_sessions} n
  932. WHERE s.id = n.newest AND n.attemptid = ?";
  933. $states = $DB->get_records_sql($sql, array($attemptid));
  934. if (!$states) {
  935. return false;
  936. }
  937. // Load the newest graded states for the questions
  938. $sql = "SELECT $statefields
  939. FROM {question_states} s, {question_sessions} n
  940. WHERE s.id = n.newgraded AND n.attemptid = ?";
  941. $gradedstates = $DB->get_records_sql($sql, array($attemptid));
  942. // Hook the two together.
  943. foreach ($states as $questionid => $state) {
  944. $states[$questionid]->_partiallyloaded = true;
  945. if ($gradedstates[$questionid]) {
  946. $states[$questionid]->last_graded = $gradedstates[$questionid];
  947. $states[$questionid]->last_graded->_partiallyloaded = true;
  948. }
  949. }
  950. return $states;
  951. }
  952. /**
  953. * Finish loading the question states that were extracted from the database with
  954. * question_preload_states, creating new states for any question where there
  955. * is not a state in the database.
  956. *
  957. * @global object
  958. * @global object
  959. * @param array $questions the questions to load state for.
  960. * @param array $states the partially loaded states this array is updated.
  961. * @param object $cmoptions options from the module we are loading the states for. E.g. $quiz.
  962. * @param object $attempt The attempt for which the question sessions are
  963. * to be restored or created.
  964. * @param mixed either the id of a previous attempt, if this attmpt is
  965. * building on a previous one, or false for a clean attempt.
  966. * @return true or false for success or failure.
  967. */
  968. function question_load_states(&$questions, &$states, $cmoptions, $attempt, $lastattemptid = false) {
  969. global $QTYPES, $DB;
  970. // loop through all questions and set the last_graded states
  971. foreach (array_keys($questions) as $qid) {
  972. if (isset($states[$qid])) {
  973. restore_question_state($questions[$qid], $states[$qid]);
  974. if (isset($states[$qid]->_partiallyloaded)) {
  975. unset($states[$qid]->_partiallyloaded);
  976. }
  977. if (isset($states[$qid]->last_graded)) {
  978. restore_question_state($questions[$qid], $states[$qid]->last_graded);
  979. if (isset($states[$qid]->last_graded->_partiallyloaded)) {
  980. unset($states[$qid]->last_graded->_partiallyloaded);
  981. }
  982. } else {
  983. $states[$qid]->last_graded = clone($states[$qid]);
  984. }
  985. } else {
  986. if ($lastattemptid) {
  987. // If the new attempt is to be based on this previous attempt.
  988. // Find the responses from the previous attempt and save them to the new session
  989. // Load the last graded state for the question. Note, $statefields is
  990. // the same as above, except that we don't want n.manualcomment.
  991. $statefields = 'n.questionid as question, s.id, s.attempt, ' .
  992. 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' .
  993. 's.penalty, n.sumpenalty';
  994. $sql = "SELECT $statefields
  995. FROM {question_states} s, {question_sessions} n
  996. WHERE s.id = n.newest
  997. AND n.attemptid = ?
  998. AND n.questionid = ?";
  999. if (!$laststate = $DB->get_record_sql($sql, array($lastattemptid, $qid))) {
  1000. // Only restore previous responses that have been graded
  1001. continue;
  1002. }
  1003. // Restore the state so that the responses will be restored
  1004. restore_question_state($questions[$qid], $laststate);
  1005. $states[$qid] = clone($laststate);
  1006. unset($states[$qid]->id);
  1007. } else {
  1008. // create a new empty state
  1009. $states[$qid] = new stdClass();
  1010. $states[$qid]->question = $qid;
  1011. $states[$qid]->responses = array('' => '');
  1012. $states[$qid]->raw_grade = 0;
  1013. }
  1014. // now fill/overide initial values
  1015. $states[$qid]->attempt = $attempt->uniqueid;
  1016. $states[$qid]->seq_number = 0;
  1017. $states[$qid]->timestamp = $attempt->timestart;
  1018. $states[$qid]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
  1019. $states[$qid]->grade = 0;
  1020. $states[$qid]->penalty = 0;
  1021. $states[$qid]->sumpenalty = 0;
  1022. $states[$qid]->manualcomment = '';
  1023. $states[$qid]->manualcommentformat = FORMAT_HTML;
  1024. $states[$qid]->flagged = 0;
  1025. // Prevent further changes to the session from incrementing the
  1026. // sequence number
  1027. $states[$qid]->changed = true;
  1028. if ($lastattemptid) {
  1029. // prepare the previous responses for new processing
  1030. $action = new stdClass;
  1031. $action->responses = $laststate->responses;
  1032. $action->timestamp = $laststate->timestamp;
  1033. $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
  1034. // Process these responses ...
  1035. question_process_responses($questions[$qid], $states[$qid], $action, $cmoptions, $attempt);
  1036. // Fix for Bug #5506: When each attempt is built on the last one,
  1037. // preserve the options from any previous attempt.
  1038. if ( isset($laststate->options) ) {
  1039. $states[$qid]->options = $laststate->options;
  1040. }
  1041. } else {
  1042. // Create the empty question type specific information
  1043. if (!$QTYPES[$questions[$qid]->qtype]->create_session_and_responses(
  1044. $questions[$qid], $states[$qid], $cmoptions, $attempt)) {
  1045. return false;
  1046. }
  1047. }
  1048. $states[$qid]->last_graded = clone($states[$qid]);
  1049. }
  1050. }
  1051. return true;
  1052. }
  1053. /**
  1054. * Loads the most recent state of each question session from the database
  1055. * or create new one.
  1056. *
  1057. * For each question the most recent session state for the current attempt
  1058. * is loaded from the question_states table and the question type specific data and
  1059. * responses are added by calling {@link restore_question_state()} which in turn
  1060. * calls {@link restore_session_and_responses()} for each question.
  1061. * If no states exist for the question instance an empty state object is
  1062. * created representing the start of a session and empty question
  1063. * type specific information and responses are created by calling
  1064. * {@link create_session_and_responses()}.
  1065. *
  1066. * @return array An array of state objects representing the most recent
  1067. * states of the question sessions.
  1068. * @param array $questions The questions for which sessions are to be restored or
  1069. * created.
  1070. * @param object $cmoptions
  1071. * @param object $attempt The attempt for which the question sessions are
  1072. * to be restored or created.
  1073. * @param mixed either the id of a previous attempt, if this attmpt is
  1074. * building on a previous one, or false for a clean attempt.
  1075. */
  1076. function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
  1077. // Preload the states.
  1078. $states = question_preload_states($attempt->uniqueid);
  1079. if (!$states) {
  1080. $states = array();
  1081. }
  1082. // Then finish the job.
  1083. if (!question_load_states($questions, $states, $cmoptions, $attempt, $lastattemptid)) {
  1084. return false;
  1085. }
  1086. return $states;
  1087. }
  1088. /**
  1089. * Load a particular previous state of a question.
  1090. *
  1091. * @global object
  1092. * @param array $question The question to load the state for.
  1093. * @param object $cmoptions Options from the specifica activity module, e.g. $quiz.
  1094. * @param integer $attemptid The question_attempts this is part of.
  1095. * @param integer $stateid The id of a specific state of this question.
  1096. * @return object the requested state. False on error.
  1097. */
  1098. function question_load_specific_state($question, $cmoptions, $attemptid, $stateid) {
  1099. global $DB;
  1100. // Load specified states for the question.
  1101. // sess.sumpenalty is probably wrong here shoul really be a sum of penalties from before the one we are asking for.
  1102. $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.manualcommentformat,
  1103. sess.flagged, sess.id as questionsessionid
  1104. FROM {question_states} st, {question_sessions} sess
  1105. WHERE st.id = ?
  1106. AND st.attempt = ?
  1107. AND sess.attemptid = st.attempt
  1108. AND st.question = ?
  1109. AND sess.questionid = st.question';
  1110. $state = $DB->get_record_sql($sql, array($stateid, $attemptid, $question->id));
  1111. if (!$state) {
  1112. return false;
  1113. }
  1114. restore_question_state($question, $state);
  1115. // Load the most recent graded states for the questions before the specified one.
  1116. $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.manualcommentformat,
  1117. sess.flagged, sess.id as questionsessionid
  1118. FROM {question_states} st, {question_sessions} sess
  1119. WHERE st.seq_number <= ?
  1120. AND st.attempt = ?
  1121. AND sess.attemptid = st.attempt
  1122. AND st.question = ?
  1123. AND sess.questionid = st.question
  1124. AND st.event IN ('.QUESTION_EVENTS_GRADED.') '.
  1125. 'ORDER BY st.seq_number DESC';
  1126. $gradedstates = $DB->get_records_sql($sql, array($state->seq_number, $attemptid, $question->id), 0, 1);
  1127. if (empty($gradedstates)) {
  1128. $state->last_graded = clone($state);
  1129. } else {
  1130. $gradedstate = reset($gradedstates);
  1131. restore_question_state($question, $gradedstate);
  1132. $state->last_graded = $gradedstate;
  1133. }
  1134. return $state;
  1135. }
  1136. /**
  1137. * Creates the run-time fields for the states
  1138. *
  1139. * Extends the state objects for a question by calling
  1140. * {@link restore_session_and_responses()}
  1141. *
  1142. * @global object
  1143. * @param object $question The question for which the state is needed
  1144. * @param object $state The state as loaded from the database
  1145. * @return boolean Represents success or failure
  1146. */
  1147. function restore_question_state(&$question, &$state) {
  1148. global $QTYPES;
  1149. // initialise response to the value in the answer field
  1150. $state->responses = array('' => $state->answer);
  1151. // Set the changed field to false; any code which changes the
  1152. // question session must set this to true and must increment
  1153. // ->seq_number. The save_question_session
  1154. // function will save the new state object to the database if the field is
  1155. // set to true.
  1156. $state->changed = false;
  1157. // Load the question type specific data
  1158. return $QTYPES[$question->qtype]->restore_session_and_responses($question, $state);
  1159. }
  1160. /**
  1161. * Saves the current state of the question session to the database
  1162. *
  1163. * The state object representing the current state of the session for the
  1164. * question is saved to the question_states table with ->responses[''] saved
  1165. * to the answer field of the database table. The information in the
  1166. * question_sessions table is updated.
  1167. * The question type specific data is then saved.
  1168. *
  1169. * @global array
  1170. * @global object
  1171. * @return mixed The id of the saved or updated state or false
  1172. * @param object $question The question for which session is to be saved.
  1173. * @param object $state The state information to be saved. In particular the
  1174. * most recent responses are in ->responses. The object
  1175. * is updated to hold the new ->id.
  1176. */
  1177. function save_question_session($question, $state) {
  1178. global $QTYPES, $DB;
  1179. // Check if the state has changed
  1180. if (!$state->changed && isset($state->id)) {
  1181. if (isset($state->newflaggedstate) && $state->flagged != $state->newflaggedstate) {
  1182. // If this fails, don't worry too much, it is not critical data.
  1183. question_update_flag($state->questionsessionid, $state->newflaggedstate);
  1184. }
  1185. return $state->id;
  1186. }
  1187. // Set the legacy answer field
  1188. $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
  1189. // Save the state
  1190. if (!empty($state->update)) { // this forces the old state record to be overwritten
  1191. $DB->update_record('question_states', $state);
  1192. } else {
  1193. $state->id = $DB->insert_record('question_states', $state);
  1194. }
  1195. // create or update the session
  1196. if (!$session = $DB->get_record('question_sessions', array('attemptid' => $state->attempt, 'questionid' => $question->id))) {
  1197. $session = new stdClass;
  1198. $session->attemptid = $state->attempt;
  1199. $session->questionid = $question->id;
  1200. $session->newest = $state->id;
  1201. // The following may seem weird, but the newgraded field needs to be set
  1202. // already even if there is no graded state yet.
  1203. $session->newgraded = $state->id;
  1204. $session->sumpenalty = $state->sumpenalty;
  1205. $session->manualcomment = $state->manualcomment;
  1206. $session->manualcommentformat = $state->manualcommentformat;
  1207. $session->flagged = !empty($state->newflaggedstate);
  1208. $DB->insert_record('question_sessions', $session);
  1209. } else {
  1210. $session->newest = $state->id;
  1211. if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
  1212. // this state is graded or newly opened, so it goes into the lastgraded field as well
  1213. $session->newgraded = $state->id;
  1214. $session->sumpenalty = $state->sumpenalty;
  1215. $session->manualcomment = $state->manualcomment;
  1216. $session->manualcommentformat = $state->manualcommentformat;
  1217. }
  1218. $session->flagged = !empty($state->newflaggedstate);
  1219. $DB->update_record('question_sessions', $session);
  1220. }
  1221. unset($state->answer);
  1222. // Save the question type specific state information and responses
  1223. if (!$QTYPES[$question->qtype]->save_session_and_responses($question, $state)) {
  1224. return false;
  1225. }
  1226. // Reset the changed flag
  1227. $state->changed = false;
  1228. return $state->id;
  1229. }
  1230. /**
  1231. * Determines whether a state has been graded by looking at the event field
  1232. *
  1233. * @return boolean true if the state has been graded
  1234. * @param object $state
  1235. */
  1236. function question_state_is_graded($state) {
  1237. static $question_events_graded = array();
  1238. if (!$question_events_graded){
  1239. $question_events_graded = explode(',', QUESTION_EVENTS_GRADED);
  1240. }
  1241. return (in_array($state->event, $question_events_graded));
  1242. }
  1243. /**
  1244. * Determines whether a state has been closed by looking at the event field
  1245. *
  1246. * @return boolean true if the state has been closed
  1247. * @param object $state
  1248. */
  1249. function question_state_is_closed($state) {
  1250. static $question_events_closed = array();
  1251. if (!$question_events_closed){
  1252. $question_events_closed = explode(',', QUESTION_EVENTS_CLOSED);
  1253. }
  1254. return (in_array($state->event, $question_events_closed));
  1255. }
  1256. /**
  1257. * Extracts responses from submitted form
  1258. *
  1259. * This can extract the responses given to one or several questions present on a page
  1260. * It returns an array with one entry for each question, indexed by question id
  1261. * Each entry is an object with the properties
  1262. * ->event The event that has triggered the submission. This is determined by which button
  1263. * the user has pressed.
  1264. * ->responses An array holding the responses to an individual question, indexed by the
  1265. * name of the corresponding form element.
  1266. * ->timestamp A unix timestamp
  1267. * @return array array of action objects, indexed by question ids.
  1268. * @param array $questions an array containing at least all questions that are used on the form
  1269. * @param array $formdata the data submitted by the form on the question page
  1270. * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
  1271. */
  1272. function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
  1273. $time = time();
  1274. $actions = array();
  1275. foreach ($formdata as $key => $response) {
  1276. // Get the question id from the response name
  1277. if (false !== ($quid = question_get_id_from_name_prefix($key))) {
  1278. // check if this is a valid id
  1279. if (!isset($questions[$quid])) {
  1280. print_error('formquestionnotinids', 'question');
  1281. }
  1282. // Remove the name prefix from the name
  1283. //decrypt trying
  1284. $key = substr($key, strlen($questions[$quid]->name_prefix));
  1285. if (false === $key) {
  1286. $key = '';
  1287. }
  1288. // Check for question validate and mark buttons & set events
  1289. if ($key === 'validate') {
  1290. $actions[$quid]->event = QUESTION_EVENTVALIDATE;
  1291. } else if ($key === 'submit') {
  1292. $actions[$quid]->event = QUESTION_EVENTSUBMIT;
  1293. } else {
  1294. $actions[$quid]->event = $defaultevent;
  1295. }
  1296. // Update the state with the new response
  1297. $actions[$quid]->responses[$key] = $response;
  1298. // Set the timestamp
  1299. $actions[$quid]->timestamp = $time;
  1300. }
  1301. }
  1302. foreach ($actions as $quid => $notused) {
  1303. ksort($actions[$quid]->responses);
  1304. }
  1305. return $actions;
  1306. }
  1307. /**
  1308. * Returns the html for question feedback image.
  1309. *
  1310. * @global object
  1311. * @param float $fraction value representing the correctness of the user's
  1312. * response to a question.
  1313. * @param boolean $selected whether or not the answer is the one that the
  1314. * user picked.
  1315. * @return string
  1316. */
  1317. function question_get_feedback_image($fraction, $selected=true) {
  1318. global $CFG, $OUTPUT;
  1319. static $icons = array('correct' => 'tick_green', 'partiallycorrect' => 'tick_amber',
  1320. 'incorrect' => 'cross_red');
  1321. if ($selected) {
  1322. $size = 'big';
  1323. } else {
  1324. $size = 'small';
  1325. }
  1326. $class = question_get_feedback_class($fraction);
  1327. return '<img src="' . $OUTPUT->pix_url('i/' . $icons[$class] . '_' . $size) .
  1328. '" alt="' . get_string($class, 'quiz') . '" class="icon" />';
  1329. }
  1330. /**
  1331. * Returns the class name for question feedback.
  1332. * @param float $fraction value representing the correctness of the user's
  1333. * response to a question.
  1334. * @return string
  1335. */
  1336. function question_get_feedback_class($fraction) {
  1337. if ($fraction >= 1/1.01) {
  1338. return 'correct';
  1339. } else if ($fraction > 0.0) {
  1340. return 'partiallycorrect';
  1341. } else {
  1342. return 'incorrect';
  1343. }
  1344. }
  1345. /**
  1346. * For a given question in an attempt we walk the complete history of states
  1347. * and recalculate the grades as we go along.
  1348. *
  1349. * This is used when a question is changed and old student
  1350. * responses need to be marked with the new version of a question.
  1351. *
  1352. * @todo Make sure this is not quiz-specific
  1353. *
  1354. * @global object
  1355. * @return boolean Indicates whether the grade has changed
  1356. * @param object $question A question object
  1357. * @param object $attempt The attempt, in which the question needs to be regraded.
  1358. * @param object $cmoptions
  1359. * @param boolean $verbose Optional. Whether to print progress information or not.
  1360. * @param boolean $dryrun Optional. Whether to make changes to grades records
  1361. * or record that changes need to be made for a later regrade.
  1362. */
  1363. function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false, $dryrun=false) {
  1364. global $DB, $OUTPUT;
  1365. // load all states for this question in this attempt, ordered in sequence
  1366. if ($states = $DB->get_records('question_states',
  1367. array('attempt'=>$attempt->uniqueid, 'question'=>$question->id),
  1368. 'seq_number ASC')) {
  1369. $states = array_values($states);
  1370. // Subtract the grade for the latest state from $attempt->sumgrades to get the
  1371. // sumgrades for the attempt without this question.
  1372. $attempt->sumgrades -= $states[count($states)-1]->grade;
  1373. // Initialise the replaystate
  1374. $replaystate = question_load_specific_state($question, $cmoptions, $attempt->uniqueid, $states[0]->id);
  1375. $replaystate->sumpenalty = 0;
  1376. $replaystate->last_graded->sumpenalty = 0;
  1377. $changed = false;
  1378. for($j = 1; $j < count($states); $j++) {
  1379. restore_question_state($question, $states[$j]);
  1380. $action = new stdClass;
  1381. $action->responses = $states[$j]->responses;
  1382. $action->timestamp = $states[$j]->timestamp;
  1383. // Change event to submit so that it will be reprocessed
  1384. if (in_array($states[$j]->event, array(QUESTION_EVENTCLOSE,
  1385. QUESTION_EVENTGRADE, QUESTION_EVENTCLOSEANDGRADE))) {
  1386. $action->event = QUESTION_EVENTSUBMIT;
  1387. // By default take the event that was saved in the database
  1388. } else {
  1389. $action->event = $states[$j]->event;
  1390. }
  1391. if ($action->event == QUESTION_EVENTMANUALGRADE) {
  1392. // Ensure that the grade is in range - in the past this was not checked,
  1393. // but now it is (MDL-14835) - so we need to ensure the data is valid before
  1394. // proceeding.
  1395. if ($states[$j]->grade < 0) {
  1396. $states[$j]->grade = 0;
  1397. $changed = true;
  1398. } else if ($states[$j]->grade > $question->maxgrade) {
  1399. $states[$j]->grade = $question->maxgrade;
  1400. $changed = true;
  1401. }
  1402. if (!$dryrun){
  1403. $error = question_process_comment($question, $replaystate, $attempt,
  1404. $replaystate->manualcomment, $replaystate->manualcommentformat, $states[$j]->grade);
  1405. if (is_string($error)) {
  1406. echo $OUTPUT->notification($error);
  1407. }
  1408. } else {
  1409. $replaystate->grade = $states[$j]->grade;
  1410. }
  1411. } else {
  1412. // Reprocess (regrade) responses
  1413. if (!question_process_responses($question, $replaystate,
  1414. $action, $cmoptions, $attempt) && $verbose) {
  1415. $a = new stdClass;
  1416. $a->qid = $question->id;
  1417. $a->stateid = $states[$j]->id;
  1418. echo $OUTPUT->notification(get_string('errorduringregrade', 'question', $a));
  1419. }
  1420. // We need rounding here because grades in the DB get truncated
  1421. // e.g. 0.33333 != 0.3333333, but we want them to be equal here
  1422. if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
  1423. or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
  1424. or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
  1425. $changed = true;
  1426. }
  1427. // If this was previously a closed state, and it has been knoced back to
  1428. // graded, then fix up the state again.
  1429. if ($replaystate->event == QUESTION_EVENTGRADE &&
  1430. ($states[$j]->event == QUESTION_EVENTCLOSE ||
  1431. $states[$j]->event == QUESTION_EVENTCLOSEANDGRADE)) {
  1432. $replaystate->event = $states[$j]->event;
  1433. }
  1434. }
  1435. $replaystate->id = $states[$j]->id;
  1436. $replaystate->changed = true;
  1437. $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
  1438. if (!$dryrun){
  1439. save_question_session($question, $replaystate);
  1440. }
  1441. }
  1442. if ($changed) {
  1443. if (!$dryrun){
  1444. // TODO, call a method in quiz to do this, where 'quiz' comes from
  1445. // the question_attempts table.
  1446. $DB->update_record('quiz_attempts', $attempt);
  1447. }
  1448. }
  1449. if ($changed){
  1450. $toinsert = new stdClass();
  1451. $toinsert->oldgrade = round((float)$states[count($states)-1]->grade, 5);
  1452. $toinsert->newgrade = round((float)$replaystate->grade, 5);
  1453. $toinsert->attemptid = $attempt->uniqueid;
  1454. $toinsert->questionid = $question->id;
  1455. //the grade saved is the old grade if the new grade is saved
  1456. //it is the new grade if this is a dry run.
  1457. $toinsert->regraded = $dryrun?0:1;
  1458. $toinsert->timemodified = time();
  1459. $DB->insert_record('quiz_question_regrade', $toinsert);
  1460. return true;
  1461. } else {
  1462. return false;
  1463. }
  1464. }
  1465. return false;
  1466. }
  1467. /**
  1468. * Processes an array of student responses, grading and saving them as appropriate
  1469. *
  1470. * @global array
  1471. * @param object $question Full question object, passed by reference
  1472. * @param object $state Full state object, passed by reference
  1473. * @param object $action object with the fields ->responses which
  1474. * is an array holding the student responses,
  1475. * ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
  1476. * and ->timestamp which is a timestamp from when the responses
  1477. * were submitted by the student.
  1478. * @param object $cmoptions
  1479. * @param object $attempt The attempt is passed by reference so that
  1480. * during grading its ->sumgrades field can be updated
  1481. * @return boolean Indicates success/failure
  1482. */
  1483. function question_process_responses($question, &$state, $action, $cmoptions, &$attempt) {
  1484. global $QTYPES;
  1485. // if no responses are set initialise to empty response
  1486. if (!isset($action->responses)) {
  1487. $action->responses = array('' => '');
  1488. }
  1489. $state->newflaggedstate = !empty($action->responses['_flagged']);
  1490. // make sure these are gone!
  1491. unset($action->responses['submit'], $action->responses['validate'], $action->responses['_flagged']);
  1492. // Check the question session is still open
  1493. if (question_state_is_closed($state)) {
  1494. return true;
  1495. }
  1496. // If $action->event is not set that implies saving
  1497. if (! isset($action->event)) {
  1498. debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER);
  1499. $action->event = QUESTION_EVENTSAVE;
  1500. }
  1501. // If submitted then compare against last graded
  1502. // responses, not last given responses in this case
  1503. if (question_isgradingevent($action->event)) {
  1504. $state->responses = $state->last_graded->responses;
  1505. }
  1506. // Check for unchanged responses (exactly unchanged, not equivalent).
  1507. // We also have to catch questions that the student has not yet attempted
  1508. $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state);
  1509. if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN &&
  1510. question_isgradingevent($action->event)) {
  1511. $sameresponses = false;
  1512. }
  1513. // If the response has not been changed then we do not have to process it again
  1514. // unless the attempt is closing or validation is requested
  1515. if ($sameresponses and QUESTION_EVENTCLOSE != $action->event
  1516. and QUESTION_EVENTVALIDATE != $action->event) {
  1517. return true;
  1518. }
  1519. // Roll back grading information to last graded state and set the new
  1520. // responses
  1521. $newstate = clone($state->last_graded);
  1522. $newstate->responses = $action->responses;
  1523. $newstate->seq_number = $state->seq_number + 1;
  1524. $newstate->changed = true; // will assure that it gets saved to the database
  1525. $newstate->last_graded = clone($state->last_graded);
  1526. $newstate->timestamp = $action->timestamp;
  1527. $newstate->newflaggedstate = $state->newflaggedstate;
  1528. $newstate->flagged = $state->flagged;
  1529. $newstate->questionsessionid = $state->questionsessionid;
  1530. $state = $newstate;
  1531. // Set the event to the action we will perform. The question type specific
  1532. // grading code may override this by setting it to QUESTION_EVENTCLOSE if the
  1533. // attempt at the question causes the session to close
  1534. $state->event = $action->event;
  1535. if (!question_isgradingevent($action->event)) {
  1536. // Grade the response but don't update the overall grade
  1537. if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
  1538. return false;
  1539. }
  1540. // Temporary hack because question types are not given enough control over what is going
  1541. // on. Used by Opaque questions.
  1542. // TODO fix this code properly.
  1543. if (!empty($state->believeevent)) {
  1544. // If the state was graded we need to ...
  1545. if (question_state_is_graded($state)) {
  1546. question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
  1547. // update the attempt grade
  1548. $attempt->sumgrades -= (float)$state->last_graded->grade;
  1549. $attempt->sumgrades += (float)$state->grade;
  1550. // and update the last_graded field.
  1551. unset($state->last_graded);
  1552. $state->last_graded = clone($state);
  1553. unset($state->last_graded->changed);
  1554. }
  1555. } else {
  1556. // Don't allow the processing to change the event type
  1557. $state->event = $action->event;
  1558. }
  1559. } else { // grading event
  1560. // Unless the attempt is closing, we want to work out if the current responses
  1561. // (or equivalent responses) were already given in the last graded attempt.
  1562. if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event &&
  1563. $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) {
  1564. $state->event = QUESTION_EVENTDUPLICATE;
  1565. }
  1566. // If we did not find a duplicate or if the attempt is closing, perform grading
  1567. if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or
  1568. QUESTION_EVENTCLOSE == $action->event) {
  1569. if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) {
  1570. return false;
  1571. }
  1572. // Calculate overall grade using correct penalty method
  1573. question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions);
  1574. }
  1575. // If the state was graded we need to ...
  1576. if (question_state_is_graded($state)) {
  1577. // update the attempt grade
  1578. $attempt->sumgrades -= (float)$state->last_graded->grade;
  1579. $attempt->sumgrades += (float)$state->grade;
  1580. // and update the last_graded field.
  1581. unset($state->last_graded);
  1582. $state->last_graded = clone($state);
  1583. unset($state->last_graded->changed);
  1584. }
  1585. }
  1586. $attempt->timemodified = $action->timestamp;
  1587. return true;
  1588. }
  1589. /**
  1590. * Determine if event requires grading
  1591. */
  1592. function question_isgradingevent($event) {
  1593. return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event);
  1594. }
  1595. /**
  1596. * Applies the penalty from the previous graded responses to the raw grade
  1597. * for the current responses
  1598. *
  1599. * The grade for the question in the current state is computed by subtracting the
  1600. * penalty accumulated over the previous graded responses at the question from the
  1601. * raw grade. If the timestamp is more than 1 minute beyond the end of the attempt
  1602. * the grade is set to zero. The ->grade field of the state object is modified to
  1603. * reflect the new grade but is never allowed to decrease.
  1604. * @param object $question The question for which the penalty is to be applied.
  1605. * @param object $state The state for which the grade is to be set from the
  1606. * raw grade and the cumulative penalty from the last
  1607. * graded state. The ->grade field is updated by applying
  1608. * the penalty scheme determined in $cmoptions to the ->raw_grade and
  1609. * ->last_graded->penalty fields.
  1610. * @param object $cmoptions The options set by the course module.
  1611. * The ->penaltyscheme field determines whether penalties
  1612. * for incorrect earlier responses are subtracted.
  1613. */
  1614. function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) {
  1615. // TODO. Quiz dependancy. The fact that the attempt that is passed in here
  1616. // is from quiz_attempts, and we use things like $cmoptions->timelimit.
  1617. // deal with penalty
  1618. if ($cmoptions->penaltyscheme) {
  1619. $state->grade = $state->raw_grade - $state->sumpenalty;
  1620. $state->sumpenalty += (float) $state->penalty;
  1621. } else {
  1622. $state->grade = $state->raw_grade;
  1623. }
  1624. // deal with timelimit
  1625. if ($cmoptions->timelimit) {
  1626. // We allow for 5% uncertainty in the following test
  1627. if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 1.05) {
  1628. $cm = get_coursemodule_from_instance('quiz', $cmoptions->id);
  1629. if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id),
  1630. $attempt->userid, false)) {
  1631. $state->grade = 0;
  1632. }
  1633. }
  1634. }
  1635. // deal with closing time
  1636. if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness
  1637. and !$attempt->preview) { // ignore closing time for previews
  1638. $state->grade = 0;
  1639. }
  1640. // Ensure that the grade does not go down
  1641. $state->grade = max($state->grade, $state->last_graded->grade);
  1642. }
  1643. /**
  1644. * Print the icon for the question type
  1645. *
  1646. * @global array
  1647. * @global object
  1648. * @param object $question The question object for which the icon is required
  1649. * only $question->qtype is used.
  1650. * @param boolean $return If true the functions returns the link as a string
  1651. */
  1652. function print_question_icon($question, $return = false) {
  1653. global $QTYPES, $CFG, $OUTPUT;
  1654. if (array_key_exists($question->qtype, $QTYPES)) {
  1655. $namestr = $QTYPES[$question->qtype]->local_name();
  1656. } else {
  1657. $namestr = 'missingtype';
  1658. }
  1659. $html = '<img src="' . $OUTPUT->pix_url('icon', 'qtype_'.$question->qtype) . '" alt="' .
  1660. $namestr . '" title="' . $namestr . '" />';
  1661. if ($return) {
  1662. return $html;
  1663. } else {
  1664. echo $html;
  1665. }
  1666. }
  1667. /**
  1668. * @param $question
  1669. * @param $state
  1670. * @param $prefix
  1671. * @param $cmoptions
  1672. * @param $caption
  1673. */
  1674. function question_print_comment_fields($question, $state, $prefix, $cmoptions, $caption = '') {
  1675. global $QTYPES;
  1676. $idprefix = preg_replace('/[^-_a-zA-Z0-9]/', '', $prefix);
  1677. $otherquestionsinuse = '';
  1678. if (!empty($cmoptions->questions)) {
  1679. $otherquestionsinuse = $cmoptions->questions;
  1680. }
  1681. if (!question_state_is_graded($state) && $QTYPES[$question->qtype]->is_question_manual_graded($question, $otherquestionsinuse)) {
  1682. $grade = '';
  1683. } else {
  1684. $grade = question_format_grade($cmoptions, $state->last_graded->grade);
  1685. }
  1686. $maxgrade = question_format_grade($cmoptions, $question->maxgrade);
  1687. $fieldsize = strlen($maxgrade) - 1;
  1688. if (empty($caption)) {
  1689. $caption = format_string($question->name);
  1690. }
  1691. ?>
  1692. <fieldset class="que comment clearfix">
  1693. <legend class="ftoggler"><?php echo $caption; ?></legend>
  1694. <div class="fcontainer clearfix">
  1695. <div class="fitem">
  1696. <div class="fitemtitle">
  1697. <label for="<?php echo $idprefix; ?>_comment_box"><?php print_string('comment', 'quiz'); ?></label>
  1698. </div>
  1699. <div class="felement fhtmleditor">
  1700. <?php print_textarea(can_use_html_editor(), 15, 60, 630, 300, $prefix . '[comment]',
  1701. $state->manualcomment, 0, false, $idprefix . '_comment_box'); ?>
  1702. </div>
  1703. </div>
  1704. <div class="fitem">
  1705. <div class="fitemtitle">
  1706. <label for="<?php echo $idprefix; ?>_grade_field"><?php print_string('grade', 'quiz'); ?></label>
  1707. </div>
  1708. <div class="felement ftext">
  1709. <input type="text" name="<?php echo $prefix; ?>[grade]" size="<?php echo $fieldsize; ?>" id="<?php echo $idprefix; ?>_grade_field" value="<?php echo $grade; ?>" /> / <?php echo $maxgrade; ?>
  1710. </div>
  1711. </div>
  1712. </div>
  1713. </fieldset>
  1714. <?php
  1715. }
  1716. /**
  1717. * Process a manual grading action. That is, use $comment and $grade to update
  1718. * $state and $attempt. The attempt and the comment text are stored in the
  1719. * database. $state is only updated in memory, it is up to the call to store
  1720. * that, if appropriate.
  1721. *
  1722. * @global object
  1723. * @param object $question the question
  1724. * @param object $state the state to be updated.
  1725. * @param object $attempt the attempt the state belongs to, to be updated.
  1726. * @param string $comment the new comment from the teacher.
  1727. * @param mixed $grade the grade the teacher assigned, or '' to not change the grade.
  1728. * @return mixed true on success, a string error message if a problem is detected
  1729. * (for example score out of range).
  1730. */
  1731. function question_process_comment($question, &$state, &$attempt, $comment, $commentformat, $grade) {
  1732. global $DB;
  1733. $grade = trim($grade);
  1734. if ($grade < 0 || $grade > $question->maxgrade) {
  1735. $a = new stdClass;
  1736. $a->grade = $grade;
  1737. $a->maxgrade = $question->maxgrade;
  1738. $a->name = $question->name;
  1739. return get_string('errormanualgradeoutofrange', 'question', $a);
  1740. }
  1741. // Update the comment and save it in the database
  1742. $comment = trim($comment);
  1743. $state->manualcomment = $comment;
  1744. $state->manualcommentformat = $commentformat;
  1745. $state->newflaggedstate = $state->flagged;
  1746. $DB->set_field('question_sessions', 'manualcomment', $comment, array('attemptid'=>$attempt->uniqueid, 'questionid'=>$question->id));
  1747. // Update the attempt if the score has changed.
  1748. if ($grade !== '' && (abs($state->last_graded->grade - $grade) > 0.002 || $state->last_graded->event != QUESTION_EVENTMANUALGRADE)) {
  1749. $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade;
  1750. $attempt->timemodified = time();
  1751. $DB->update_record('quiz_attempts', $attempt);
  1752. // We want to update existing state (rather than creating new one) if it
  1753. // was itself created by a manual grading event.
  1754. $state->update = $state->event == QUESTION_EVENTMANUALGRADE;
  1755. // Update the other parts of the state object.
  1756. $state->raw_grade = $grade;
  1757. $state->grade = $grade;
  1758. $state->penalty = 0;
  1759. $state->timestamp = time();
  1760. $state->seq_number++;
  1761. $state->event = QUESTION_EVENTMANUALGRADE;
  1762. // Update the last graded state (don't simplify!)
  1763. unset($state->last_graded);
  1764. $state->last_graded = clone($state);
  1765. // We need to indicate that the state has changed in order for it to be saved.
  1766. $state->changed = 1;
  1767. }
  1768. return true;
  1769. }
  1770. /**
  1771. * Construct name prefixes for question form element names
  1772. *
  1773. * Construct the name prefix that should be used for example in the
  1774. * names of form elements created by questions.
  1775. * This is called by {@link get_question_options()}
  1776. * to set $question->name_prefix.
  1777. * This name prefix includes the question id which can be
  1778. * extracted from it with {@link question_get_id_from_name_prefix()}.
  1779. *
  1780. * @return string
  1781. * @param integer $id The question id
  1782. */
  1783. function question_make_name_prefix($id) {
  1784. return 'resp' . $id . '_';
  1785. }
  1786. /**
  1787. * Extract question id from the prefix of form element names
  1788. *
  1789. * @return integer The question id
  1790. * @param string $name The name that contains a prefix that was
  1791. * constructed with {@link question_make_name_prefix()}
  1792. */
  1793. function question_get_id_from_name_prefix($name) {
  1794. if (!preg_match('/^resp([0-9]+)_/', $name, $matches)) {
  1795. return false;
  1796. }
  1797. return (integer) $matches[1];
  1798. }
  1799. /**
  1800. * Extract question id from the prefix of form element names
  1801. *
  1802. * @return integer The question id
  1803. * @param string $name The name that contains a prefix that was
  1804. * constructed with {@link question_make_name_prefix()}
  1805. */
  1806. function question_id_and_key_from_post_name($name) {
  1807. if (!preg_match('/^resp([0-9]+)_(.*)$/', $name, $matches)) {
  1808. return array(false, false);
  1809. }
  1810. return array((integer) $matches[1], $matches[2]);
  1811. }
  1812. /**
  1813. * Returns the unique id for a new attempt
  1814. *
  1815. * Every module can keep their own attempts table with their own sequential ids but
  1816. * the question code needs to also have a unique id by which to identify all these
  1817. * attempts. Hence a module, when creating a new attempt, calls this function and
  1818. * stores the return value in the 'uniqueid' field of its attempts table.
  1819. *
  1820. * @global object
  1821. */
  1822. function question_new_attempt_uniqueid($modulename='quiz') {
  1823. global $DB;
  1824. $attempt = new stdClass;
  1825. $attempt->modulename = $modulename;
  1826. $id = $DB->insert_record('question_attempts', $attempt);
  1827. return $id;
  1828. }
  1829. /**
  1830. * Creates a stamp that uniquely identifies this version of the question
  1831. *
  1832. * In future we want this to use a hash of the question data to guarantee that
  1833. * identical versions have the same version stamp.
  1834. *
  1835. * @param object $question
  1836. * @return string A unique version stamp
  1837. */
  1838. function question_hash($question) {
  1839. return make_unique_id_code();
  1840. }
  1841. /**
  1842. * Round a grade to to the correct number of decimal places, and format it for display.
  1843. * If $cmoptions->questiondecimalpoints is set, that is used, otherwise
  1844. * else if $cmoptions->decimalpoints is used,
  1845. * otherwise a default of 2 is used, but this should not be relied upon, and generated a developer debug warning.
  1846. * However, if $cmoptions->questiondecimalpoints is -1, the means use $cmoptions->decimalpoints.
  1847. *
  1848. * @param object $cmoptions The modules settings.
  1849. * @param float $grade The grade to round.
  1850. */
  1851. function question_format_grade($cmoptions, $grade) {
  1852. if (isset($cmoptions->questiondecimalpoints) && $cmoptions->questiondecimalpoints != -1) {
  1853. $decimalplaces = $cmoptions->questiondecimalpoints;
  1854. } else if (isset($cmoptions->decimalpoints)) {
  1855. $decimalplaces = $cmoptions->decimalpoints;
  1856. } else {
  1857. $decimalplaces = 2;
  1858. debugging('Code that leads to question_format_grade being called should set ' .
  1859. '$cmoptions->questiondecimalpoints or $cmoptions->decimalpoints', DEBUG_DEVELOPER);
  1860. }
  1861. return format_float($grade, $decimalplaces);
  1862. }
  1863. /**
  1864. * @return string An inline script that creates a JavaScript object storing
  1865. * various strings and bits of configuration that the scripts in qengine.js need
  1866. * to get from PHP.
  1867. */
  1868. function question_init_qengine_js() {
  1869. global $CFG, $PAGE, $OUTPUT;
  1870. static $done = false;
  1871. if ($done) {
  1872. return;
  1873. }
  1874. $module = array(
  1875. 'name' => 'core_question_flags',
  1876. 'fullpath' => '/question/flags.js',
  1877. 'requires' => array('base', 'dom', 'event-delegate', 'io-base'),
  1878. );
  1879. $actionurl = $CFG->wwwroot . '/question/toggleflag.php';
  1880. $flagattributes = array(
  1881. 0 => array(
  1882. 'src' => $OUTPUT->pix_url('i/unflagged') . '',
  1883. 'title' => get_string('clicktoflag', 'question'),
  1884. 'alt' => get_string('notflagged', 'question'),
  1885. ),
  1886. 1 => array(
  1887. 'src' => $OUTPUT->pix_url('i/flagged') . '',
  1888. 'title' => get_string('clicktounflag', 'question'),
  1889. 'alt' => get_string('flagged', 'question'),
  1890. ),
  1891. );
  1892. $PAGE->requires->js_init_call('M.core_question_flags.init',
  1893. array($actionurl, $flagattributes), false, $module);
  1894. $done = true;
  1895. }
  1896. /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
  1897. /**
  1898. * Give the questions in $questionlist a chance to request the CSS or JavaScript
  1899. * they need, before the header is printed.
  1900. *
  1901. * If your code is going to call the print_question function, it must call this
  1902. * funciton before print_header.
  1903. *
  1904. * @param array $questionlist a list of questionids of the questions what will appear on this page.
  1905. * @param array $questions an array of question objects, whose keys are question ids.
  1906. * Must contain all the questions in $questionlist
  1907. * @param array $states an array of question state objects, whose keys are question ids.
  1908. * Must contain the state of all the questions in $questionlist
  1909. */
  1910. function question_get_html_head_contributions($questionlist, &$questions, &$states) {
  1911. global $CFG, $PAGE, $QTYPES;
  1912. // The question engine's own JavaScript.
  1913. question_init_qengine_js();
  1914. // Anything that questions on this page need.
  1915. foreach ($questionlist as $questionid) {
  1916. $question = $questions[$questionid];
  1917. $QTYPES[$question->qtype]->get_html_head_contributions($question, $states[$questionid]);
  1918. }
  1919. }
  1920. /**
  1921. * Like {@link get_html_head_contributions()} but for the editing page
  1922. * question/question.php.
  1923. *
  1924. * @param $question A question object. Only $question->qtype is used.
  1925. * @return string Deprecated. Some HTML code that can go inside the head tag.
  1926. */
  1927. function question_get_editing_head_contributions($question) {
  1928. global $QTYPES;
  1929. $QTYPES[$question->qtype]->get_editing_head_contributions();
  1930. }
  1931. /**
  1932. * Prints a question
  1933. *
  1934. * Simply calls the question type specific print_question() method.
  1935. *
  1936. * @global array
  1937. * @param object $question The question to be rendered.
  1938. * @param object $state The state to render the question in.
  1939. * @param integer $number The number for this question.
  1940. * @param object $cmoptions The options specified by the course module
  1941. * @param object $options An object specifying the rendering options.
  1942. */
  1943. function print_question(&$question, &$state, $number, $cmoptions, $options=null, $context=null) {
  1944. global $QTYPES;
  1945. $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options, $context);
  1946. }
  1947. /**
  1948. * Saves question options
  1949. *
  1950. * Simply calls the question type specific save_question_options() method.
  1951. *
  1952. * @global array
  1953. */
  1954. function save_question_options($question) {
  1955. global $QTYPES;
  1956. $QTYPES[$question->qtype]->save_question_options($question);
  1957. }
  1958. /**
  1959. * Gets all teacher stored answers for a given question
  1960. *
  1961. * Simply calls the question type specific get_all_responses() method.
  1962. *
  1963. * @global array
  1964. */
  1965. // ULPGC ecastro
  1966. function get_question_responses($question, $state) {
  1967. global $QTYPES;
  1968. $r = $QTYPES[$question->qtype]->get_all_responses($question, $state);
  1969. return $r;
  1970. }
  1971. /**
  1972. * Gets the response given by the user in a particular state
  1973. *
  1974. * Simply calls the question type specific get_actual_response() method.
  1975. *
  1976. * @global array
  1977. */
  1978. // ULPGC ecastro
  1979. function get_question_actual_response($question, $state) {
  1980. global $QTYPES;
  1981. $r = $QTYPES[$question->qtype]->get_actual_response($question, $state);
  1982. return $r;
  1983. }
  1984. /**
  1985. * TODO: document this
  1986. *
  1987. * @global array
  1988. */
  1989. // ULPGc ecastro
  1990. function get_question_fraction_grade($question, $state) {
  1991. global $QTYPES;
  1992. $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state);
  1993. return $r;
  1994. }
  1995. /**
  1996. * @global array
  1997. * @return integer grade out of 1 that a random guess by a student might score.
  1998. */
  1999. // ULPGc ecastro
  2000. function question_get_random_guess_score($question) {
  2001. global $QTYPES;
  2002. $r = $QTYPES[$question->qtype]->get_random_guess_score($question);
  2003. return $r;
  2004. }
  2005. /// CATEGORY FUNCTIONS /////////////////////////////////////////////////////////////////
  2006. /**
  2007. * returns the categories with their names ordered following parent-child relationships
  2008. * finally it tries to return pending categories (those being orphaned, whose parent is
  2009. * incorrect) to avoid missing any category from original array.
  2010. *
  2011. * @global object
  2012. */
  2013. function sort_categories_by_tree(&$categories, $id = 0, $level = 1) {
  2014. global $DB;
  2015. $children = array();
  2016. $keys = array_keys($categories);
  2017. foreach ($keys as $key) {
  2018. if (!isset($categories[$key]->processed) && $categories[$key]->parent == $id) {
  2019. $children[$key] = $categories[$key];
  2020. $categories[$key]->processed = true;
  2021. $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
  2022. }
  2023. }
  2024. //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too
  2025. if ($level == 1) {
  2026. foreach ($keys as $key) {
  2027. // If not processed and it's a good candidate to start (because its parent doesn't exist in the course)
  2028. if (!isset($categories[$key]->processed) && !$DB->record_exists(
  2029. 'question_categories', array('contextid'=>$categories[$key]->contextid, 'id'=>$categories[$key]->parent))) {
  2030. $children[$key] = $categories[$key];
  2031. $categories[$key]->processed = true;
  2032. $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1);
  2033. }
  2034. }
  2035. }
  2036. return $children;
  2037. }
  2038. /**
  2039. * Private method, only for the use of add_indented_names().
  2040. *
  2041. * Recursively adds an indentedname field to each category, starting with the category
  2042. * with id $id, and dealing with that category and all its children, and
  2043. * return a new array, with those categories in the right order.
  2044. *
  2045. * @param array $categories an array of categories which has had childids
  2046. * fields added by flatten_category_tree(). Passed by reference for
  2047. * performance only. It is not modfied.
  2048. * @param int $id the category to start the indenting process from.
  2049. * @param int $depth the indent depth. Used in recursive calls.
  2050. * @return array a new array of categories, in the right order for the tree.
  2051. */
  2052. function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) {
  2053. // Indent the name of this category.
  2054. $newcategories = array();
  2055. $newcategories[$id] = $categories[$id];
  2056. $newcategories[$id]->indentedname = str_repeat('&nbsp;&nbsp;&nbsp;', $depth) . $categories[$id]->name;
  2057. // Recursively indent the children.
  2058. foreach ($categories[$id]->childids as $childid) {
  2059. if ($childid != $nochildrenof){
  2060. $newcategories = $newcategories + flatten_category_tree($categories, $childid, $depth + 1, $nochildrenof);
  2061. }
  2062. }
  2063. // Remove the childids array that were temporarily added.
  2064. unset($newcategories[$id]->childids);
  2065. return $newcategories;
  2066. }
  2067. /**
  2068. * Format categories into an indented list reflecting the tree structure.
  2069. *
  2070. * @param array $categories An array of category objects, for example from the.
  2071. * @return array The formatted list of categories.
  2072. */
  2073. function add_indented_names($categories, $nochildrenof = -1) {
  2074. // Add an array to each category to hold the child category ids. This array will be removed
  2075. // again by flatten_category_tree(). It should not be used outside these two functions.
  2076. foreach (array_keys($categories) as $id) {
  2077. $categories[$id]->childids = array();
  2078. }
  2079. // Build the tree structure, and record which categories are top-level.
  2080. // We have to be careful, because the categories array may include published
  2081. // categories from other courses, but not their parents.
  2082. $toplevelcategoryids = array();
  2083. foreach (array_keys($categories) as $id) {
  2084. if (!empty($categories[$id]->parent) && array_key_exists($categories[$id]->parent, $categories)) {
  2085. $categories[$categories[$id]->parent]->childids[] = $id;
  2086. } else {
  2087. $toplevelcategoryids[] = $id;
  2088. }
  2089. }
  2090. // Flatten the tree to and add the indents.
  2091. $newcategories = array();
  2092. foreach ($toplevelcategoryids as $id) {
  2093. $newcategories = $newcategories + flatten_category_tree($categories, $id, 0, $nochildrenof);
  2094. }
  2095. return $newcategories;
  2096. }
  2097. /**
  2098. * Output a select menu of question categories.
  2099. *
  2100. * Categories from this course and (optionally) published categories from other courses
  2101. * are included. Optionally, only categories the current user may edit can be included.
  2102. *
  2103. * @param integer $courseid the id of the course to get the categories for.
  2104. * @param integer $published if true, include publised categories from other courses.
  2105. * @param integer $only_editable if true, exclude categories this user is not allowed to edit.
  2106. * @param integer $selected optionally, the id of a category to be selected by default in the dropdown.
  2107. */
  2108. function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) {
  2109. global $OUTPUT;
  2110. $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof);
  2111. if ($selected) {
  2112. $choose = '';
  2113. } else {
  2114. $choose = 'choosedots';
  2115. }
  2116. $options = array();
  2117. foreach($categoriesarray as $group=>$opts) {
  2118. $options[] = array($group=>$opts);
  2119. }
  2120. echo html_writer::select($options, 'category', $selected, $choose);
  2121. }
  2122. /**
  2123. * @global object
  2124. * @param integer $contextid a context id.
  2125. * @return object the default question category for that context, or false if none.
  2126. */
  2127. function question_get_default_category($contextid) {
  2128. global $DB;
  2129. $category = $DB->get_records('question_categories', array('contextid' => $contextid),'id','*',0,1);
  2130. if (!empty($category)) {
  2131. return reset($category);
  2132. } else {
  2133. return false;
  2134. }
  2135. }
  2136. /**
  2137. * @global object
  2138. * @global object
  2139. * @param object $context a context
  2140. * @return string A URL for editing questions in this context.
  2141. */
  2142. function question_edit_url($context) {
  2143. global $CFG, $SITE;
  2144. if (!has_any_capability(question_get_question_capabilities(), $context)) {
  2145. return false;
  2146. }
  2147. $baseurl = $CFG->wwwroot . '/question/edit.php?';
  2148. $defaultcategory = question_get_default_category($context->id);
  2149. if ($defaultcategory) {
  2150. $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&amp;';
  2151. }
  2152. switch ($context->contextlevel) {
  2153. case CONTEXT_SYSTEM:
  2154. return $baseurl . 'courseid=' . $SITE->id;
  2155. case CONTEXT_COURSECAT:
  2156. // This is nasty, becuase we can only edit questions in a course
  2157. // context at the moment, so for now we just return false.
  2158. return false;
  2159. case CONTEXT_COURSE:
  2160. return $baseurl . 'courseid=' . $context->instanceid;
  2161. case CONTEXT_MODULE:
  2162. return $baseurl . 'cmid=' . $context->instanceid;
  2163. }
  2164. }
  2165. /**
  2166. * Gets the default category in the most specific context.
  2167. * If no categories exist yet then default ones are created in all contexts.
  2168. *
  2169. * @global object
  2170. * @param array $contexts The context objects for this context and all parent contexts.
  2171. * @return object The default category - the category in the course context
  2172. */
  2173. function question_make_default_categories($contexts) {
  2174. global $DB;
  2175. static $preferredlevels = array(
  2176. CONTEXT_COURSE => 4,
  2177. CONTEXT_MODULE => 3,
  2178. CONTEXT_COURSECAT => 2,
  2179. CONTEXT_SYSTEM => 1,
  2180. );
  2181. $toreturn = null;
  2182. $preferredness = 0;
  2183. // If it already exists, just return it.
  2184. foreach ($contexts as $key => $context) {
  2185. if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))) {
  2186. // Otherwise, we need to make one
  2187. $category = new stdClass;
  2188. $contextname = print_context_name($context, false, true);
  2189. $category->name = get_string('defaultfor', 'question', $contextname);
  2190. $category->info = get_string('defaultinfofor', 'question', $contextname);
  2191. $category->contextid = $context->id;
  2192. $category->parent = 0;
  2193. $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically.
  2194. $category->stamp = make_unique_id_code();
  2195. $category->id = $DB->insert_record('question_categories', $category);
  2196. } else {
  2197. $category = question_get_default_category($context->id);
  2198. }
  2199. if ($preferredlevels[$context->contextlevel] > $preferredness &&
  2200. has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) {
  2201. $toreturn = $category;
  2202. $preferredness = $preferredlevels[$context->contextlevel];
  2203. }
  2204. }
  2205. if (!is_null($toreturn)) {
  2206. $toreturn = clone($toreturn);
  2207. }
  2208. return $toreturn;
  2209. }
  2210. /**
  2211. * Get all the category objects, including a count of the number of questions in that category,
  2212. * for all the categories in the lists $contexts.
  2213. *
  2214. * @global object
  2215. * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
  2216. * @param string $sortorder used as the ORDER BY clause in the select statement.
  2217. * @return array of category objects.
  2218. */
  2219. function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
  2220. global $DB;
  2221. return $DB->get_records_sql("
  2222. SELECT c.*, (SELECT count(1) FROM {question} q
  2223. WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
  2224. FROM {question_categories} c
  2225. WHERE c.contextid IN ($contexts)
  2226. ORDER BY $sortorder");
  2227. }
  2228. /**
  2229. * Output an array of question categories.
  2230. * @global object
  2231. */
  2232. function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) {
  2233. global $CFG;
  2234. $pcontexts = array();
  2235. foreach($contexts as $context){
  2236. $pcontexts[] = $context->id;
  2237. }
  2238. $contextslist = join($pcontexts, ', ');
  2239. $categories = get_categories_for_contexts($contextslist);
  2240. $categories = question_add_context_in_key($categories);
  2241. if ($top){
  2242. $categories = question_add_tops($categories, $pcontexts);
  2243. }
  2244. $categories = add_indented_names($categories, $nochildrenof);
  2245. //sort cats out into different contexts
  2246. $categoriesarray = array();
  2247. foreach ($pcontexts as $pcontext){
  2248. $contextstring = print_context_name(get_context_instance_by_id($pcontext), true, true);
  2249. foreach ($categories as $category) {
  2250. if ($category->contextid == $pcontext){
  2251. $cid = $category->id;
  2252. if ($currentcat!= $cid || $currentcat==0) {
  2253. $countstring = (!empty($category->questioncount))?" ($category->questioncount)":'';
  2254. $categoriesarray[$contextstring][$cid] = $category->indentedname.$countstring;
  2255. }
  2256. }
  2257. }
  2258. }
  2259. if ($popupform){
  2260. $popupcats = array();
  2261. foreach ($categoriesarray as $contextstring => $optgroup){
  2262. $group = array();
  2263. foreach ($optgroup as $key=>$value) {
  2264. $key = str_replace($CFG->wwwroot, '', $key);
  2265. $group[$key] = $value;
  2266. }
  2267. $popupcats[] = array($contextstring=>$group);
  2268. }
  2269. return $popupcats;
  2270. } else {
  2271. return $categoriesarray;
  2272. }
  2273. }
  2274. function question_add_context_in_key($categories){
  2275. $newcatarray = array();
  2276. foreach ($categories as $id => $category) {
  2277. $category->parent = "$category->parent,$category->contextid";
  2278. $category->id = "$category->id,$category->contextid";
  2279. $newcatarray["$id,$category->contextid"] = $category;
  2280. }
  2281. return $newcatarray;
  2282. }
  2283. function question_add_tops($categories, $pcontexts){
  2284. $topcats = array();
  2285. foreach ($pcontexts as $context){
  2286. $newcat = new stdClass();
  2287. $newcat->id = "0,$context";
  2288. $newcat->name = get_string('top');
  2289. $newcat->parent = -1;
  2290. $newcat->contextid = $context;
  2291. $topcats["0,$context"] = $newcat;
  2292. }
  2293. //put topcats in at beginning of array - they'll be sorted into different contexts later.
  2294. return array_merge($topcats, $categories);
  2295. }
  2296. /**
  2297. * Returns a comma separated list of ids of the category and all subcategories
  2298. * @global object
  2299. */
  2300. function question_categorylist($categoryid) {
  2301. global $DB;
  2302. // returns a comma separated list of ids of the category and all subcategories
  2303. $categorylist = $categoryid;
  2304. if ($subcategories = $DB->get_records('question_categories', array('parent'=>$categoryid), 'sortorder ASC', 'id, 1')) {
  2305. foreach ($subcategories as $subcategory) {
  2306. $categorylist .= ','. question_categorylist($subcategory->id);
  2307. }
  2308. }
  2309. return $categorylist;
  2310. }
  2311. //===========================
  2312. // Import/Export Functions
  2313. //===========================
  2314. /**
  2315. * Get list of available import or export formats
  2316. *
  2317. * @global object
  2318. * @param string $type 'import' if import list, otherwise export list assumed
  2319. * @return array sorted list of import/export formats available
  2320. */
  2321. function get_import_export_formats( $type ) {
  2322. global $CFG;
  2323. $fileformats = get_plugin_list("qformat");
  2324. $fileformatname=array();
  2325. require_once( "{$CFG->dirroot}/question/format.php" );
  2326. foreach ($fileformats as $fileformat=>$fdir) {
  2327. $format_file = "$fdir/format.php";
  2328. if (file_exists($format_file) ) {
  2329. require_once($format_file);
  2330. }
  2331. else {
  2332. continue;
  2333. }
  2334. $classname = "qformat_$fileformat";
  2335. $format_class = new $classname();
  2336. if ($type=='import') {
  2337. $provided = $format_class->provide_import();
  2338. }
  2339. else {
  2340. $provided = $format_class->provide_export();
  2341. }
  2342. if ($provided) {
  2343. $formatname = get_string($fileformat, 'quiz');
  2344. if ($formatname == "[[$fileformat]]") {
  2345. $formatname = get_string($fileformat, 'qformat_'.$fileformat);
  2346. if ($formatname == "[[$fileformat]]") {
  2347. $formatname = $fileformat; // Just use the raw folder name
  2348. }
  2349. }
  2350. $fileformatnames[$fileformat] = $formatname;
  2351. }
  2352. }
  2353. natcasesort($fileformatnames);
  2354. return $fileformatnames;
  2355. }
  2356. /**
  2357. * Create a reasonable default file name for exporting questions from a particular
  2358. * category.
  2359. * @param object $course the course the questions are in.
  2360. * @param object $category the question category.
  2361. * @return string the filename.
  2362. */
  2363. function question_default_export_filename($course, $category) {
  2364. // We build a string that is an appropriate name (questions) from the lang pack,
  2365. // then the corse shortname, then the question category name, then a timestamp.
  2366. $base = clean_filename(get_string('exportfilename', 'question'));
  2367. $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question'));
  2368. $timestamp = clean_filename(userdate(time(), $dateformat, 99, false));
  2369. $shortname = clean_filename($course->shortname);
  2370. if ($shortname == '' || $shortname == '_' ) {
  2371. $shortname = $course->id;
  2372. }
  2373. $categoryname = clean_filename(format_string($category->name));
  2374. return "{$base}-{$shortname}-{$categoryname}-{$timestamp}";
  2375. return $export_name;
  2376. }
  2377. /**
  2378. * @package moodlecore
  2379. * @subpackage question
  2380. * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
  2381. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  2382. */
  2383. class context_to_string_translator{
  2384. /**
  2385. * @var array used to translate between contextids and strings for this context.
  2386. */
  2387. var $contexttostringarray = array();
  2388. function context_to_string_translator($contexts){
  2389. $this->generate_context_to_string_array($contexts);
  2390. }
  2391. function context_to_string($contextid){
  2392. return $this->contexttostringarray[$contextid];
  2393. }
  2394. function string_to_context($contextname){
  2395. $contextid = array_search($contextname, $this->contexttostringarray);
  2396. return $contextid;
  2397. }
  2398. function generate_context_to_string_array($contexts){
  2399. if (!$this->contexttostringarray){
  2400. $catno = 1;
  2401. foreach ($contexts as $context){
  2402. switch ($context->contextlevel){
  2403. case CONTEXT_MODULE :
  2404. $contextstring = 'module';
  2405. break;
  2406. case CONTEXT_COURSE :
  2407. $contextstring = 'course';
  2408. break;
  2409. case CONTEXT_COURSECAT :
  2410. $contextstring = "cat$catno";
  2411. $catno++;
  2412. break;
  2413. case CONTEXT_SYSTEM :
  2414. $contextstring = 'system';
  2415. break;
  2416. }
  2417. $this->contexttostringarray[$context->id] = $contextstring;
  2418. }
  2419. }
  2420. }
  2421. }
  2422. /**
  2423. * @return array all the capabilities that relate to accessing particular questions.
  2424. */
  2425. function question_get_question_capabilities() {
  2426. return array(
  2427. 'moodle/question:add',
  2428. 'moodle/question:editmine',
  2429. 'moodle/question:editall',
  2430. 'moodle/question:viewmine',
  2431. 'moodle/question:viewall',
  2432. 'moodle/question:usemine',
  2433. 'moodle/question:useall',
  2434. 'moodle/question:movemine',
  2435. 'moodle/question:moveall',
  2436. );
  2437. }
  2438. /**
  2439. * @return array all the question bank capabilities.
  2440. */
  2441. function question_get_all_capabilities() {
  2442. $caps = question_get_question_capabilities();
  2443. $caps[] = 'moodle/question:managecategory';
  2444. $caps[] = 'moodle/question:flag';
  2445. return $caps;
  2446. }
  2447. /**
  2448. * Check capability on category
  2449. *
  2450. * @global object
  2451. * @global object
  2452. * @param mixed $question object or id
  2453. * @param string $cap 'add', 'edit', 'view', 'use', 'move'
  2454. * @param integer $cachecat useful to cache all question records in a category
  2455. * @return boolean this user has the capability $cap for this question $question?
  2456. */
  2457. function question_has_capability_on($question, $cap, $cachecat = -1){
  2458. global $USER, $DB;
  2459. // nicolasconnault@gmail.com In some cases I get $question === false. Since no such object exists, it can't be deleted, we can safely return true
  2460. if ($question === false) {
  2461. return true;
  2462. }
  2463. // these are capabilities on existing questions capabilties are
  2464. //set per category. Each of these has a mine and all version. Append 'mine' and 'all'
  2465. $question_questioncaps = array('edit', 'view', 'use', 'move');
  2466. static $questions = array();
  2467. static $categories = array();
  2468. static $cachedcat = array();
  2469. if ($cachecat != -1 && array_search($cachecat, $cachedcat) === false) {
  2470. $questions += $DB->get_records('question', array('category' => $cachecat));
  2471. $cachedcat[] = $cachecat;
  2472. }
  2473. if (!is_object($question)){
  2474. if (!isset($questions[$question])){
  2475. if (!$questions[$question] = $DB->get_record('question', array('id' => $question), 'id,category,createdby')) {
  2476. print_error('questiondoesnotexist', 'question');
  2477. }
  2478. }
  2479. $question = $questions[$question];
  2480. }
  2481. if (!isset($categories[$question->category])){
  2482. if (!$categories[$question->category] = $DB->get_record('question_categories', array('id'=>$question->category))) {
  2483. print_error('invalidcategory', 'quiz');
  2484. }
  2485. }
  2486. $category = $categories[$question->category];
  2487. $context = get_context_instance_by_id($category->contextid);
  2488. if (array_search($cap, $question_questioncaps)!== FALSE){
  2489. if (!has_capability('moodle/question:'.$cap.'all', $context)){
  2490. if ($question->createdby == $USER->id){
  2491. return has_capability('moodle/question:'.$cap.'mine', $context);
  2492. } else {
  2493. return false;
  2494. }
  2495. } else {
  2496. return true;
  2497. }
  2498. } else {
  2499. return has_capability('moodle/question:'.$cap, $context);
  2500. }
  2501. }
  2502. /**
  2503. * Require capability on question.
  2504. */
  2505. function question_require_capability_on($question, $cap){
  2506. if (!question_has_capability_on($question, $cap)){
  2507. print_error('nopermissions', '', '', $cap);
  2508. }
  2509. return true;
  2510. }
  2511. /**
  2512. * Get the real state - the correct question id and answer - for a random
  2513. * question.
  2514. * @param object $state with property answer.
  2515. * @return mixed return integer real question id or false if there was an
  2516. * error..
  2517. */
  2518. function question_get_real_state($state) {
  2519. global $OUTPUT;
  2520. $realstate = clone($state);
  2521. $matches = array();
  2522. if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)){
  2523. echo $OUTPUT->notification(get_string('errorrandom', 'quiz_statistics'));
  2524. return false;
  2525. } else {
  2526. $realstate->question = $matches[1];
  2527. $realstate->answer = $matches[2];
  2528. return $realstate;
  2529. }
  2530. }
  2531. /**
  2532. * Update the flagged state of a particular question session.
  2533. *
  2534. * @global object
  2535. * @param integer $sessionid question_session id.
  2536. * @param boolean $newstate the new state for the flag.
  2537. * @return boolean success or failure.
  2538. */
  2539. function question_update_flag($sessionid, $newstate) {
  2540. global $DB;
  2541. return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid));
  2542. }
  2543. /**
  2544. * Update the flagged state of all the questions in an attempt, where a new .
  2545. *
  2546. * @global object
  2547. * @param integer $sessionid question_session id.
  2548. * @param boolean $newstate the new state for the flag.
  2549. * @return boolean success or failure.
  2550. */
  2551. function question_save_flags($formdata, $attemptid, $questionids) {
  2552. global $DB;
  2553. $donequestionids = array();
  2554. foreach ($formdata as $postvariable => $value) {
  2555. list($qid, $key) = question_id_and_key_from_post_name($postvariable);
  2556. if ($qid !== false && in_array($qid, $questionids)) {
  2557. if ($key == '_flagged') {
  2558. $DB->set_field('question_sessions', 'flagged', !empty($value),
  2559. array('attemptid' => $attemptid, 'questionid' => $qid));
  2560. $donequestionids[$qid] = 1;
  2561. }
  2562. }
  2563. }
  2564. foreach ($questionids as $qid) {
  2565. if (!isset($donequestionids[$qid])) {
  2566. $DB->set_field('question_sessions', 'flagged', 0,
  2567. array('attemptid' => $attemptid, 'questionid' => $qid));
  2568. }
  2569. }
  2570. }
  2571. /**
  2572. *
  2573. * @global object
  2574. * @param integer $attemptid the question_attempt id.
  2575. * @param integer $questionid the question id.
  2576. * @param integer $sessionid the question_session id.
  2577. * @param object $user a user, or null to use $USER.
  2578. * @return string that needs to be sent to question/toggleflag.php for it to work.
  2579. */
  2580. function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) {
  2581. if (is_null($user)) {
  2582. global $USER;
  2583. $user = $USER;
  2584. }
  2585. return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid);
  2586. }
  2587. /**
  2588. * Adds question bank setting links to the given navigation node if caps are met.
  2589. *
  2590. * @param navigation_node $navigationnode The navigation node to add the question branch to
  2591. * @param stdClass $context
  2592. * @return navigation_node Returns the question branch that was added
  2593. */
  2594. function question_extend_settings_navigation(navigation_node $navigationnode, $context) {
  2595. global $PAGE;
  2596. if ($context->contextlevel == CONTEXT_COURSE) {
  2597. $params = array('courseid'=>$context->instanceid);
  2598. } else if ($context->contextlevel == CONTEXT_MODULE) {
  2599. $params = array('cmid'=>$context->instanceid);
  2600. } else {
  2601. return;
  2602. }
  2603. $questionnode = $navigationnode->add(get_string('questionbank','question'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER);
  2604. $contexts = new question_edit_contexts($context);
  2605. if ($contexts->have_one_edit_tab_cap('questions')) {
  2606. $questionnode->add(get_string('questions', 'quiz'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_SETTING);
  2607. }
  2608. if ($contexts->have_one_edit_tab_cap('categories')) {
  2609. $questionnode->add(get_string('categories', 'quiz'), new moodle_url('/question/category.php', $params), navigation_node::TYPE_SETTING);
  2610. }
  2611. if ($contexts->have_one_edit_tab_cap('import')) {
  2612. $questionnode->add(get_string('import', 'quiz'), new moodle_url('/question/import.php', $params), navigation_node::TYPE_SETTING);
  2613. }
  2614. if ($contexts->have_one_edit_tab_cap('export')) {
  2615. $questionnode->add(get_string('export', 'quiz'), new moodle_url('/question/export.php', $params), navigation_node::TYPE_SETTING);
  2616. }
  2617. return $questionnode;
  2618. }
  2619. class question_edit_contexts {
  2620. public static $CAPS = array(
  2621. 'editq' => array('moodle/question:add',
  2622. 'moodle/question:editmine',
  2623. 'moodle/question:editall',
  2624. 'moodle/question:viewmine',
  2625. 'moodle/question:viewall',
  2626. 'moodle/question:usemine',
  2627. 'moodle/question:useall',
  2628. 'moodle/question:movemine',
  2629. 'moodle/question:moveall'),
  2630. 'questions'=>array('moodle/question:add',
  2631. 'moodle/question:editmine',
  2632. 'moodle/question:editall',
  2633. 'moodle/question:viewmine',
  2634. 'moodle/question:viewall',
  2635. 'moodle/question:movemine',
  2636. 'moodle/question:moveall'),
  2637. 'categories'=>array('moodle/question:managecategory'),
  2638. 'import'=>array('moodle/question:add'),
  2639. 'export'=>array('moodle/question:viewall', 'moodle/question:viewmine'));
  2640. protected $allcontexts;
  2641. /**
  2642. * @param current context
  2643. */
  2644. public function question_edit_contexts($thiscontext){
  2645. $pcontextids = get_parent_contexts($thiscontext);
  2646. $contexts = array($thiscontext);
  2647. foreach ($pcontextids as $pcontextid){
  2648. $contexts[] = get_context_instance_by_id($pcontextid);
  2649. }
  2650. $this->allcontexts = $contexts;
  2651. }
  2652. /**
  2653. * @return array all parent contexts
  2654. */
  2655. public function all(){
  2656. return $this->allcontexts;
  2657. }
  2658. /**
  2659. * @return object lowest context which must be either the module or course context
  2660. */
  2661. public function lowest(){
  2662. return $this->allcontexts[0];
  2663. }
  2664. /**
  2665. * @param string $cap capability
  2666. * @return array parent contexts having capability, zero based index
  2667. */
  2668. public function having_cap($cap){
  2669. $contextswithcap = array();
  2670. foreach ($this->allcontexts as $context){
  2671. if (has_capability($cap, $context)){
  2672. $contextswithcap[] = $context;
  2673. }
  2674. }
  2675. return $contextswithcap;
  2676. }
  2677. /**
  2678. * @param array $caps capabilities
  2679. * @return array parent contexts having at least one of $caps, zero based index
  2680. */
  2681. public function having_one_cap($caps){
  2682. $contextswithacap = array();
  2683. foreach ($this->allcontexts as $context){
  2684. foreach ($caps as $cap){
  2685. if (has_capability($cap, $context)){
  2686. $contextswithacap[] = $context;
  2687. break; //done with caps loop
  2688. }
  2689. }
  2690. }
  2691. return $contextswithacap;
  2692. }
  2693. /**
  2694. * @param string $tabname edit tab name
  2695. * @return array parent contexts having at least one of $caps, zero based index
  2696. */
  2697. public function having_one_edit_tab_cap($tabname){
  2698. return $this->having_one_cap(self::$CAPS[$tabname]);
  2699. }
  2700. /**
  2701. * Has at least one parent context got the cap $cap?
  2702. *
  2703. * @param string $cap capability
  2704. * @return boolean
  2705. */
  2706. public function have_cap($cap){
  2707. return (count($this->having_cap($cap)));
  2708. }
  2709. /**
  2710. * Has at least one parent context got one of the caps $caps?
  2711. *
  2712. * @param array $caps capability
  2713. * @return boolean
  2714. */
  2715. public function have_one_cap($caps){
  2716. foreach ($caps as $cap) {
  2717. if ($this->have_cap($cap)) {
  2718. return true;
  2719. }
  2720. }
  2721. return false;
  2722. }
  2723. /**
  2724. * Has at least one parent context got one of the caps for actions on $tabname
  2725. *
  2726. * @param string $tabname edit tab name
  2727. * @return boolean
  2728. */
  2729. public function have_one_edit_tab_cap($tabname){
  2730. return $this->have_one_cap(self::$CAPS[$tabname]);
  2731. }
  2732. /**
  2733. * Throw error if at least one parent context hasn't got the cap $cap
  2734. *
  2735. * @param string $cap capability
  2736. */
  2737. public function require_cap($cap){
  2738. if (!$this->have_cap($cap)){
  2739. print_error('nopermissions', '', '', $cap);
  2740. }
  2741. }
  2742. /**
  2743. * Throw error if at least one parent context hasn't got one of the caps $caps
  2744. *
  2745. * @param array $cap capabilities
  2746. */
  2747. public function require_one_cap($caps) {
  2748. if (!$this->have_one_cap($caps)) {
  2749. $capsstring = join($caps, ', ');
  2750. print_error('nopermissions', '', '', $capsstring);
  2751. }
  2752. }
  2753. /**
  2754. * Throw error if at least one parent context hasn't got one of the caps $caps
  2755. *
  2756. * @param string $tabname edit tab name
  2757. */
  2758. public function require_one_edit_tab_cap($tabname){
  2759. if (!$this->have_one_edit_tab_cap($tabname)) {
  2760. print_error('nopermissions', '', '', 'access question edit tab '.$tabname);
  2761. }
  2762. }
  2763. }
  2764. /**
  2765. * Rewrite question url, file_rewrite_pluginfile_urls always build url by
  2766. * $file/$contextid/$component/$filearea/$itemid/$pathname_in_text, so we cannot add
  2767. * extra questionid and attempted in url by it, so we create quiz_rewrite_question_urls
  2768. * to build url here
  2769. *
  2770. * @param string $text text being processed
  2771. * @param string $file the php script used to serve files
  2772. * @param int $contextid
  2773. * @param string $component component
  2774. * @param string $filearea filearea
  2775. * @param array $ids other IDs will be used to check file permission
  2776. * @param int $itemid
  2777. * @param array $options
  2778. * @return string
  2779. */
  2780. function quiz_rewrite_question_urls($text, $file, $contextid, $component, $filearea, array $ids, $itemid, array $options=null) {
  2781. global $CFG;
  2782. $options = (array)$options;
  2783. if (!isset($options['forcehttps'])) {
  2784. $options['forcehttps'] = false;
  2785. }
  2786. if (!$CFG->slasharguments) {
  2787. $file = $file . '?file=';
  2788. }
  2789. $baseurl = "$CFG->wwwroot/$file/$contextid/$component/$filearea/";
  2790. if (!empty($ids)) {
  2791. $baseurl .= (implode('/', $ids) . '/');
  2792. }
  2793. if ($itemid !== null) {
  2794. $baseurl .= "$itemid/";
  2795. }
  2796. if ($options['forcehttps']) {
  2797. $baseurl = str_replace('http://', 'https://', $baseurl);
  2798. }
  2799. return str_replace('@@PLUGINFILE@@/', $baseurl, $text);
  2800. }
  2801. /**
  2802. * Called by pluginfile.php to serve files related to the 'question' core
  2803. * component and for files belonging to qtypes.
  2804. *
  2805. * For files that relate to questions in a question_attempt, then we delegate to
  2806. * a function in the component that owns the attempt (for example in the quiz,
  2807. * or in core question preview) to get necessary inforation.
  2808. *
  2809. * (Note that, at the moment, all question file areas relate to questions in
  2810. * attempts, so the If at the start of the last paragraph is always true.)
  2811. *
  2812. * Does not return, either calls send_file_not_found(); or serves the file.
  2813. *
  2814. * @param object $course course settings object
  2815. * @param object $context context object
  2816. * @param string $component the name of the component we are serving files for.
  2817. * @param string $filearea the name of the file area.
  2818. * @param array $args the remaining bits of the file path.
  2819. * @param bool $forcedownload whether the user must be forced to download the file.
  2820. */
  2821. function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload) {
  2822. global $DB, $CFG;
  2823. list($context, $course, $cm) = get_context_info_array($context->id);
  2824. require_login($course, false, $cm);
  2825. if ($filearea === 'export') {
  2826. require_once($CFG->dirroot . '/question/editlib.php');
  2827. $contexts = new question_edit_contexts($context);
  2828. // check export capability
  2829. $contexts->require_one_edit_tab_cap('export');
  2830. $category_id = (int)array_shift($args);
  2831. $format = array_shift($args);
  2832. $cattofile = array_shift($args);
  2833. $contexttofile = array_shift($args);
  2834. $filename = array_shift($args);
  2835. // load parent class for import/export
  2836. require_once($CFG->dirroot . '/question/format.php');
  2837. require_once($CFG->dirroot . '/question/editlib.php');
  2838. require_once($CFG->dirroot . '/question/format/' . $format . '/format.php');
  2839. $classname = 'qformat_' . $format;
  2840. if (!class_exists($classname)) {
  2841. send_file_not_found();
  2842. }
  2843. $qformat = new $classname();
  2844. if (!$category = $DB->get_record('question_categories', array('id' => $category_id))) {
  2845. send_file_not_found();
  2846. }
  2847. $qformat->setCategory($category);
  2848. $qformat->setContexts($contexts->having_one_edit_tab_cap('export'));
  2849. $qformat->setCourse($course);
  2850. if ($cattofile == 'withcategories') {
  2851. $qformat->setCattofile(true);
  2852. } else {
  2853. $qformat->setCattofile(false);
  2854. }
  2855. if ($contexttofile == 'withcontexts') {
  2856. $qformat->setContexttofile(true);
  2857. } else {
  2858. $qformat->setContexttofile(false);
  2859. }
  2860. if (!$qformat->exportpreprocess()) {
  2861. send_file_not_found();
  2862. print_error('exporterror', 'question', $thispageurl->out());
  2863. }
  2864. // export data to moodle file pool
  2865. if (!$content = $qformat->exportprocess(true)) {
  2866. send_file_not_found();
  2867. }
  2868. //DEBUG
  2869. //echo '<textarea cols=90 rows=20>';
  2870. //echo $content;
  2871. //echo '</textarea>';
  2872. //die;
  2873. send_file($content, $filename, 0, 0, true, true, $qformat->mime_type());
  2874. }
  2875. $attemptid = (int)array_shift($args);
  2876. $questionid = (int)array_shift($args);
  2877. if ($attemptid === 0) {
  2878. // preview
  2879. require_once($CFG->dirroot . '/question/previewlib.php');
  2880. return question_preview_question_pluginfile($course, $context,
  2881. $component, $filearea, $attemptid, $questionid, $args, $forcedownload);
  2882. } else {
  2883. $module = $DB->get_field('question_attempts', 'modulename',
  2884. array('id' => $attemptid));
  2885. $dir = get_component_directory($module);
  2886. if (!file_exists("$dir/lib.php")) {
  2887. send_file_not_found();
  2888. }
  2889. include_once("$dir/lib.php");
  2890. $filefunction = $module . '_question_pluginfile';
  2891. if (!function_exists($filefunction)) {
  2892. send_file_not_found();
  2893. }
  2894. $filefunction($course, $context, $component, $filearea, $attemptid, $questionid,
  2895. $args, $forcedownload);
  2896. send_file_not_found();
  2897. }
  2898. }
  2899. /**
  2900. * Final test for whether a studnet should be allowed to see a particular file.
  2901. * This delegates the decision to the question type plugin.
  2902. *
  2903. * @param object $question The question to be rendered.
  2904. * @param object $state The state to render the question in.
  2905. * @param object $options An object specifying the rendering options.
  2906. * @param string $component the name of the component we are serving files for.
  2907. * @param string $filearea the name of the file area.
  2908. * @param array $args the remaining bits of the file path.
  2909. * @param bool $forcedownload whether the user must be forced to download the file.
  2910. */
  2911. function question_check_file_access($question, $state, $options, $contextid, $component,
  2912. $filearea, $args, $forcedownload) {
  2913. global $QTYPES;
  2914. return $QTYPES[$question->qtype]->check_file_access($question, $state, $options, $contextid, $component,
  2915. $filearea, $args, $forcedownload);
  2916. }
  2917. /**
  2918. * Create url for question export
  2919. *
  2920. * @param int $contextid, current context
  2921. * @param int $categoryid, categoryid
  2922. * @param string $format
  2923. * @param string $withcategories
  2924. * @param string $ithcontexts
  2925. * @param moodle_url export file url
  2926. */
  2927. function question_make_export_url($contextid, $categoryid, $format, $withcategories, $withcontexts, $filename) {
  2928. global $CFG;
  2929. $urlbase = "$CFG->httpswwwroot/pluginfile.php";
  2930. return moodle_url::make_file_url($urlbase, "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}/{$withcontexts}/{$filename}", true);
  2931. }