MDL-27639 restore of attempt data from 2.0 - first attempt.
[moodle.git] / question / engine / upgrade / upgradelib.php
blobdc63af233c4525582294797cde853452b4f2dc00
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.
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/>.
17 /**
18 * This file contains the code required to upgrade all the attempt data from
19 * old versions of Moodle into the tables used by the new question engine.
21 * @package moodlecore
22 * @subpackage questionengine
23 * @copyright 2010 The Open University
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28 defined('MOODLE_INTERNAL') || die();
30 global $CFG;
31 require_once($CFG->dirroot . '/question/engine/bank.php');
32 require_once($CFG->dirroot . '/question/engine/upgrade/logger.php');
33 require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php');
36 /**
37 * This class manages upgrading all the question attempts from the old database
38 * structure to the new question engine.
40 * @copyright 2010 The Open University
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43 class question_engine_attempt_upgrader {
44 /** @var question_engine_upgrade_question_loader */
45 protected $questionloader;
46 /** @var question_engine_assumption_logger */
47 protected $logger;
48 /** @var int used by {@link prevent_timeout()}. */
49 protected $dotcounter = 0;
50 /** @var progress_bar */
51 protected $progressbar = null;
52 /** @var boolean */
53 protected $doingbackup = false;
55 /**
56 * Called before starting to upgrade all the attempts at a particular quiz.
57 * @param int $done the number of quizzes processed so far.
58 * @param int $outof the total number of quizzes to process.
59 * @param int $quizid the id of the quiz that is about to be processed.
61 protected function print_progress($done, $outof, $quizid) {
62 if (is_null($this->progressbar)) {
63 $this->progressbar = new progress_bar('qe2upgrade');
66 gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove.
67 $a = new stdClass();
68 $a->done = $done;
69 $a->todo = $outof;
70 $a->info = $quizid;
71 $this->progressbar->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a));
74 protected function prevent_timeout() {
75 set_time_limit(300);
76 if ($this->doingbackup) {
77 return;
79 echo '.';
80 $this->dotcounter += 1;
81 if ($this->dotcounter % 100 == 0) {
82 echo '<br />';
86 protected function get_quiz_ids() {
87 global $CFG, $DB;
89 // Look to see if the admin has set things up to only upgrade certain attempts.
90 $partialupgradefile = $CFG->dirroot . '/local/qeupgradehelper/partialupgrade.php';
91 $partialupgradefunction = 'local_qeupgradehelper_get_quizzes_to_upgrade';
92 if (is_readable($partialupgradefile)) {
93 include_once($partialupgradefile);
94 if (function_exists($partialupgradefunction)) {
95 $quizids = $partialupgradefunction();
97 // Ignore any quiz ids that do not acually exist.
98 if (empty($quizids)) {
99 return array();
101 list($test, $params) = $DB->get_in_or_equal($quizids);
102 return $DB->get_fieldset_sql("
103 SELECT id
104 FROM {quiz}
105 WHERE id $test
106 ORDER BY id", $params);
110 // Otherwise, upgrade all attempts.
111 return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
114 public function convert_all_quiz_attempts() {
115 global $DB;
117 $quizids = $this->get_quiz_ids();
118 if (empty($quizids)) {
119 return true;
122 $done = 0;
123 $outof = count($quizids);
124 $this->logger = new question_engine_assumption_logger();
126 foreach ($quizids as $quizid) {
127 $this->print_progress($done, $outof, $quizid);
129 $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST);
130 $this->update_all_attempts_at_quiz($quiz);
132 $done += 1;
135 $this->print_progress($outof, $outof, 'All done!');
136 $this->logger = null;
139 public function get_attempts_extra_where() {
140 return ' AND needsupgradetonewqe = 1';
143 public function update_all_attempts_at_quiz($quiz) {
144 global $DB;
146 // Wipe question loader cache.
147 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
149 $transaction = $DB->start_delegated_transaction();
151 $params = array('quizid' => $quiz->id);
152 $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where();
154 $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid');
155 $questionsessionsrs = $DB->get_recordset_sql("
156 SELECT *
157 FROM {question_sessions}
158 WHERE attemptid IN (
159 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
160 ORDER BY attemptid, questionid
161 ", $params);
163 $questionsstatesrs = $DB->get_recordset_sql("
164 SELECT *
165 FROM {question_states}
166 WHERE attempt IN (
167 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
168 ORDER BY attempt, question, seq_number, id
169 ", $params);
171 $datatodo = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs;
172 while ($datatodo && $quizattemptsrs->valid()) {
173 $attempt = $quizattemptsrs->current();
174 $quizattemptsrs->next();
175 $this->convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs);
178 $quizattemptsrs->close();
179 $questionsessionsrs->close();
180 $questionsstatesrs->close();
182 $transaction->allow_commit();
185 protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
186 moodle_recordset $questionsstatesrs) {
187 $qas = array();
188 $this->logger->set_current_attempt_id($attempt->id);
189 while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) {
190 $question = $this->load_question($qsession->questionid, $quiz->id);
191 $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs);
192 try {
193 $qas[$qsession->questionid] = $this->convert_question_attempt(
194 $quiz, $attempt, $question, $qsession, $qstates);
195 } catch (Exception $e) {
196 notify($e->getMessage());
199 $this->logger->set_current_attempt_id(null);
201 $questionorder = array();
202 foreach (explode(',', $quiz->questions) as $questionid) {
203 if ($questionid == 0) {
204 continue;
206 if (!array_key_exists($questionid, $qas)) {
207 $this->logger->log_assumption("Supplying minimal open state for
208 question {$questionid} in attempt {$attempt->id} at quiz
209 {$attempt->quiz}, since the session was missing.", $attempt->id);
210 try {
211 $qas[$questionid] = $this->supply_missing_question_attempt(
212 $quiz, $attempt, $question);
213 } catch (Exception $e) {
214 notify($e->getMessage());
219 return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
222 public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
223 $missing = array();
225 $layout = explode(',', $attempt->layout);
226 $questionkeys = array_combine(array_values($layout), array_keys($layout));
228 $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
230 $i = 0;
231 foreach (explode(',', $quizlayout) as $questionid) {
232 if ($questionid == 0) {
233 continue;
235 $i++;
237 if (!array_key_exists($questionid, $qas)) {
238 $missing[] = $questionid;
239 $layout[$questionkeys[$questionid]] = $questionid;
240 continue;
243 $qa = $qas[$questionid];
244 $qa->questionusageid = $attempt->uniqueid;
245 $qa->slot = $i;
246 $this->insert_record('question_attempts', $qa);
247 $layout[$questionkeys[$questionid]] = $qa->slot;
249 foreach ($qa->steps as $step) {
250 $step->questionattemptid = $qa->id;
251 $this->insert_record('question_attempt_steps', $step);
253 foreach ($step->data as $name => $value) {
254 $datum = new stdClass();
255 $datum->attemptstepid = $step->id;
256 $datum->name = $name;
257 $datum->value = $value;
258 $this->insert_record('question_attempt_step_data', $datum, false);
263 $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
265 if ($missing) {
266 notify("Question sessions for questions " .
267 implode(', ', $missing) .
268 " were missing when upgrading question usage {$attempt->uniqueid}.");
272 protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
273 global $DB;
274 $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
275 array('id' => $qubaid));
278 protected function set_quiz_attempt_layout($qubaid, $layout) {
279 global $DB;
280 $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
281 $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid));
284 protected function delete_quiz_attempt($qubaid) {
285 global $DB;
286 $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
287 $DB->delete_records('question_attempts', array('id' => $qubaid));
290 protected function insert_record($table, $record, $saveid = true) {
291 global $DB;
292 $newid = $DB->insert_record($table, $record, $saveid);
293 if ($saveid) {
294 $record->id = $newid;
296 return $newid;
299 public function load_question($questionid, $quizid = null) {
300 return $this->questionloader->get_question($questionid, $quizid);
303 public function load_dataset($questionid, $selecteditem) {
304 return $this->questionloader->load_dataset($questionid, $selecteditem);
307 public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
308 if (!$questionsessionsrs->valid()) {
309 return false;
312 $qsession = $questionsessionsrs->current();
313 if ($qsession->attemptid != $attempt->uniqueid) {
314 // No more question sessions belonging to this attempt.
315 return false;
318 // Session found, move the pointer in the RS and return the record.
319 $questionsessionsrs->next();
320 return $qsession;
323 public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
324 $qstates = array();
326 while ($questionsstatesrs->valid()) {
327 $state = $questionsstatesrs->current();
328 if ($state->attempt != $attempt->uniqueid ||
329 $state->question != $question->id) {
330 // We have found all the states for this attempt. Stop.
331 break;
334 // Add the new state to the array, and advance.
335 $qstates[$state->seq_number] = $state;
336 $questionsstatesrs->next();
339 return $qstates;
342 protected function get_converter_class_name($question, $quiz, $qsessionid) {
343 if ($question->qtype == 'essay') {
344 return 'qbehaviour_manualgraded_converter';
345 } else if ($question->qtype == 'description') {
346 return 'qbehaviour_informationitem_converter';
347 } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
348 return 'qbehaviour_deferredfeedback_converter';
349 } else if ($quiz->preferredbehaviour == 'adaptive') {
350 return 'qbehaviour_adaptive_converter';
351 } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
352 return 'qbehaviour_adaptivenopenalty_converter';
353 } else {
354 throw new coding_exception("Question session {$qsessionid}
355 has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
359 public function supply_missing_question_attempt($quiz, $attempt, $question) {
360 if ($question->qtype == 'random') {
361 throw new coding_exception("Cannot supply a missing qsession for question
362 {$question->id} in attempt {$attempt->id}.");
365 $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
367 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
368 null, null, $this->logger, $this);
369 $qa = $qbehaviourupdater->supply_missing_qa();
370 $qbehaviourupdater->discard();
371 return $qa;
374 public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
375 $this->prevent_timeout();
377 if ($question->qtype == 'random') {
378 list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
379 $qsession->questionid = $question->id;
382 $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
384 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
385 $qstates, $this->logger, $this);
386 $qa = $qbehaviourupdater->get_converted_qa();
387 $qbehaviourupdater->discard();
388 return $qa;
391 protected function decode_random_attempt($qstates, $maxmark) {
392 $realquestionid = null;
393 foreach ($qstates as $i => $state) {
394 if (strpos($state->answer, '-') < 6) {
395 // Broken state, skip it.
396 $this->logger->log_assumption("Had to skip brokes state {$state->id}
397 for question {$state->question}.");
398 unset($qstates[$i]);
399 continue;
401 list($randombit, $realanswer) = explode('-', $state->answer, 2);
402 $newquestionid = substr($randombit, 6);
403 if ($realquestionid && $realquestionid != $newquestionid) {
404 throw new coding_exception("Question session {$this->qsession->id}
405 for random question points to two different real questions
406 {$realquestionid} and {$newquestionid}.");
408 $qstates[$i]->answer = $realanswer;
411 if (empty($newquestionid)) {
412 // This attempt only had broken states. Set a fake $newquestionid to
413 // prevent a null DB error later.
414 $newquestionid = 0;
417 $newquestion = $this->load_question($newquestionid);
418 $newquestion->maxmark = $maxmark;
419 return array($newquestion, $qstates);
422 public function prepare_to_restore() {
423 $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade.
424 $this->logger = new dummy_question_engine_assumption_logger();
425 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
431 * This class deals with loading (and caching) question definitions during the
432 * question engine upgrade.
434 * @copyright 2010 The Open University
435 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
437 class question_engine_upgrade_question_loader {
438 private $cache = array();
439 private $datasetcache = array();
441 public function __construct($logger) {
442 $this->logger = $logger;
445 protected function load_question($questionid, $quizid) {
446 global $DB;
448 if ($quizid) {
449 $question = $DB->get_record_sql("
450 SELECT q.*, qqi.grade AS maxmark
451 FROM {question} q
452 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
453 WHERE q.id = $questionid AND qqi.quiz = $quizid");
454 } else {
455 $question = $DB->get_record('question', array('id' => $questionid));
458 if (!$question) {
459 return null;
462 if (empty($question->defaultmark)) {
463 if (!empty($question->defaultgrade)) {
464 $question->defaultmark = $question->defaultgrade;
465 } else {
466 $question->defaultmark = 0;
468 unset($question->defaultgrade);
471 $qtype = question_bank::get_qtype($question->qtype, false);
472 if ($qtype->name() === 'missingtype') {
473 $this->logger->log_assumption("Dealing with question id {$question->id}
474 that is of an unknown type {$question->qtype}.");
475 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
476 '</p>' . $question->questiontext;
479 $qtype->get_question_options($question);
481 return $question;
484 public function get_question($questionid, $quizid) {
485 if (isset($this->cache[$questionid])) {
486 return $this->cache[$questionid];
489 $question = $this->load_question($questionid, $quizid);
491 if (!$question) {
492 $this->logger->log_assumption("Dealing with question id {$questionid}
493 that was missing from the database.");
494 $question = new stdClass();
495 $question->id = $questionid;
496 $question->qtype = 'deleted';
497 $question->maxmark = 1; // Guess, but that is all we can do.
498 $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
501 $this->cache[$questionid] = $question;
502 return $this->cache[$questionid];
505 public function load_dataset($questionid, $selecteditem) {
506 global $DB;
508 if (isset($this->datasetcache[$questionid][$selecteditem])) {
509 return $this->datasetcache[$questionid][$selecteditem];
512 $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
513 SELECT qdd.name, qdi.value
514 FROM {question_dataset_items} qdi
515 JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
516 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
517 WHERE qd.question = ?
518 AND qdi.itemnumber = ?
519 ', array($questionid, $selecteditem));
520 return $this->datasetcache[$questionid][$selecteditem];
526 * Base class for the classes that convert the question-type specific bits of
527 * the attempt data.
529 * @copyright 2010 The Open University
530 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
532 abstract class question_qtype_attempt_updater {
533 /** @var object the question definition data. */
534 protected $question;
535 /** @var question_behaviour_attempt_updater */
536 protected $updater;
537 /** @var question_engine_assumption_logger */
538 protected $logger;
539 /** @var question_engine_attempt_upgrader */
540 protected $qeupdater;
542 public function __construct($updater, $question, $logger, $qeupdater) {
543 $this->updater = $updater;
544 $this->question = $question;
545 $this->logger = $logger;
546 $this->qeupdater = $qeupdater;
549 public function discard() {
550 // Help the garbage collector, which seems to be struggling.
551 $this->updater = null;
552 $this->question = null;
553 $this->logger = null;
554 $this->qeupdater = null;
557 protected function to_text($html) {
558 return $this->updater->to_text($html);
561 public function question_summary() {
562 return $this->to_text($this->question->questiontext);
565 public function compare_answers($answer1, $answer2) {
566 return $answer1 == $answer2;
569 public abstract function right_answer();
570 public abstract function response_summary($state);
571 public abstract function was_answered($state);
572 public abstract function set_first_step_data_elements($state, &$data);
573 public abstract function set_data_elements_for_step($state, &$data);
574 public abstract function supply_missing_first_step_data(&$data);
578 class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
579 public function right_answer() {
580 return '';
583 public function response_summary($state) {
584 return $state->answer;
587 public function was_answered($state) {
588 return !empty($state->answer);
591 public function set_first_step_data_elements($state, &$data) {
592 $data['upgradedfromdeletedquestion'] = $state->answer;
595 public function supply_missing_first_step_data(&$data) {
598 public function set_data_elements_for_step($state, &$data) {
599 $data['upgradedfromdeletedquestion'] = $state->answer;