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

/question/engine/upgrade/upgradelib.php

https://github.com/rwijaya/moodle
PHP | 623 lines | 450 code | 101 blank | 72 comment | 72 complexity | 832100b851ffaf448aaff16ec01638ce MD5 | raw file
  1. <?php
  2. // This file is part of Moodle - http://moodle.org/
  3. //
  4. // Moodle is free software: you can redistribute it and/or modify
  5. // it under the terms of the GNU General Public License as published by
  6. // the Free Software Foundation, either version 3 of the License, or
  7. // (at your option) any later version.
  8. //
  9. // Moodle is distributed in the hope that it will be useful,
  10. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. // GNU General Public License for more details.
  13. //
  14. // You should have received a copy of the GNU General Public License
  15. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  16. /**
  17. * This file contains the code required to upgrade all the attempt data from
  18. * old versions of Moodle into the tables used by the new question engine.
  19. *
  20. * @package moodlecore
  21. * @subpackage questionengine
  22. * @copyright 2010 The Open University
  23. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24. */
  25. defined('MOODLE_INTERNAL') || die();
  26. global $CFG;
  27. require_once($CFG->dirroot . '/question/engine/bank.php');
  28. require_once($CFG->dirroot . '/question/engine/upgrade/logger.php');
  29. require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php');
  30. /**
  31. * This class manages upgrading all the question attempts from the old database
  32. * structure to the new question engine.
  33. *
  34. * @copyright 2010 The Open University
  35. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36. */
  37. class question_engine_attempt_upgrader {
  38. /** @var question_engine_upgrade_question_loader */
  39. protected $questionloader;
  40. /** @var question_engine_assumption_logger */
  41. protected $logger;
  42. /** @var int used by {@link prevent_timeout()}. */
  43. protected $dotcounter = 0;
  44. /** @var progress_bar */
  45. protected $progressbar = null;
  46. /** @var boolean */
  47. protected $doingbackup = false;
  48. /**
  49. * Called before starting to upgrade all the attempts at a particular quiz.
  50. * @param int $done the number of quizzes processed so far.
  51. * @param int $outof the total number of quizzes to process.
  52. * @param int $quizid the id of the quiz that is about to be processed.
  53. */
  54. protected function print_progress($done, $outof, $quizid) {
  55. if (is_null($this->progressbar)) {
  56. $this->progressbar = new progress_bar('qe2upgrade');
  57. $this->progressbar->create();
  58. }
  59. gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove.
  60. $a = new stdClass();
  61. $a->done = $done;
  62. $a->outof = $outof;
  63. $a->info = $quizid;
  64. $this->progressbar->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a));
  65. }
  66. protected function prevent_timeout() {
  67. core_php_time_limit::raise(300);
  68. if ($this->doingbackup) {
  69. return;
  70. }
  71. echo '.';
  72. $this->dotcounter += 1;
  73. if ($this->dotcounter % 100 == 0) {
  74. echo '<br />';
  75. }
  76. }
  77. protected function get_quiz_ids() {
  78. global $CFG, $DB;
  79. // Look to see if the admin has set things up to only upgrade certain attempts.
  80. $partialupgradefile = $CFG->dirroot . '/' . $CFG->admin .
  81. '/tool/qeupgradehelper/partialupgrade.php';
  82. $partialupgradefunction = 'tool_qeupgradehelper_get_quizzes_to_upgrade';
  83. if (is_readable($partialupgradefile)) {
  84. include_once($partialupgradefile);
  85. if (function_exists($partialupgradefunction)) {
  86. $quizids = $partialupgradefunction();
  87. // Ignore any quiz ids that do not acually exist.
  88. if (empty($quizids)) {
  89. return array();
  90. }
  91. list($test, $params) = $DB->get_in_or_equal($quizids);
  92. return $DB->get_fieldset_sql("
  93. SELECT id
  94. FROM {quiz}
  95. WHERE id $test
  96. ORDER BY id", $params);
  97. }
  98. }
  99. // Otherwise, upgrade all attempts.
  100. return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
  101. }
  102. public function convert_all_quiz_attempts() {
  103. global $DB;
  104. $quizids = $this->get_quiz_ids();
  105. if (empty($quizids)) {
  106. return true;
  107. }
  108. $done = 0;
  109. $outof = count($quizids);
  110. $this->logger = new question_engine_assumption_logger();
  111. foreach ($quizids as $quizid) {
  112. $this->print_progress($done, $outof, $quizid);
  113. $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST);
  114. $this->update_all_attempts_at_quiz($quiz);
  115. $done += 1;
  116. }
  117. $this->print_progress($outof, $outof, 'All done!');
  118. $this->logger = null;
  119. }
  120. public function get_attempts_extra_where() {
  121. return ' AND needsupgradetonewqe = 1';
  122. }
  123. public function update_all_attempts_at_quiz($quiz) {
  124. global $DB;
  125. // Wipe question loader cache.
  126. $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
  127. $transaction = $DB->start_delegated_transaction();
  128. $params = array('quizid' => $quiz->id);
  129. $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where();
  130. $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid');
  131. $questionsessionsrs = $DB->get_recordset_sql("
  132. SELECT s.*
  133. FROM {question_sessions} s
  134. JOIN {quiz_attempts} a ON (attemptid = uniqueid)
  135. WHERE $where
  136. ORDER BY attemptid, questionid
  137. ", $params);
  138. $questionsstatesrs = $DB->get_recordset_sql("
  139. SELECT s.*
  140. FROM {question_states} s
  141. JOIN {quiz_attempts} ON (s.attempt = uniqueid)
  142. WHERE $where
  143. ORDER BY s.attempt, question, seq_number, s.id
  144. ", $params);
  145. $datatodo = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs;
  146. while ($datatodo && $quizattemptsrs->valid()) {
  147. $attempt = $quizattemptsrs->current();
  148. $quizattemptsrs->next();
  149. $this->convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs);
  150. }
  151. $quizattemptsrs->close();
  152. $questionsessionsrs->close();
  153. $questionsstatesrs->close();
  154. $transaction->allow_commit();
  155. }
  156. protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
  157. moodle_recordset $questionsstatesrs) {
  158. $qas = array();
  159. $this->logger->set_current_attempt_id($attempt->id);
  160. while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) {
  161. $question = $this->load_question($qsession->questionid, $quiz->id);
  162. $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs);
  163. try {
  164. $qas[$qsession->questionid] = $this->convert_question_attempt(
  165. $quiz, $attempt, $question, $qsession, $qstates);
  166. } catch (Exception $e) {
  167. notify($e->getMessage());
  168. }
  169. }
  170. $this->logger->set_current_attempt_id(null);
  171. $questionorder = array();
  172. foreach (explode(',', $quiz->questions) as $questionid) {
  173. if ($questionid == 0) {
  174. continue;
  175. }
  176. if (!array_key_exists($questionid, $qas)) {
  177. $this->logger->log_assumption("Supplying minimal open state for
  178. question {$questionid} in attempt {$attempt->id} at quiz
  179. {$attempt->quiz}, since the session was missing.", $attempt->id);
  180. try {
  181. $question = $this->load_question($questionid, $quiz->id);
  182. $qas[$questionid] = $this->supply_missing_question_attempt(
  183. $quiz, $attempt, $question);
  184. } catch (Exception $e) {
  185. notify($e->getMessage());
  186. }
  187. }
  188. }
  189. return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
  190. }
  191. public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
  192. $missing = array();
  193. $layout = explode(',', $attempt->layout);
  194. $questionkeys = array_combine(array_values($layout), array_keys($layout));
  195. $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
  196. $i = 0;
  197. foreach (explode(',', $quizlayout) as $questionid) {
  198. if ($questionid == 0) {
  199. continue;
  200. }
  201. $i++;
  202. if (!array_key_exists($questionid, $qas)) {
  203. $missing[] = $questionid;
  204. $layout[$questionkeys[$questionid]] = $questionid;
  205. continue;
  206. }
  207. $qa = $qas[$questionid];
  208. $qa->questionusageid = $attempt->uniqueid;
  209. $qa->slot = $i;
  210. if (core_text::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
  211. // It seems some people write very long quesions! MDL-30760
  212. $qa->questionsummary = core_text::substr($qa->questionsummary,
  213. 0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
  214. }
  215. $this->insert_record('question_attempts', $qa);
  216. $layout[$questionkeys[$questionid]] = $qa->slot;
  217. foreach ($qa->steps as $step) {
  218. $step->questionattemptid = $qa->id;
  219. $this->insert_record('question_attempt_steps', $step);
  220. foreach ($step->data as $name => $value) {
  221. $datum = new stdClass();
  222. $datum->attemptstepid = $step->id;
  223. $datum->name = $name;
  224. $datum->value = $value;
  225. $this->insert_record('question_attempt_step_data', $datum, false);
  226. }
  227. }
  228. }
  229. $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
  230. if ($missing) {
  231. notify("Question sessions for questions " .
  232. implode(', ', $missing) .
  233. " were missing when upgrading question usage {$attempt->uniqueid}.");
  234. }
  235. }
  236. protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
  237. global $DB;
  238. $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
  239. array('id' => $qubaid));
  240. }
  241. protected function set_quiz_attempt_layout($qubaid, $layout) {
  242. global $DB;
  243. $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
  244. $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid));
  245. }
  246. protected function delete_quiz_attempt($qubaid) {
  247. global $DB;
  248. $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
  249. $DB->delete_records('question_attempts', array('id' => $qubaid));
  250. }
  251. protected function insert_record($table, $record, $saveid = true) {
  252. global $DB;
  253. $newid = $DB->insert_record($table, $record, $saveid);
  254. if ($saveid) {
  255. $record->id = $newid;
  256. }
  257. return $newid;
  258. }
  259. public function load_question($questionid, $quizid = null) {
  260. return $this->questionloader->get_question($questionid, $quizid);
  261. }
  262. public function load_dataset($questionid, $selecteditem) {
  263. return $this->questionloader->load_dataset($questionid, $selecteditem);
  264. }
  265. public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
  266. if (!$questionsessionsrs->valid()) {
  267. return false;
  268. }
  269. $qsession = $questionsessionsrs->current();
  270. if ($qsession->attemptid != $attempt->uniqueid) {
  271. // No more question sessions belonging to this attempt.
  272. return false;
  273. }
  274. // Session found, move the pointer in the RS and return the record.
  275. $questionsessionsrs->next();
  276. return $qsession;
  277. }
  278. public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
  279. $qstates = array();
  280. while ($questionsstatesrs->valid()) {
  281. $state = $questionsstatesrs->current();
  282. if ($state->attempt != $attempt->uniqueid ||
  283. $state->question != $question->id) {
  284. // We have found all the states for this attempt. Stop.
  285. break;
  286. }
  287. // Add the new state to the array, and advance.
  288. $qstates[] = $state;
  289. $questionsstatesrs->next();
  290. }
  291. return $qstates;
  292. }
  293. protected function get_converter_class_name($question, $quiz, $qsessionid) {
  294. global $DB;
  295. if ($question->qtype == 'deleted') {
  296. $where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7';
  297. $params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%");
  298. if ($DB->record_exists_select('question_states', $where, $params)) {
  299. $this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded.");
  300. return 'qbehaviour_manualgraded_converter';
  301. }
  302. }
  303. $qtype = question_bank::get_qtype($question->qtype, false);
  304. if ($qtype->is_manual_graded()) {
  305. return 'qbehaviour_manualgraded_converter';
  306. } else if ($question->qtype == 'description') {
  307. return 'qbehaviour_informationitem_converter';
  308. } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
  309. return 'qbehaviour_deferredfeedback_converter';
  310. } else if ($quiz->preferredbehaviour == 'adaptive') {
  311. return 'qbehaviour_adaptive_converter';
  312. } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
  313. return 'qbehaviour_adaptivenopenalty_converter';
  314. } else {
  315. throw new coding_exception("Question session {$qsessionid}
  316. has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
  317. }
  318. }
  319. public function supply_missing_question_attempt($quiz, $attempt, $question) {
  320. if ($question->qtype == 'random') {
  321. throw new coding_exception("Cannot supply a missing qsession for question
  322. {$question->id} in attempt {$attempt->id}.");
  323. }
  324. $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
  325. $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
  326. null, null, $this->logger, $this);
  327. $qa = $qbehaviourupdater->supply_missing_qa();
  328. $qbehaviourupdater->discard();
  329. return $qa;
  330. }
  331. public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
  332. $this->prevent_timeout();
  333. if ($question->qtype == 'random') {
  334. list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
  335. $qsession->questionid = $question->id;
  336. }
  337. $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
  338. $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
  339. $qstates, $this->logger, $this);
  340. $qa = $qbehaviourupdater->get_converted_qa();
  341. $qbehaviourupdater->discard();
  342. return $qa;
  343. }
  344. protected function decode_random_attempt($qstates, $maxmark) {
  345. $realquestionid = null;
  346. foreach ($qstates as $i => $state) {
  347. if (strpos($state->answer, '-') < 6) {
  348. // Broken state, skip it.
  349. $this->logger->log_assumption("Had to skip brokes state {$state->id}
  350. for question {$state->question}.");
  351. unset($qstates[$i]);
  352. continue;
  353. }
  354. list($randombit, $realanswer) = explode('-', $state->answer, 2);
  355. $newquestionid = substr($randombit, 6);
  356. if ($realquestionid && $realquestionid != $newquestionid) {
  357. throw new coding_exception("Question session {$this->qsession->id}
  358. for random question points to two different real questions
  359. {$realquestionid} and {$newquestionid}.");
  360. }
  361. $qstates[$i]->answer = $realanswer;
  362. }
  363. if (empty($newquestionid)) {
  364. // This attempt only had broken states. Set a fake $newquestionid to
  365. // prevent a null DB error later.
  366. $newquestionid = 0;
  367. }
  368. $newquestion = $this->load_question($newquestionid);
  369. $newquestion->maxmark = $maxmark;
  370. return array($newquestion, $qstates);
  371. }
  372. public function prepare_to_restore() {
  373. $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade.
  374. $this->logger = new dummy_question_engine_assumption_logger();
  375. $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
  376. }
  377. }
  378. /**
  379. * This class deals with loading (and caching) question definitions during the
  380. * question engine upgrade.
  381. *
  382. * @copyright 2010 The Open University
  383. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  384. */
  385. class question_engine_upgrade_question_loader {
  386. protected $cache = array();
  387. protected $datasetcache = array();
  388. public function __construct($logger) {
  389. $this->logger = $logger;
  390. }
  391. protected function load_question($questionid, $quizid) {
  392. global $DB;
  393. if ($quizid) {
  394. $question = $DB->get_record_sql("
  395. SELECT q.*, qqi.grade AS maxmark
  396. FROM {question} q
  397. JOIN {quiz_question_instances} qqi ON qqi.question = q.id
  398. WHERE q.id = $questionid AND qqi.quiz = $quizid");
  399. } else {
  400. $question = $DB->get_record('question', array('id' => $questionid));
  401. }
  402. if (!$question) {
  403. return null;
  404. }
  405. if (empty($question->defaultmark)) {
  406. if (!empty($question->defaultgrade)) {
  407. $question->defaultmark = $question->defaultgrade;
  408. } else {
  409. $question->defaultmark = 0;
  410. }
  411. unset($question->defaultgrade);
  412. }
  413. $qtype = question_bank::get_qtype($question->qtype, false);
  414. if ($qtype->name() === 'missingtype') {
  415. $this->logger->log_assumption("Dealing with question id {$question->id}
  416. that is of an unknown type {$question->qtype}.");
  417. $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
  418. '</p>' . $question->questiontext;
  419. }
  420. $qtype->get_question_options($question);
  421. return $question;
  422. }
  423. public function get_question($questionid, $quizid) {
  424. if (isset($this->cache[$questionid])) {
  425. return $this->cache[$questionid];
  426. }
  427. $question = $this->load_question($questionid, $quizid);
  428. if (!$question) {
  429. $this->logger->log_assumption("Dealing with question id {$questionid}
  430. that was missing from the database.");
  431. $question = new stdClass();
  432. $question->id = $questionid;
  433. $question->qtype = 'deleted';
  434. $question->maxmark = 1; // Guess, but that is all we can do.
  435. $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
  436. }
  437. $this->cache[$questionid] = $question;
  438. return $this->cache[$questionid];
  439. }
  440. public function load_dataset($questionid, $selecteditem) {
  441. global $DB;
  442. if (isset($this->datasetcache[$questionid][$selecteditem])) {
  443. return $this->datasetcache[$questionid][$selecteditem];
  444. }
  445. $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
  446. SELECT qdd.name, qdi.value
  447. FROM {question_dataset_items} qdi
  448. JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
  449. JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
  450. WHERE qd.question = ?
  451. AND qdi.itemnumber = ?
  452. ', array($questionid, $selecteditem));
  453. return $this->datasetcache[$questionid][$selecteditem];
  454. }
  455. }
  456. /**
  457. * Base class for the classes that convert the question-type specific bits of
  458. * the attempt data.
  459. *
  460. * @copyright 2010 The Open University
  461. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  462. */
  463. abstract class question_qtype_attempt_updater {
  464. /** @var object the question definition data. */
  465. protected $question;
  466. /** @var question_behaviour_attempt_updater */
  467. protected $updater;
  468. /** @var question_engine_assumption_logger */
  469. protected $logger;
  470. /** @var question_engine_attempt_upgrader */
  471. protected $qeupdater;
  472. public function __construct($updater, $question, $logger, $qeupdater) {
  473. $this->updater = $updater;
  474. $this->question = $question;
  475. $this->logger = $logger;
  476. $this->qeupdater = $qeupdater;
  477. }
  478. public function discard() {
  479. // Help the garbage collector, which seems to be struggling.
  480. $this->updater = null;
  481. $this->question = null;
  482. $this->logger = null;
  483. $this->qeupdater = null;
  484. }
  485. protected function to_text($html) {
  486. return $this->updater->to_text($html);
  487. }
  488. public function question_summary() {
  489. return $this->to_text($this->question->questiontext);
  490. }
  491. public function compare_answers($answer1, $answer2) {
  492. return $answer1 == $answer2;
  493. }
  494. public function is_blank_answer($state) {
  495. return $state->answer == '';
  496. }
  497. public abstract function right_answer();
  498. public abstract function response_summary($state);
  499. public abstract function was_answered($state);
  500. public abstract function set_first_step_data_elements($state, &$data);
  501. public abstract function set_data_elements_for_step($state, &$data);
  502. public abstract function supply_missing_first_step_data(&$data);
  503. }
  504. class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
  505. public function right_answer() {
  506. return '';
  507. }
  508. public function response_summary($state) {
  509. return $state->answer;
  510. }
  511. public function was_answered($state) {
  512. return !empty($state->answer);
  513. }
  514. public function set_first_step_data_elements($state, &$data) {
  515. $data['upgradedfromdeletedquestion'] = $state->answer;
  516. }
  517. public function supply_missing_first_step_data(&$data) {
  518. }
  519. public function set_data_elements_for_step($state, &$data) {
  520. $data['upgradedfromdeletedquestion'] = $state->answer;
  521. }
  522. }