PageRenderTime 53ms CodeModel.GetById 20ms RepoModel.GetById 1ms app.codeStats 0ms

/question/engine/upgrade/upgradelib.php

https://github.com/kpike/moodle
PHP | 607 lines | 435 code | 101 blank | 71 comment | 69 complexity | 38194ce1710d5a3d7d8ab9e9784ef88a 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. set_time_limit(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 . '/local/qeupgradehelper/partialupgrade.php';
  81. $partialupgradefunction = 'local_qeupgradehelper_get_quizzes_to_upgrade';
  82. if (is_readable($partialupgradefile)) {
  83. include_once($partialupgradefile);
  84. if (function_exists($partialupgradefunction)) {
  85. $quizids = $partialupgradefunction();
  86. // Ignore any quiz ids that do not acually exist.
  87. if (empty($quizids)) {
  88. return array();
  89. }
  90. list($test, $params) = $DB->get_in_or_equal($quizids);
  91. return $DB->get_fieldset_sql("
  92. SELECT id
  93. FROM {quiz}
  94. WHERE id $test
  95. ORDER BY id", $params);
  96. }
  97. }
  98. // Otherwise, upgrade all attempts.
  99. return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
  100. }
  101. public function convert_all_quiz_attempts() {
  102. global $DB;
  103. $quizids = $this->get_quiz_ids();
  104. if (empty($quizids)) {
  105. return true;
  106. }
  107. $done = 0;
  108. $outof = count($quizids);
  109. $this->logger = new question_engine_assumption_logger();
  110. foreach ($quizids as $quizid) {
  111. $this->print_progress($done, $outof, $quizid);
  112. $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST);
  113. $this->update_all_attempts_at_quiz($quiz);
  114. $done += 1;
  115. }
  116. $this->print_progress($outof, $outof, 'All done!');
  117. $this->logger = null;
  118. }
  119. public function get_attempts_extra_where() {
  120. return ' AND needsupgradetonewqe = 1';
  121. }
  122. public function update_all_attempts_at_quiz($quiz) {
  123. global $DB;
  124. // Wipe question loader cache.
  125. $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
  126. $transaction = $DB->start_delegated_transaction();
  127. $params = array('quizid' => $quiz->id);
  128. $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where();
  129. $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid');
  130. $questionsessionsrs = $DB->get_recordset_sql("
  131. SELECT *
  132. FROM {question_sessions}
  133. WHERE attemptid IN (
  134. SELECT uniqueid FROM {quiz_attempts} WHERE $where)
  135. ORDER BY attemptid, questionid
  136. ", $params);
  137. $questionsstatesrs = $DB->get_recordset_sql("
  138. SELECT *
  139. FROM {question_states}
  140. WHERE attempt IN (
  141. SELECT uniqueid FROM {quiz_attempts} WHERE $where)
  142. ORDER BY attempt, question, seq_number, id
  143. ", $params);
  144. $datatodo = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs;
  145. while ($datatodo && $quizattemptsrs->valid()) {
  146. $attempt = $quizattemptsrs->current();
  147. $quizattemptsrs->next();
  148. $this->convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs);
  149. }
  150. $quizattemptsrs->close();
  151. $questionsessionsrs->close();
  152. $questionsstatesrs->close();
  153. $transaction->allow_commit();
  154. }
  155. protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
  156. moodle_recordset $questionsstatesrs) {
  157. $qas = array();
  158. $this->logger->set_current_attempt_id($attempt->id);
  159. while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) {
  160. $question = $this->load_question($qsession->questionid, $quiz->id);
  161. $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs);
  162. try {
  163. $qas[$qsession->questionid] = $this->convert_question_attempt(
  164. $quiz, $attempt, $question, $qsession, $qstates);
  165. } catch (Exception $e) {
  166. notify($e->getMessage());
  167. }
  168. }
  169. $this->logger->set_current_attempt_id(null);
  170. $questionorder = array();
  171. foreach (explode(',', $quiz->questions) as $questionid) {
  172. if ($questionid == 0) {
  173. continue;
  174. }
  175. if (!array_key_exists($questionid, $qas)) {
  176. $this->logger->log_assumption("Supplying minimal open state for
  177. question {$questionid} in attempt {$attempt->id} at quiz
  178. {$attempt->quiz}, since the session was missing.", $attempt->id);
  179. try {
  180. $question = $this->load_question($questionid, $quiz->id);
  181. $qas[$questionid] = $this->supply_missing_question_attempt(
  182. $quiz, $attempt, $question);
  183. } catch (Exception $e) {
  184. notify($e->getMessage());
  185. }
  186. }
  187. }
  188. return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
  189. }
  190. public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
  191. $missing = array();
  192. $layout = explode(',', $attempt->layout);
  193. $questionkeys = array_combine(array_values($layout), array_keys($layout));
  194. $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
  195. $i = 0;
  196. foreach (explode(',', $quizlayout) as $questionid) {
  197. if ($questionid == 0) {
  198. continue;
  199. }
  200. $i++;
  201. if (!array_key_exists($questionid, $qas)) {
  202. $missing[] = $questionid;
  203. $layout[$questionkeys[$questionid]] = $questionid;
  204. continue;
  205. }
  206. $qa = $qas[$questionid];
  207. $qa->questionusageid = $attempt->uniqueid;
  208. $qa->slot = $i;
  209. $this->insert_record('question_attempts', $qa);
  210. $layout[$questionkeys[$questionid]] = $qa->slot;
  211. foreach ($qa->steps as $step) {
  212. $step->questionattemptid = $qa->id;
  213. $this->insert_record('question_attempt_steps', $step);
  214. foreach ($step->data as $name => $value) {
  215. $datum = new stdClass();
  216. $datum->attemptstepid = $step->id;
  217. $datum->name = $name;
  218. $datum->value = $value;
  219. $this->insert_record('question_attempt_step_data', $datum, false);
  220. }
  221. }
  222. }
  223. $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
  224. if ($missing) {
  225. notify("Question sessions for questions " .
  226. implode(', ', $missing) .
  227. " were missing when upgrading question usage {$attempt->uniqueid}.");
  228. }
  229. }
  230. protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
  231. global $DB;
  232. $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
  233. array('id' => $qubaid));
  234. }
  235. protected function set_quiz_attempt_layout($qubaid, $layout) {
  236. global $DB;
  237. $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
  238. $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid));
  239. }
  240. protected function delete_quiz_attempt($qubaid) {
  241. global $DB;
  242. $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
  243. $DB->delete_records('question_attempts', array('id' => $qubaid));
  244. }
  245. protected function insert_record($table, $record, $saveid = true) {
  246. global $DB;
  247. $newid = $DB->insert_record($table, $record, $saveid);
  248. if ($saveid) {
  249. $record->id = $newid;
  250. }
  251. return $newid;
  252. }
  253. public function load_question($questionid, $quizid = null) {
  254. return $this->questionloader->get_question($questionid, $quizid);
  255. }
  256. public function load_dataset($questionid, $selecteditem) {
  257. return $this->questionloader->load_dataset($questionid, $selecteditem);
  258. }
  259. public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
  260. if (!$questionsessionsrs->valid()) {
  261. return false;
  262. }
  263. $qsession = $questionsessionsrs->current();
  264. if ($qsession->attemptid != $attempt->uniqueid) {
  265. // No more question sessions belonging to this attempt.
  266. return false;
  267. }
  268. // Session found, move the pointer in the RS and return the record.
  269. $questionsessionsrs->next();
  270. return $qsession;
  271. }
  272. public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
  273. $qstates = array();
  274. while ($questionsstatesrs->valid()) {
  275. $state = $questionsstatesrs->current();
  276. if ($state->attempt != $attempt->uniqueid ||
  277. $state->question != $question->id) {
  278. // We have found all the states for this attempt. Stop.
  279. break;
  280. }
  281. // Add the new state to the array, and advance.
  282. $qstates[] = $state;
  283. $questionsstatesrs->next();
  284. }
  285. return $qstates;
  286. }
  287. protected function get_converter_class_name($question, $quiz, $qsessionid) {
  288. if ($question->qtype == 'essay') {
  289. return 'qbehaviour_manualgraded_converter';
  290. } else if ($question->qtype == 'description') {
  291. return 'qbehaviour_informationitem_converter';
  292. } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
  293. return 'qbehaviour_deferredfeedback_converter';
  294. } else if ($quiz->preferredbehaviour == 'adaptive') {
  295. return 'qbehaviour_adaptive_converter';
  296. } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
  297. return 'qbehaviour_adaptivenopenalty_converter';
  298. } else {
  299. throw new coding_exception("Question session {$qsessionid}
  300. has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
  301. }
  302. }
  303. public function supply_missing_question_attempt($quiz, $attempt, $question) {
  304. if ($question->qtype == 'random') {
  305. throw new coding_exception("Cannot supply a missing qsession for question
  306. {$question->id} in attempt {$attempt->id}.");
  307. }
  308. $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
  309. $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
  310. null, null, $this->logger, $this);
  311. $qa = $qbehaviourupdater->supply_missing_qa();
  312. $qbehaviourupdater->discard();
  313. return $qa;
  314. }
  315. public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
  316. $this->prevent_timeout();
  317. if ($question->qtype == 'random') {
  318. list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
  319. $qsession->questionid = $question->id;
  320. }
  321. $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
  322. $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
  323. $qstates, $this->logger, $this);
  324. $qa = $qbehaviourupdater->get_converted_qa();
  325. $qbehaviourupdater->discard();
  326. return $qa;
  327. }
  328. protected function decode_random_attempt($qstates, $maxmark) {
  329. $realquestionid = null;
  330. foreach ($qstates as $i => $state) {
  331. if (strpos($state->answer, '-') < 6) {
  332. // Broken state, skip it.
  333. $this->logger->log_assumption("Had to skip brokes state {$state->id}
  334. for question {$state->question}.");
  335. unset($qstates[$i]);
  336. continue;
  337. }
  338. list($randombit, $realanswer) = explode('-', $state->answer, 2);
  339. $newquestionid = substr($randombit, 6);
  340. if ($realquestionid && $realquestionid != $newquestionid) {
  341. throw new coding_exception("Question session {$this->qsession->id}
  342. for random question points to two different real questions
  343. {$realquestionid} and {$newquestionid}.");
  344. }
  345. $qstates[$i]->answer = $realanswer;
  346. }
  347. if (empty($newquestionid)) {
  348. // This attempt only had broken states. Set a fake $newquestionid to
  349. // prevent a null DB error later.
  350. $newquestionid = 0;
  351. }
  352. $newquestion = $this->load_question($newquestionid);
  353. $newquestion->maxmark = $maxmark;
  354. return array($newquestion, $qstates);
  355. }
  356. public function prepare_to_restore() {
  357. $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade.
  358. $this->logger = new dummy_question_engine_assumption_logger();
  359. $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
  360. }
  361. }
  362. /**
  363. * This class deals with loading (and caching) question definitions during the
  364. * question engine upgrade.
  365. *
  366. * @copyright 2010 The Open University
  367. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  368. */
  369. class question_engine_upgrade_question_loader {
  370. private $cache = array();
  371. private $datasetcache = array();
  372. public function __construct($logger) {
  373. $this->logger = $logger;
  374. }
  375. protected function load_question($questionid, $quizid) {
  376. global $DB;
  377. if ($quizid) {
  378. $question = $DB->get_record_sql("
  379. SELECT q.*, qqi.grade AS maxmark
  380. FROM {question} q
  381. JOIN {quiz_question_instances} qqi ON qqi.question = q.id
  382. WHERE q.id = $questionid AND qqi.quiz = $quizid");
  383. } else {
  384. $question = $DB->get_record('question', array('id' => $questionid));
  385. }
  386. if (!$question) {
  387. return null;
  388. }
  389. if (empty($question->defaultmark)) {
  390. if (!empty($question->defaultgrade)) {
  391. $question->defaultmark = $question->defaultgrade;
  392. } else {
  393. $question->defaultmark = 0;
  394. }
  395. unset($question->defaultgrade);
  396. }
  397. $qtype = question_bank::get_qtype($question->qtype, false);
  398. if ($qtype->name() === 'missingtype') {
  399. $this->logger->log_assumption("Dealing with question id {$question->id}
  400. that is of an unknown type {$question->qtype}.");
  401. $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
  402. '</p>' . $question->questiontext;
  403. }
  404. $qtype->get_question_options($question);
  405. return $question;
  406. }
  407. public function get_question($questionid, $quizid) {
  408. if (isset($this->cache[$questionid])) {
  409. return $this->cache[$questionid];
  410. }
  411. $question = $this->load_question($questionid, $quizid);
  412. if (!$question) {
  413. $this->logger->log_assumption("Dealing with question id {$questionid}
  414. that was missing from the database.");
  415. $question = new stdClass();
  416. $question->id = $questionid;
  417. $question->qtype = 'deleted';
  418. $question->maxmark = 1; // Guess, but that is all we can do.
  419. $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
  420. }
  421. $this->cache[$questionid] = $question;
  422. return $this->cache[$questionid];
  423. }
  424. public function load_dataset($questionid, $selecteditem) {
  425. global $DB;
  426. if (isset($this->datasetcache[$questionid][$selecteditem])) {
  427. return $this->datasetcache[$questionid][$selecteditem];
  428. }
  429. $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
  430. SELECT qdd.name, qdi.value
  431. FROM {question_dataset_items} qdi
  432. JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
  433. JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
  434. WHERE qd.question = ?
  435. AND qdi.itemnumber = ?
  436. ', array($questionid, $selecteditem));
  437. return $this->datasetcache[$questionid][$selecteditem];
  438. }
  439. }
  440. /**
  441. * Base class for the classes that convert the question-type specific bits of
  442. * the attempt data.
  443. *
  444. * @copyright 2010 The Open University
  445. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  446. */
  447. abstract class question_qtype_attempt_updater {
  448. /** @var object the question definition data. */
  449. protected $question;
  450. /** @var question_behaviour_attempt_updater */
  451. protected $updater;
  452. /** @var question_engine_assumption_logger */
  453. protected $logger;
  454. /** @var question_engine_attempt_upgrader */
  455. protected $qeupdater;
  456. public function __construct($updater, $question, $logger, $qeupdater) {
  457. $this->updater = $updater;
  458. $this->question = $question;
  459. $this->logger = $logger;
  460. $this->qeupdater = $qeupdater;
  461. }
  462. public function discard() {
  463. // Help the garbage collector, which seems to be struggling.
  464. $this->updater = null;
  465. $this->question = null;
  466. $this->logger = null;
  467. $this->qeupdater = null;
  468. }
  469. protected function to_text($html) {
  470. return $this->updater->to_text($html);
  471. }
  472. public function question_summary() {
  473. return $this->to_text($this->question->questiontext);
  474. }
  475. public function compare_answers($answer1, $answer2) {
  476. return $answer1 == $answer2;
  477. }
  478. public function is_blank_answer($state) {
  479. return $state->answer == '';
  480. }
  481. public abstract function right_answer();
  482. public abstract function response_summary($state);
  483. public abstract function was_answered($state);
  484. public abstract function set_first_step_data_elements($state, &$data);
  485. public abstract function set_data_elements_for_step($state, &$data);
  486. public abstract function supply_missing_first_step_data(&$data);
  487. }
  488. class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
  489. public function right_answer() {
  490. return '';
  491. }
  492. public function response_summary($state) {
  493. return $state->answer;
  494. }
  495. public function was_answered($state) {
  496. return !empty($state->answer);
  497. }
  498. public function set_first_step_data_elements($state, &$data) {
  499. $data['upgradedfromdeletedquestion'] = $state->answer;
  500. }
  501. public function supply_missing_first_step_data(&$data) {
  502. }
  503. public function set_data_elements_for_step($state, &$data) {
  504. $data['upgradedfromdeletedquestion'] = $state->answer;
  505. }
  506. }