PageRenderTime 183ms CodeModel.GetById 111ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/questionlib.php

https://github.com/nadavkav/MoodleTAO
PHP | 1288 lines | 700 code | 135 blank | 453 comment | 126 complexity | be053c0a11ead6146e45193c5837c0e5 MD5 | raw file
  1. <?php // $Id$
  2. /**
  3. * Code for handling and processing questions
  4. *
  5. * This is code that is module independent, i.e., can be used by any module that
  6. * uses questions, like quiz, lesson, ..
  7. * This script also loads the questiontype classes
  8. * Code for handling the editing of questions is in {@link question/editlib.php}
  9. *
  10. * TODO: separate those functions which form part of the API
  11. * from the helper functions.
  12. *
  13. * @author Martin Dougiamas and many others. This has recently been completely
  14. * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of
  15. * the Serving Mathematics project
  16. * {@link http://maths.york.ac.uk/serving_maths}
  17. * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
  18. * @package question
  19. */
  20. /// CONSTANTS ///////////////////////////////////
  21. /**#@+
  22. * The different types of events that can create question states
  23. */
  24. define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle
  25. define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used)
  26. define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated
  27. define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle.
  28. define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously
  29. define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded.
  30. define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle.
  31. define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked
  32. 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.
  33. define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher
  34. define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','.
  35. QUESTION_EVENTCLOSEANDGRADE.','.
  36. QUESTION_EVENTMANUALGRADE);
  37. /**#@-*/
  38. /**#@+
  39. * The core question types.
  40. */
  41. define("SHORTANSWER", "shortanswer");
  42. define("TRUEFALSE", "truefalse");
  43. define("MULTICHOICE", "multichoice");
  44. define("RANDOM", "random");
  45. define("MATCH", "match");
  46. define("RANDOMSAMATCH", "randomsamatch");
  47. define("DESCRIPTION", "description");
  48. define("NUMERICAL", "numerical");
  49. define("MULTIANSWER", "multianswer");
  50. define("CALCULATED", "calculated");
  51. define("ESSAY", "essay");
  52. /**#@-*/
  53. /**
  54. * Constant determines the number of answer boxes supplied in the editing
  55. * form for multiple choice and similar question types.
  56. */
  57. define("QUESTION_NUMANS", "10");
  58. /**
  59. * Constant determines the number of answer boxes supplied in the editing
  60. * form for multiple choice and similar question types to start with, with
  61. * the option of adding QUESTION_NUMANS_ADD more answers.
  62. */
  63. define("QUESTION_NUMANS_START", 3);
  64. /**
  65. * Constant determines the number of answer boxes to add in the editing
  66. * form for multiple choice and similar question types when the user presses
  67. * 'add form fields button'.
  68. */
  69. define("QUESTION_NUMANS_ADD", 3);
  70. /**
  71. * The options used when popping up a question preview window in Javascript.
  72. */
  73. define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700,height=540');
  74. /**#@+
  75. * Option flags for ->optionflags
  76. * The options are read out via bitwise operation using these constants
  77. */
  78. /**
  79. * Whether the questions is to be run in adaptive mode. If this is not set then
  80. * a question closes immediately after the first submission of responses. This
  81. * is how question is Moodle always worked before version 1.5
  82. */
  83. define('QUESTION_ADAPTIVE', 1);
  84. /**
  85. * options used in forms that move files.
  86. *
  87. */
  88. define('QUESTION_FILENOTHINGSELECTED', 0);
  89. define('QUESTION_FILEDONOTHING', 1);
  90. define('QUESTION_FILECOPY', 2);
  91. define('QUESTION_FILEMOVE', 3);
  92. define('QUESTION_FILEMOVELINKSONLY', 4);
  93. /**#@-*/
  94. /// QTYPES INITIATION //////////////////
  95. // These variables get initialised via calls to question_register_questiontype
  96. // as the question type classes are included.
  97. global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
  98. /**
  99. * Array holding question type objects
  100. */
  101. $QTYPES = array();
  102. /**
  103. * String in the format "'type1','type2'" that can be used in SQL clauses like
  104. * "WHERE q.type IN ($QTYPE_MANUAL)".
  105. */
  106. $QTYPE_MANUAL = '';
  107. /**
  108. * String in the format "'type1','type2'" that can be used in SQL clauses like
  109. * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)".
  110. */
  111. $QTYPE_EXCLUDE_FROM_RANDOM = '';
  112. /**
  113. * Add a new question type to the various global arrays above.
  114. *
  115. * @param object $qtype An instance of the new question type class.
  116. */
  117. function question_register_questiontype($qtype) {
  118. global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM;
  119. $name = $qtype->name();
  120. $QTYPES[$name] = $qtype;
  121. if ($qtype->is_manual_graded()) {
  122. if ($QTYPE_MANUAL) {
  123. $QTYPE_MANUAL .= ',';
  124. }
  125. $QTYPE_MANUAL .= "'$name'";
  126. }
  127. if (!$qtype->is_usable_by_random()) {
  128. if ($QTYPE_EXCLUDE_FROM_RANDOM) {
  129. $QTYPE_EXCLUDE_FROM_RANDOM .= ',';
  130. }
  131. $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'";
  132. }
  133. }
  134. require_once("$CFG->dirroot/question/type/questiontype.php");
  135. // Load the questiontype.php file for each question type
  136. // These files in turn call question_register_questiontype()
  137. // with a new instance of each qtype class.
  138. $qtypenames= get_list_of_plugins('question/type');
  139. foreach($qtypenames as $qtypename) {
  140. // Instanciates all plug-in question types
  141. $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php";
  142. // echo "Loading $qtypename<br/>"; // Uncomment for debugging
  143. if (is_readable($qtypefilepath)) {
  144. require_once($qtypefilepath);
  145. }
  146. }
  147. /**
  148. * An array of question type names translated to the user's language, suitable for use when
  149. * creating a drop-down menu of options.
  150. *
  151. * Long-time Moodle programmers will realise that this replaces the old $QTYPE_MENU array.
  152. * The array returned will only hold the names of all the question types that the user should
  153. * be able to create directly. Some internal question types like random questions are excluded.
  154. *
  155. * @return array an array of question type names translated to the user's language.
  156. */
  157. function question_type_menu() {
  158. global $QTYPES;
  159. static $menu_options = null;
  160. if (is_null($menu_options)) {
  161. $menu_options = array();
  162. foreach ($QTYPES as $name => $qtype) {
  163. $menuname = $qtype->menu_name();
  164. if ($menuname) {
  165. $menu_options[$name] = $menuname;
  166. }
  167. }
  168. }
  169. return $menu_options;
  170. }
  171. /// OTHER CLASSES /////////////////////////////////////////////////////////
  172. /**
  173. * This holds the options that are set by the course module
  174. */
  175. class cmoptions {
  176. /**
  177. * Whether a new attempt should be based on the previous one. If true
  178. * then a new attempt will start in a state where all responses are set
  179. * to the last responses from the previous attempt.
  180. */
  181. var $attemptonlast = false;
  182. /**
  183. * Various option flags. The flags are accessed via bitwise operations
  184. * using the constants defined in the CONSTANTS section above.
  185. */
  186. var $optionflags = QUESTION_ADAPTIVE;
  187. /**
  188. * Determines whether in the calculation of the score for a question
  189. * penalties for earlier wrong responses within the same attempt will
  190. * be subtracted.
  191. */
  192. var $penaltyscheme = true;
  193. /**
  194. * The maximum time the user is allowed to answer the questions withing
  195. * an attempt. This is measured in minutes so needs to be multiplied by
  196. * 60 before compared to timestamps. If set to 0 no timelimit will be applied
  197. */
  198. var $timelimit = 0;
  199. /**
  200. * Timestamp for the closing time. Responses submitted after this time will
  201. * be saved but no credit will be given for them.
  202. */
  203. var $timeclose = 9999999999;
  204. /**
  205. * The id of the course from withing which the question is currently being used
  206. */
  207. var $course = SITEID;
  208. /**
  209. * Whether the answers in a multiple choice question should be randomly
  210. * shuffled when a new attempt is started.
  211. */
  212. var $shuffleanswers = true;
  213. /**
  214. * The number of decimals to be shown when scores are printed
  215. */
  216. var $decimalpoints = 2;
  217. }
  218. /// FUNCTIONS //////////////////////////////////////////////////////
  219. /**
  220. * Returns an array of names of activity modules that use this question
  221. *
  222. * @param object $questionid
  223. * @return array of strings
  224. */
  225. function question_list_instances($questionid) {
  226. global $CFG;
  227. $instances = array();
  228. $modules = get_records('modules');
  229. foreach ($modules as $module) {
  230. $fullmod = $CFG->dirroot . '/mod/' . $module->name;
  231. if (file_exists($fullmod . '/lib.php')) {
  232. include_once($fullmod . '/lib.php');
  233. $fn = $module->name.'_question_list_instances';
  234. if (function_exists($fn)) {
  235. $instances = $instances + $fn($questionid);
  236. }
  237. }
  238. }
  239. return $instances;
  240. }
  241. /**
  242. * Determine whether there arey any questions belonging to this context, that is whether any of its
  243. * question categories contain any questions. This will return true even if all the questions are
  244. * hidden.
  245. *
  246. * @param mixed $context either a context object, or a context id.
  247. * @return boolean whether any of the question categories beloning to this context have
  248. * any questions in them.
  249. */
  250. function question_context_has_any_questions($context) {
  251. global $CFG;
  252. if (is_object($context)) {
  253. $contextid = $context->id;
  254. } else if (is_numeric($context)) {
  255. $contextid = $context;
  256. } else {
  257. print_error('invalidcontextinhasanyquestions', 'question');
  258. }
  259. return record_exists_sql('SELECT * FROM ' . $CFG->prefix . 'question q ' .
  260. 'JOIN ' . $CFG->prefix . 'question_categories qc ON qc.id = q.category ' .
  261. "WHERE qc.contextid = $contextid AND q.parent = 0");
  262. }
  263. /**
  264. * Returns list of 'allowed' grades for grade selection
  265. * formatted suitably for dropdown box function
  266. * @return object ->gradeoptionsfull full array ->gradeoptions +ve only
  267. */
  268. function get_grade_options() {
  269. // define basic array of grades
  270. $grades = array(
  271. 1.00,
  272. 0.90,
  273. 0.83333,
  274. 0.80,
  275. 0.75,
  276. 0.70,
  277. 0.66666,
  278. 0.60,
  279. 0.50,
  280. 0.40,
  281. 0.33333,
  282. 0.30,
  283. 0.25,
  284. 0.20,
  285. 0.16666,
  286. 0.142857,
  287. 0.125,
  288. 0.11111,
  289. 0.10,
  290. 0.05,
  291. 0);
  292. // iterate through grades generating full range of options
  293. $gradeoptionsfull = array();
  294. $gradeoptions = array();
  295. foreach ($grades as $grade) {
  296. $percentage = 100 * $grade;
  297. $neggrade = -$grade;
  298. $gradeoptions["$grade"] = "$percentage %";
  299. $gradeoptionsfull["$grade"] = "$percentage %";
  300. $gradeoptionsfull["$neggrade"] = -$percentage." %";
  301. }
  302. $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none");
  303. // sort lists
  304. arsort($gradeoptions, SORT_NUMERIC);
  305. arsort($gradeoptionsfull, SORT_NUMERIC);
  306. // construct return object
  307. $grades = new stdClass;
  308. $grades->gradeoptions = $gradeoptions;
  309. $grades->gradeoptionsfull = $gradeoptionsfull;
  310. return $grades;
  311. }
  312. /**
  313. * match grade options
  314. * if no match return error or match nearest
  315. * @param array $gradeoptionsfull list of valid options
  316. * @param int $grade grade to be tested
  317. * @param string $matchgrades 'error' or 'nearest'
  318. * @return mixed either 'fixed' value or false if erro
  319. */
  320. function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') {
  321. // if we just need an error...
  322. if ($matchgrades=='error') {
  323. foreach($gradeoptionsfull as $value => $option) {
  324. // slightly fuzzy test, never check floats for equality :-)
  325. if (abs($grade-$value)<0.00001) {
  326. return $grade;
  327. }
  328. }
  329. // didn't find a match so that's an error
  330. return false;
  331. }
  332. // work out nearest value
  333. else if ($matchgrades=='nearest') {
  334. $hownear = array();
  335. foreach($gradeoptionsfull as $value => $option) {
  336. if ($grade==$value) {
  337. return $grade;
  338. }
  339. $hownear[ $value ] = abs( $grade - $value );
  340. }
  341. // reverse sort list of deltas and grab the last (smallest)
  342. asort( $hownear, SORT_NUMERIC );
  343. reset( $hownear );
  344. return key( $hownear );
  345. }
  346. else {
  347. return false;
  348. }
  349. }
  350. /**
  351. * Tests whether a category is in use by any activity module
  352. *
  353. * @return boolean
  354. * @param integer $categoryid
  355. * @param boolean $recursive Whether to examine category children recursively
  356. */
  357. function question_category_isused($categoryid, $recursive = false) {
  358. //Look at each question in the category
  359. if ($questions = get_records('question', 'category', $categoryid)) {
  360. foreach ($questions as $question) {
  361. if (count(question_list_instances($question->id))) {
  362. return true;
  363. }
  364. }
  365. }
  366. //Look under child categories recursively
  367. if ($recursive) {
  368. if ($children = get_records('question_categories', 'parent', $categoryid)) {
  369. foreach ($children as $child) {
  370. if (question_category_isused($child->id, $recursive)) {
  371. return true;
  372. }
  373. }
  374. }
  375. }
  376. return false;
  377. }
  378. /**
  379. * Deletes all data associated to an attempt from the database
  380. *
  381. * @param integer $attemptid The id of the attempt being deleted
  382. */
  383. function delete_attempt($attemptid) {
  384. global $QTYPES;
  385. $states = get_records('question_states', 'attempt', $attemptid);
  386. if ($states) {
  387. $stateslist = implode(',', array_keys($states));
  388. // delete question-type specific data
  389. foreach ($QTYPES as $qtype) {
  390. $qtype->delete_states($stateslist);
  391. }
  392. }
  393. // delete entries from all other question tables
  394. // It is important that this is done only after calling the questiontype functions
  395. delete_records("question_states", "attempt", $attemptid);
  396. delete_records("question_sessions", "attemptid", $attemptid);
  397. delete_records("question_attempts", "id", $attemptid);
  398. }
  399. /**
  400. * Deletes question and all associated data from the database
  401. *
  402. * It will not delete a question if it is used by an activity module
  403. * @param object $question The question being deleted
  404. */
  405. function delete_question($questionid) {
  406. global $QTYPES;
  407. if (!$question = get_record('question', 'id', $questionid)) {
  408. // In some situations, for example if this was a child of a
  409. // Cloze question that was previously deleted, the question may already
  410. // have gone. In this case, just do nothing.
  411. return;
  412. }
  413. // Do not delete a question if it is used by an activity module
  414. if (count(question_list_instances($questionid))) {
  415. return;
  416. }
  417. // delete questiontype-specific data
  418. question_require_capability_on($question, 'edit');
  419. if ($question) {
  420. if (isset($QTYPES[$question->qtype])) {
  421. $QTYPES[$question->qtype]->delete_question($questionid);
  422. }
  423. } else {
  424. echo "Question with id $questionid does not exist.<br />";
  425. }
  426. if ($states = get_records('question_states', 'question', $questionid)) {
  427. $stateslist = implode(',', array_keys($states));
  428. // delete questiontype-specific data
  429. foreach ($QTYPES as $qtype) {
  430. $qtype->delete_states($stateslist);
  431. }
  432. }
  433. // delete entries from all other question tables
  434. // It is important that this is done only after calling the questiontype functions
  435. delete_records("question_answers", "question", $questionid);
  436. delete_records("question_states", "question", $questionid);
  437. delete_records("question_sessions", "questionid", $questionid);
  438. // Now recursively delete all child questions
  439. if ($children = get_records('question', 'parent', $questionid)) {
  440. foreach ($children as $child) {
  441. if ($child->id != $questionid) {
  442. delete_question($child->id);
  443. }
  444. }
  445. }
  446. // Finally delete the question record itself
  447. delete_records('question', 'id', $questionid);
  448. return;
  449. }
  450. /**
  451. * All question categories and their questions are deleted for this course.
  452. *
  453. * @param object $mod an object representing the activity
  454. * @param boolean $feedback to specify if the process must output a summary of its work
  455. * @return boolean
  456. */
  457. function question_delete_course($course, $feedback=true) {
  458. //To store feedback to be showed at the end of the process
  459. $feedbackdata = array();
  460. //Cache some strings
  461. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  462. $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
  463. $categoriescourse = get_records('question_categories', 'contextid', $coursecontext->id, 'parent', 'id, parent, name');
  464. if ($categoriescourse) {
  465. //Sort categories following their tree (parent-child) relationships
  466. //this will make the feedback more readable
  467. $categoriescourse = sort_categories_by_tree($categoriescourse);
  468. foreach ($categoriescourse as $category) {
  469. //Delete it completely (questions and category itself)
  470. //deleting questions
  471. if ($questions = get_records("question", "category", $category->id)) {
  472. foreach ($questions as $question) {
  473. delete_question($question->id);
  474. }
  475. delete_records("question", "category", $category->id);
  476. }
  477. //delete the category
  478. delete_records('question_categories', 'id', $category->id);
  479. //Fill feedback
  480. $feedbackdata[] = array($category->name, $strcatdeleted);
  481. }
  482. //Inform about changes performed if feedback is enabled
  483. if ($feedback) {
  484. $table = new stdClass;
  485. $table->head = array(get_string('category','quiz'), get_string('action'));
  486. $table->data = $feedbackdata;
  487. print_table($table);
  488. }
  489. }
  490. return true;
  491. }
  492. /**
  493. * Category is about to be deleted,
  494. * 1/ All question categories and their questions are deleted for this course category.
  495. * 2/ All questions are moved to new category
  496. *
  497. * @param object $category course category object
  498. * @param object $newcategory empty means everything deleted, otherwise id of category where content moved
  499. * @param boolean $feedback to specify if the process must output a summary of its work
  500. * @return boolean
  501. */
  502. function question_delete_course_category($category, $newcategory, $feedback=true) {
  503. $context = get_context_instance(CONTEXT_COURSECAT, $category->id);
  504. if (empty($newcategory)) {
  505. $feedbackdata = array(); // To store feedback to be showed at the end of the process
  506. $rescueqcategory = null; // See the code around the call to question_save_from_deletion.
  507. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  508. // Loop over question categories.
  509. if ($categories = get_records('question_categories', 'contextid', $context->id, 'parent', 'id, parent, name')) {
  510. foreach ($categories as $category) {
  511. // Deal with any questions in the category.
  512. if ($questions = get_records('question', 'category', $category->id)) {
  513. // Try to delete each question.
  514. foreach ($questions as $question) {
  515. delete_question($question->id);
  516. }
  517. // Check to see if there were any questions that were kept because they are
  518. // still in use somehow, even though quizzes in courses in this category will
  519. // already have been deteted. This could happen, for example, if questions are
  520. // added to a course, and then that course is moved to another category (MDL-14802).
  521. $questionids = get_records_select_menu('question', 'category = ' . $category->id, '', 'id,1');
  522. if (!empty($questionids)) {
  523. if (!$rescueqcategory = question_save_from_deletion(implode(',', array_keys($questionids)),
  524. get_parent_contextid($context), print_context_name($context), $rescueqcategory)) {
  525. return false;
  526. }
  527. $feedbackdata[] = array($category->name, get_string('questionsmovedto', 'question', $rescueqcategory->name));
  528. }
  529. }
  530. // Now delete the category.
  531. if (!delete_records('question_categories', 'id', $category->id)) {
  532. return false;
  533. }
  534. $feedbackdata[] = array($category->name, $strcatdeleted);
  535. } // End loop over categories.
  536. }
  537. // Output feedback if requested.
  538. if ($feedback and $feedbackdata) {
  539. $table = new stdClass;
  540. $table->head = array(get_string('questioncategory','question'), get_string('action'));
  541. $table->data = $feedbackdata;
  542. print_table($table);
  543. }
  544. } else {
  545. // Move question categories ot the new context.
  546. if (!$newcontext = get_context_instance(CONTEXT_COURSECAT, $newcategory->id)) {
  547. return false;
  548. }
  549. if (!set_field('question_categories', 'contextid', $newcontext->id, 'contextid', $context->id)) {
  550. return false;
  551. }
  552. if ($feedback) {
  553. $a = new stdClass;
  554. $a->oldplace = print_context_name($context);
  555. $a->newplace = print_context_name($newcontext);
  556. notify(get_string('movedquestionsandcategories', 'question', $a), 'notifysuccess');
  557. }
  558. }
  559. return true;
  560. }
  561. /**
  562. * Enter description here...
  563. *
  564. * @param string $questionids list of questionids
  565. * @param object $newcontext the context to create the saved category in.
  566. * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name
  567. * @param object $newcategory
  568. * @return mixed false on
  569. */
  570. function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) {
  571. // Make a category in the parent context to move the questions to.
  572. if (is_null($newcategory)) {
  573. $newcategory = new object();
  574. $newcategory->parent = 0;
  575. $newcategory->contextid = $newcontextid;
  576. $newcategory->name = addslashes(get_string('questionsrescuedfrom', 'question', $oldplace));
  577. $newcategory->info = addslashes(get_string('questionsrescuedfrominfo', 'question', $oldplace));
  578. $newcategory->sortorder = 999;
  579. $newcategory->stamp = make_unique_id_code();
  580. if (!$newcategory->id = insert_record('question_categories', $newcategory)) {
  581. return false;
  582. }
  583. }
  584. // Move any remaining questions to the 'saved' category.
  585. if (!question_move_questions_to_category($questionids, $newcategory->id)) {
  586. return false;
  587. }
  588. return $newcategory;
  589. }
  590. /**
  591. * All question categories and their questions are deleted for this activity.
  592. *
  593. * @param object $cm the course module object representing the activity
  594. * @param boolean $feedback to specify if the process must output a summary of its work
  595. * @return boolean
  596. */
  597. function question_delete_activity($cm, $feedback=true) {
  598. //To store feedback to be showed at the end of the process
  599. $feedbackdata = array();
  600. //Cache some strings
  601. $strcatdeleted = get_string('unusedcategorydeleted', 'quiz');
  602. $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id);
  603. if ($categoriesmods = get_records('question_categories', 'contextid', $modcontext->id, 'parent', 'id, parent, name')){
  604. //Sort categories following their tree (parent-child) relationships
  605. //this will make the feedback more readable
  606. $categoriesmods = sort_categories_by_tree($categoriesmods);
  607. foreach ($categoriesmods as $category) {
  608. //Delete it completely (questions and category itself)
  609. //deleting questions
  610. if ($questions = get_records("question", "category", $category->id)) {
  611. foreach ($questions as $question) {
  612. delete_question($question->id);
  613. }
  614. delete_records("question", "category", $category->id);
  615. }
  616. //delete the category
  617. delete_records('question_categories', 'id', $category->id);
  618. //Fill feedback
  619. $feedbackdata[] = array($category->name, $strcatdeleted);
  620. }
  621. //Inform about changes performed if feedback is enabled
  622. if ($feedback) {
  623. $table = new stdClass;
  624. $table->head = array(get_string('category','quiz'), get_string('action'));
  625. $table->data = $feedbackdata;
  626. print_table($table);
  627. }
  628. }
  629. return true;
  630. }
  631. /**
  632. * This function should be considered private to the question bank, it is called from
  633. * question/editlib.php question/contextmoveq.php and a few similar places to to the work of
  634. * acutally moving questions and associated data. However, callers of this function also have to
  635. * do other work, which is why you should not call this method directly from outside the questionbank.
  636. *
  637. * @param string $questionids a comma-separated list of question ids.
  638. * @param integer $newcategory the id of the category to move to.
  639. */
  640. function question_move_questions_to_category($questionids, $newcategory) {
  641. $result = true;
  642. // Move the questions themselves.
  643. $result = $result && set_field_select('question', 'category', $newcategory, "id IN ($questionids)");
  644. // Move any subquestions belonging to them.
  645. $result = $result && set_field_select('question', 'category', $newcategory, "parent IN ($questionids)");
  646. // TODO Deal with datasets.
  647. return $result;
  648. }
  649. /**
  650. * @param array $row tab objects
  651. * @param question_edit_contexts $contexts object representing contexts available from this context
  652. * @param string $querystring to append to urls
  653. * */
  654. function questionbank_navigation_tabs(&$row, $contexts, $querystring) {
  655. global $CFG, $QUESTION_EDITTABCAPS;
  656. $tabs = array(
  657. 'questions' =>array("$CFG->wwwroot/question/edit.php?$querystring", get_string('questions', 'quiz'), get_string('editquestions', 'quiz')),
  658. 'categories' =>array("$CFG->wwwroot/question/category.php?$querystring", get_string('categories', 'quiz'), get_string('editqcats', 'quiz')),
  659. 'import' =>array("$CFG->wwwroot/question/import.php?$querystring", get_string('import', 'quiz'), get_string('importquestions', 'quiz')),
  660. 'export' =>array("$CFG->wwwroot/question/export.php?$querystring", get_string('export', 'quiz'), get_string('exportquestions', 'quiz')));
  661. foreach ($tabs as $tabname => $tabparams){
  662. if ($contexts->have_one_edit_tab_cap($tabname)) {
  663. $row[] = new tabobject($tabname, $tabparams[0], $tabparams[1], $tabparams[2]);
  664. }
  665. }
  666. }
  667. /**
  668. * Private function to factor common code out of get_question_options().
  669. *
  670. * @param object $question the question to tidy.
  671. * @return boolean true if successful, else false.
  672. */
  673. function _tidy_question(&$question) {
  674. global $QTYPES;
  675. if (!array_key_exists($question->qtype, $QTYPES)) {
  676. $question->qtype = 'missingtype';
  677. $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') . '</p>' . $question->questiontext;
  678. }
  679. $question->name_prefix = question_make_name_prefix($question->id);
  680. return $QTYPES[$question->qtype]->get_question_options($question);
  681. }
  682. /**
  683. * Updates the question objects with question type specific
  684. * information by calling {@link get_question_options()}
  685. *
  686. * Can be called either with an array of question objects or with a single
  687. * question object.
  688. *
  689. * @param mixed $questions Either an array of question objects to be updated
  690. * or just a single question object
  691. * @return bool Indicates success or failure.
  692. */
  693. function get_question_options(&$questions) {
  694. if (is_array($questions)) { // deal with an array of questions
  695. foreach ($questions as $i => $notused) {
  696. if (!_tidy_question($questions[$i])) {
  697. return false;
  698. }
  699. }
  700. return true;
  701. } else { // deal with single question
  702. return _tidy_question($questions);
  703. }
  704. }
  705. /**
  706. * Loads the most recent state of each question session from the database
  707. * or create new one.
  708. *
  709. * For each question the most recent session state for the current attempt
  710. * is loaded from the question_states table and the question type specific data and
  711. * responses are added by calling {@link restore_question_state()} which in turn
  712. * calls {@link restore_session_and_responses()} for each question.
  713. * If no states exist for the question instance an empty state object is
  714. * created representing the start of a session and empty question
  715. * type specific information and responses are created by calling
  716. * {@link create_session_and_responses()}.
  717. *
  718. * @return array An array of state objects representing the most recent
  719. * states of the question sessions.
  720. * @param array $questions The questions for which sessions are to be restored or
  721. * created.
  722. * @param object $cmoptions
  723. * @param object $attempt The attempt for which the question sessions are
  724. * to be restored or created.
  725. * @param mixed either the id of a previous attempt, if this attmpt is
  726. * building on a previous one, or false for a clean attempt.
  727. */
  728. function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) {
  729. global $CFG, $QTYPES;
  730. // get the question ids
  731. $ids = array_keys($questions);
  732. $questionlist = implode(',', $ids);
  733. // The question field must be listed first so that it is used as the
  734. // array index in the array returned by get_records_sql
  735. $statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment';
  736. // Load the newest states for the questions
  737. $sql = "SELECT $statefields".
  738. " FROM {$CFG->prefix}question_states s,".
  739. " {$CFG->prefix}question_sessions n".
  740. " WHERE s.id = n.newest".
  741. " AND n.attemptid = '$attempt->uniqueid'".
  742. " AND n.questionid IN ($questionlist)";
  743. $states = get_records_sql($sql);
  744. // Load the newest graded states for the questions
  745. $sql = "SELECT $statefields".
  746. " FROM {$CFG->prefix}question_states s,".
  747. " {$CFG->prefix}question_sessions n".
  748. " WHERE s.id = n.newgraded".
  749. " AND n.attemptid = '$attempt->uniqueid'".
  750. " AND n.questionid IN ($questionlist)";
  751. $gradedstates = get_records_sql($sql);
  752. // loop through all questions and set the last_graded states
  753. foreach ($ids as $i) {
  754. if (isset($states[$i])) {
  755. restore_question_state($questions[$i], $states[$i]);
  756. if (isset($gradedstates[$i])) {
  757. restore_question_state($questions[$i], $gradedstates[$i]);
  758. $states[$i]->last_graded = $gradedstates[$i];
  759. } else {
  760. $states[$i]->last_graded = clone($states[$i]);
  761. }
  762. } else {
  763. if ($lastattemptid) {
  764. // If the new attempt is to be based on this previous attempt.
  765. // Find the responses from the previous attempt and save them to the new session
  766. // Load the last graded state for the question
  767. $statefields = 'n.questionid as question, s.*, n.sumpenalty';
  768. $sql = "SELECT $statefields".
  769. " FROM {$CFG->prefix}question_states s,".
  770. " {$CFG->prefix}question_sessions n".
  771. " WHERE s.id = n.newgraded".
  772. " AND n.attemptid = '$lastattemptid'".
  773. " AND n.questionid = '$i'";
  774. if (!$laststate = get_record_sql($sql)) {
  775. // Only restore previous responses that have been graded
  776. continue;
  777. }
  778. // Restore the state so that the responses will be restored
  779. restore_question_state($questions[$i], $laststate);
  780. $states[$i] = clone($laststate);
  781. unset($states[$i]->id);
  782. } else {
  783. // create a new empty state
  784. $states[$i] = new object;
  785. $states[$i]->question = $i;
  786. $states[$i]->responses = array('' => '');
  787. $states[$i]->raw_grade = 0;
  788. }
  789. // now fill/overide initial values
  790. $states[$i]->attempt = $attempt->uniqueid;
  791. $states[$i]->seq_number = 0;
  792. $states[$i]->timestamp = $attempt->timestart;
  793. $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN;
  794. $states[$i]->grade = 0;
  795. $states[$i]->penalty = 0;
  796. $states[$i]->sumpenalty = 0;
  797. $states[$i]->manualcomment = '';
  798. // Prevent further changes to the session from incrementing the
  799. // sequence number
  800. $states[$i]->changed = true;
  801. if ($lastattemptid) {
  802. // prepare the previous responses for new processing
  803. $action = new stdClass;
  804. $action->responses = $laststate->responses;
  805. $action->timestamp = $laststate->timestamp;
  806. $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631
  807. // Process these responses ...
  808. question_process_responses($questions[$i], $states[$i], $action, $cmoptions, $attempt);
  809. // Fix for Bug #5506: When each attempt is built on the last one,
  810. // preserve the options from any previous attempt.
  811. if ( isset($laststate->options) ) {
  812. $states[$i]->options = $laststate->options;
  813. }
  814. } else {
  815. // Create the empty question type specific information
  816. if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses(
  817. $questions[$i], $states[$i], $cmoptions, $attempt)) {
  818. return false;
  819. }
  820. }
  821. $states[$i]->last_graded = clone($states[$i]);
  822. }
  823. }
  824. return $states;
  825. }
  826. /**
  827. * Creates the run-time fields for the states
  828. *
  829. * Extends the state objects for a question by calling
  830. * {@link restore_session_and_responses()}
  831. * @param object $question The question for which the state is needed
  832. * @param object $state The state as loaded from the database
  833. * @return boolean Represents success or failure
  834. */
  835. function restore_question_state(&$question, &$state) {
  836. global $QTYPES;
  837. // initialise response to the value in the answer field
  838. $state->responses = array('' => addslashes($state->answer));
  839. unset($state->answer);
  840. $state->manualcomment = isset($state->manualcomment) ? addslashes($state->manualcomment) : '';
  841. // Set the changed field to false; any code which changes the
  842. // question session must set this to true and must increment
  843. // ->seq_number. The save_question_session
  844. // function will save the new state object to the database if the field is
  845. // set to true.
  846. $state->changed = false;
  847. // Load the question type specific data
  848. return $QTYPES[$question->qtype]
  849. ->restore_session_and_responses($question, $state);
  850. }
  851. /**
  852. * Saves the current state of the question session to the database
  853. *
  854. * The state object representing the current state of the session for the
  855. * question is saved to the question_states table with ->responses[''] saved
  856. * to the answer field of the database table. The information in the
  857. * question_sessions table is updated.
  858. * The question type specific data is then saved.
  859. * @return mixed The id of the saved or updated state or false
  860. * @param object $question The question for which session is to be saved.
  861. * @param object $state The state information to be saved. In particular the
  862. * most recent responses are in ->responses. The object
  863. * is updated to hold the new ->id.
  864. */
  865. function save_question_session(&$question, &$state) {
  866. global $QTYPES;
  867. // Check if the state has changed
  868. if (!$state->changed && isset($state->id)) {
  869. return $state->id;
  870. }
  871. // Set the legacy answer field
  872. $state->answer = isset($state->responses['']) ? $state->responses[''] : '';
  873. // Save the state
  874. if (!empty($state->update)) { // this forces the old state record to be overwritten
  875. update_record('question_states', $state);
  876. } else {
  877. if (!$state->id = insert_record('question_states', $state)) {
  878. unset($state->id);
  879. unset($state->answer);
  880. return false;
  881. }
  882. }
  883. // create or update the session
  884. if (!$session = get_record('question_sessions', 'attemptid',
  885. $state->attempt, 'questionid', $question->id)) {
  886. $session->attemptid = $state->attempt;
  887. $session->questionid = $question->id;
  888. $session->newest = $state->id;
  889. // The following may seem weird, but the newgraded field needs to be set
  890. // already even if there is no graded state yet.
  891. $session->newgraded = $state->id;
  892. $session->sumpenalty = $state->sumpenalty;
  893. $session->manualcomment = $state->manualcomment;
  894. if (!insert_record('question_sessions', $session)) {
  895. error('Could not insert entry in question_sessions');
  896. }
  897. } else {
  898. $session->newest = $state->id;
  899. if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) {
  900. // this state is graded or newly opened, so it goes into the lastgraded field as well
  901. $session->newgraded = $state->id;
  902. $session->sumpenalty = $state->sumpenalty;
  903. $session->manualcomment = $state->manualcomment;
  904. } else {
  905. $session->manualcomment = addslashes($session->manualcomment);
  906. }
  907. update_record('question_sessions', $session);
  908. }
  909. unset($state->answer);
  910. // Save the question type specific state information and responses
  911. if (!$QTYPES[$question->qtype]->save_session_and_responses(
  912. $question, $state)) {
  913. return false;
  914. }
  915. // Reset the changed flag
  916. $state->changed = false;
  917. return $state->id;
  918. }
  919. /**
  920. * Determines whether a state has been graded by looking at the event field
  921. *
  922. * @return boolean true if the state has been graded
  923. * @param object $state
  924. */
  925. function question_state_is_graded($state) {
  926. $gradedevents = explode(',', QUESTION_EVENTS_GRADED);
  927. return (in_array($state->event, $gradedevents));
  928. }
  929. /**
  930. * Determines whether a state has been closed by looking at the event field
  931. *
  932. * @return boolean true if the state has been closed
  933. * @param object $state
  934. */
  935. function question_state_is_closed($state) {
  936. return ($state->event == QUESTION_EVENTCLOSE
  937. or $state->event == QUESTION_EVENTCLOSEANDGRADE
  938. or $state->event == QUESTION_EVENTMANUALGRADE);
  939. }
  940. /**
  941. * Extracts responses from submitted form
  942. *
  943. * This can extract the responses given to one or several questions present on a page
  944. * It returns an array with one entry for each question, indexed by question id
  945. * Each entry is an object with the properties
  946. * ->event The event that has triggered the submission. This is determined by which button
  947. * the user has pressed.
  948. * ->responses An array holding the responses to an individual question, indexed by the
  949. * name of the corresponding form element.
  950. * ->timestamp A unix timestamp
  951. * @return array array of action objects, indexed by question ids.
  952. * @param array $questions an array containing at least all questions that are used on the form
  953. * @param array $formdata the data submitted by the form on the question page
  954. * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted
  955. */
  956. function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) {
  957. $time = time();
  958. $actions = array();
  959. foreach ($formdata as $key => $response) {
  960. // Get the question id from the response name
  961. if (false !== ($quid = question_get_id_from_name_prefix($key))) {
  962. // check if this is a valid id
  963. if (!isset($questions[$quid])) {
  964. error('Form contained question that is not in questionids');
  965. }
  966. // Remove the name prefix from the name
  967. //decrypt trying
  968. $key = substr($key, strlen($questions[$quid]->name_prefix));
  969. if (false === $key) {
  970. $key = '';
  971. }
  972. // Check for question validate and mark buttons & set events
  973. if ($key === 'validate') {
  974. $actions[$quid]->event = QUESTION_EVENTVALIDATE;
  975. } else if ($key === 'submit') {
  976. $actions[$quid]->event = QUESTION_EVENTSUBMIT;
  977. } else {
  978. $actions[$quid]->event = $defaultevent;
  979. }
  980. // Update the state with the new response
  981. $actions[$quid]->responses[$key] = $response;
  982. // Set the timestamp
  983. $actions[$quid]->timestamp = $time;
  984. }
  985. }
  986. foreach ($actions as $quid => $notused) {
  987. ksort($actions[$quid]->responses);
  988. }
  989. return $actions;
  990. }
  991. /**
  992. * Returns the html for question feedback image.
  993. * @param float $fraction value representing the correctness of the user's
  994. * response to a question.
  995. * @param boolean $selected whether or not the answer is the one that the
  996. * user picked.
  997. * @return string
  998. */
  999. function question_get_feedback_image($fraction, $selected=true) {
  1000. global $CFG;
  1001. if ($fraction >= 1.0) {
  1002. if ($selected) {
  1003. $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_big.gif" '.
  1004. 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
  1005. } else {
  1006. $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_green_small.gif" '.
  1007. 'alt="'.get_string('correct', 'quiz').'" class="icon" />';
  1008. }
  1009. } else if ($fraction > 0.0 && $fraction < 1.0) {
  1010. if ($selected) {
  1011. $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_big.gif" '.
  1012. 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
  1013. } else {
  1014. $feedbackimg = '<img src="'.$CFG->pixpath.'/i/tick_amber_small.gif" '.
  1015. 'alt="'.get_string('partiallycorrect', 'quiz').'" class="icon" />';
  1016. }
  1017. } else {
  1018. if ($selected) {
  1019. $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_big.gif" '.
  1020. 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
  1021. } else {
  1022. $feedbackimg = '<img src="'.$CFG->pixpath.'/i/cross_red_small.gif" '.
  1023. 'alt="'.get_string('incorrect', 'quiz').'" class="icon" />';
  1024. }
  1025. }
  1026. return $feedbackimg;
  1027. }
  1028. /**
  1029. * Returns the class name for question feedback.
  1030. * @param float $fraction value representing the correctness of the user's
  1031. * response to a question.
  1032. * @return string
  1033. */
  1034. function question_get_feedback_class($fraction) {
  1035. global $CFG;
  1036. if ($fraction >= 1.0) {
  1037. $class = 'correct';
  1038. } else if ($fraction > 0.0 && $fraction < 1.0) {
  1039. $class = 'partiallycorrect';
  1040. } else {
  1041. $class = 'incorrect';
  1042. }
  1043. return $class;
  1044. }
  1045. /**
  1046. * For a given question in an attempt we walk the complete history of states
  1047. * and recalculate the grades as we go along.
  1048. *
  1049. * This is used when a question is changed and old student
  1050. * responses need to be marked with the new version of a question.
  1051. *
  1052. * TODO: Make sure this is not quiz-specific
  1053. *
  1054. * @return boolean Indicates whether the grade has changed
  1055. * @param object $question A question object
  1056. * @param object $attempt The attempt, in which the question needs to be regraded.
  1057. * @param object $cmoptions
  1058. * @param boolean $verbose Optional. Whether to print progress information or not.
  1059. */
  1060. function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false) {
  1061. // load all states for this question in this attempt, ordered in sequence
  1062. if ($states = get_records_select('question_states',
  1063. "attempt = '{$attempt->uniqueid}' AND question = '{$question->id}'",
  1064. 'seq_number ASC')) {
  1065. $states = array_values($states);
  1066. // Subtract the grade for the latest state from $attempt->sumgrades to get the
  1067. // sumgrades for the attempt without this question.
  1068. $attempt->sumgrades -= $states[count($states)-1]->grade;
  1069. // Initialise the replaystate
  1070. $state = clone($states[0]);
  1071. $state->manualcomment = get_field('question_sessions', 'manualcomment', 'attemptid',
  1072. $attempt->uniqueid, 'questionid', $question->id);
  1073. restore_question_state($question, $state);
  1074. $state->sumpenalty = 0.0;
  1075. $replaystate = clone($state);
  1076. $replaystate->last_graded = $state;
  1077. $changed = false;
  1078. for($j = 1; $j < count($states); $j++) {
  1079. restore_question_state($question, $states[$j]);
  1080. $action = new stdClass;
  1081. $action->responses = $states[$j]->responses;
  1082. $action->timestamp = $states[$j]->timestamp;
  1083. // Change event to submit so that it will be reprocessed
  1084. if (QUESTION_EVENTCLOSE == $states[$j]->event
  1085. or QUESTION_EVENTGRADE == $states[$j]->event
  1086. or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) {
  1087. $action->event = QUESTION_EVENTSUBMIT;
  1088. // By default take the event that was saved in the database
  1089. } else {
  1090. $action->event = $states[$j]->event;
  1091. }
  1092. if ($action->event == QUESTION_EVENTMANUALGRADE) {
  1093. // Ensure that the grade is in range - in the past this was not checked,
  1094. // but now it is (MDL-14835) - so we need to ensure the data is valid before
  1095. // proceeding.
  1096. if ($states[$j]->grade < 0) {
  1097. $states[$j]->grade = 0;
  1098. } else if ($states[$j]->grade > $question->maxgrade) {
  1099. $states[$j]->grade = $question->maxgrade;
  1100. }
  1101. $error = question_process_comment($question, $replaystate, $attempt,
  1102. $replaystate->manualcomment, $states[$j]->grade);
  1103. if (is_string($error)) {
  1104. notify($error);
  1105. }
  1106. } else {
  1107. // Reprocess (regrade) responses
  1108. if (!question_process_responses($question, $replaystate,
  1109. $action, $cmoptions, $attempt)) {
  1110. $verbose && notify("Couldn't regrade state #{$state->id}!");
  1111. }
  1112. }
  1113. // We need rounding here because grades in the DB get truncated
  1114. // e.g. 0.33333 != 0.3333333, but we want them to be equal here
  1115. if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5))
  1116. or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5))
  1117. or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) {
  1118. $changed = true;
  1119. }
  1120. $replaystate->id = $states[$j]->id;
  1121. $replaystate->changed = true;
  1122. $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created
  1123. save_question_session($question, $replaystate);
  1124. }
  1125. if ($changed) {
  1126. // TODO, call a method in quiz to do this, where 'quiz' comes from
  1127. // the question_attempts table.
  1128. update_record('quiz_attempts', $attempt);
  1129. }
  1130. return $changed;
  1131. }
  1132. return false;
  1133. }
  1134. /**
  1135. * Processes an array of student responses, grading and saving them as appropriate
  1136. *
  1137. * @param object $question Full question object, passed by reference
  1138. * @param object $state Full state object, passed by reference
  1139. * @param object $action object with the fields ->responses which
  1140. * is an array holding the student responses,
  1141. * ->action which specifies the action, e.g., QUESTION_EVENTGRADE,
  1142. * and ->timestamp which is a timestamp from when the responses
  1143. * were submitted by the student.
  1144. * @param object $cmoptions
  1145. * @param object $attempt The attempt is passed by reference so that
  1146. * during grading its ->sumgrades field can be updated
  1147. * @return boolean Indicates success/failure
  1148. */
  1149. function question_process_responses(&$question