2 // This file is part of Moodle - http://moodle.org/
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.
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/>.
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.
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();
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');
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 */
48 /** @var int used by {@link prevent_timeout()}. */
49 protected $dotcounter = 0;
50 /** @var progress_bar */
51 protected $progressbar = null;
53 protected $doingbackup = false;
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');
64 $this->progressbar->create();
67 gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove.
72 $this->progressbar->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a));
75 protected function prevent_timeout() {
77 if ($this->doingbackup) {
81 $this->dotcounter += 1;
82 if ($this->dotcounter % 100 == 0) {
87 protected function get_quiz_ids() {
90 // Look to see if the admin has set things up to only upgrade certain attempts.
91 $partialupgradefile = $CFG->dirroot . '/local/qeupgradehelper/partialupgrade.php';
92 $partialupgradefunction = 'local_qeupgradehelper_get_quizzes_to_upgrade';
93 if (is_readable($partialupgradefile)) {
94 include_once($partialupgradefile);
95 if (function_exists($partialupgradefunction)) {
96 $quizids = $partialupgradefunction();
98 // Ignore any quiz ids that do not acually exist.
99 if (empty($quizids)) {
102 list($test, $params) = $DB->get_in_or_equal($quizids);
103 return $DB->get_fieldset_sql("
107 ORDER BY id", $params);
111 // Otherwise, upgrade all attempts.
112 return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
115 public function convert_all_quiz_attempts() {
118 $quizids = $this->get_quiz_ids();
119 if (empty($quizids)) {
124 $outof = count($quizids);
125 $this->logger = new question_engine_assumption_logger();
127 foreach ($quizids as $quizid) {
128 $this->print_progress($done, $outof, $quizid);
130 $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST);
131 $this->update_all_attempts_at_quiz($quiz);
136 $this->print_progress($outof, $outof, 'All done!');
137 $this->logger = null;
140 public function get_attempts_extra_where() {
141 return ' AND needsupgradetonewqe = 1';
144 public function update_all_attempts_at_quiz($quiz) {
147 // Wipe question loader cache.
148 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
150 $transaction = $DB->start_delegated_transaction();
152 $params = array('quizid' => $quiz->id);
153 $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where();
155 $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid');
156 $questionsessionsrs = $DB->get_recordset_sql("
158 FROM {question_sessions}
160 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
161 ORDER BY attemptid, questionid
164 $questionsstatesrs = $DB->get_recordset_sql("
166 FROM {question_states}
168 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
169 ORDER BY attempt, question, seq_number, id
172 $datatodo = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs;
173 while ($datatodo && $quizattemptsrs->valid()) {
174 $attempt = $quizattemptsrs->current();
175 $quizattemptsrs->next();
176 $this->convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs);
179 $quizattemptsrs->close();
180 $questionsessionsrs->close();
181 $questionsstatesrs->close();
183 $transaction->allow_commit();
186 protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
187 moodle_recordset $questionsstatesrs) {
189 $this->logger->set_current_attempt_id($attempt->id);
190 while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) {
191 $question = $this->load_question($qsession->questionid, $quiz->id);
192 $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs);
194 $qas[$qsession->questionid] = $this->convert_question_attempt(
195 $quiz, $attempt, $question, $qsession, $qstates);
196 } catch (Exception $e) {
197 notify($e->getMessage());
200 $this->logger->set_current_attempt_id(null);
202 $questionorder = array();
203 foreach (explode(',', $quiz->questions) as $questionid) {
204 if ($questionid == 0) {
207 if (!array_key_exists($questionid, $qas)) {
208 $this->logger->log_assumption("Supplying minimal open state for
209 question {$questionid} in attempt {$attempt->id} at quiz
210 {$attempt->quiz}, since the session was missing.", $attempt->id);
212 $question = $this->load_question($questionid, $quiz->id);
213 $qas[$questionid] = $this->supply_missing_question_attempt(
214 $quiz, $attempt, $question);
215 } catch (Exception $e) {
216 notify($e->getMessage());
221 return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
224 public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
227 $layout = explode(',', $attempt->layout);
228 $questionkeys = array_combine(array_values($layout), array_keys($layout));
230 $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
233 foreach (explode(',', $quizlayout) as $questionid) {
234 if ($questionid == 0) {
239 if (!array_key_exists($questionid, $qas)) {
240 $missing[] = $questionid;
241 $layout[$questionkeys[$questionid]] = $questionid;
245 $qa = $qas[$questionid];
246 $qa->questionusageid = $attempt->uniqueid;
248 $this->insert_record('question_attempts', $qa);
249 $layout[$questionkeys[$questionid]] = $qa->slot;
251 foreach ($qa->steps as $step) {
252 $step->questionattemptid = $qa->id;
253 $this->insert_record('question_attempt_steps', $step);
255 foreach ($step->data as $name => $value) {
256 $datum = new stdClass();
257 $datum->attemptstepid = $step->id;
258 $datum->name = $name;
259 $datum->value = $value;
260 $this->insert_record('question_attempt_step_data', $datum, false);
265 $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
268 notify("Question sessions for questions " .
269 implode(', ', $missing) .
270 " were missing when upgrading question usage {$attempt->uniqueid}.");
274 protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
276 $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
277 array('id' => $qubaid));
280 protected function set_quiz_attempt_layout($qubaid, $layout) {
282 $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
283 $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid));
286 protected function delete_quiz_attempt($qubaid) {
288 $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
289 $DB->delete_records('question_attempts', array('id' => $qubaid));
292 protected function insert_record($table, $record, $saveid = true) {
294 $newid = $DB->insert_record($table, $record, $saveid);
296 $record->id = $newid;
301 public function load_question($questionid, $quizid = null) {
302 return $this->questionloader->get_question($questionid, $quizid);
305 public function load_dataset($questionid, $selecteditem) {
306 return $this->questionloader->load_dataset($questionid, $selecteditem);
309 public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
310 if (!$questionsessionsrs->valid()) {
314 $qsession = $questionsessionsrs->current();
315 if ($qsession->attemptid != $attempt->uniqueid) {
316 // No more question sessions belonging to this attempt.
320 // Session found, move the pointer in the RS and return the record.
321 $questionsessionsrs->next();
325 public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
328 while ($questionsstatesrs->valid()) {
329 $state = $questionsstatesrs->current();
330 if ($state->attempt != $attempt->uniqueid ||
331 $state->question != $question->id) {
332 // We have found all the states for this attempt. Stop.
336 // Add the new state to the array, and advance.
338 $questionsstatesrs->next();
344 protected function get_converter_class_name($question, $quiz, $qsessionid) {
345 if ($question->qtype == 'essay') {
346 return 'qbehaviour_manualgraded_converter';
347 } else if ($question->qtype == 'description') {
348 return 'qbehaviour_informationitem_converter';
349 } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
350 return 'qbehaviour_deferredfeedback_converter';
351 } else if ($quiz->preferredbehaviour == 'adaptive') {
352 return 'qbehaviour_adaptive_converter';
353 } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
354 return 'qbehaviour_adaptivenopenalty_converter';
356 throw new coding_exception("Question session {$qsessionid}
357 has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
361 public function supply_missing_question_attempt($quiz, $attempt, $question) {
362 if ($question->qtype == 'random') {
363 throw new coding_exception("Cannot supply a missing qsession for question
364 {$question->id} in attempt {$attempt->id}.");
367 $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
369 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
370 null, null, $this->logger, $this);
371 $qa = $qbehaviourupdater->supply_missing_qa();
372 $qbehaviourupdater->discard();
376 public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
377 $this->prevent_timeout();
379 if ($question->qtype == 'random') {
380 list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
381 $qsession->questionid = $question->id;
384 $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
386 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
387 $qstates, $this->logger, $this);
388 $qa = $qbehaviourupdater->get_converted_qa();
389 $qbehaviourupdater->discard();
393 protected function decode_random_attempt($qstates, $maxmark) {
394 $realquestionid = null;
395 foreach ($qstates as $i => $state) {
396 if (strpos($state->answer, '-') < 6) {
397 // Broken state, skip it.
398 $this->logger->log_assumption("Had to skip brokes state {$state->id}
399 for question {$state->question}.");
403 list($randombit, $realanswer) = explode('-', $state->answer, 2);
404 $newquestionid = substr($randombit, 6);
405 if ($realquestionid && $realquestionid != $newquestionid) {
406 throw new coding_exception("Question session {$this->qsession->id}
407 for random question points to two different real questions
408 {$realquestionid} and {$newquestionid}.");
410 $qstates[$i]->answer = $realanswer;
413 if (empty($newquestionid)) {
414 // This attempt only had broken states. Set a fake $newquestionid to
415 // prevent a null DB error later.
419 $newquestion = $this->load_question($newquestionid);
420 $newquestion->maxmark = $maxmark;
421 return array($newquestion, $qstates);
424 public function prepare_to_restore() {
425 $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade.
426 $this->logger = new dummy_question_engine_assumption_logger();
427 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
433 * This class deals with loading (and caching) question definitions during the
434 * question engine upgrade.
436 * @copyright 2010 The Open University
437 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
439 class question_engine_upgrade_question_loader {
440 private $cache = array();
441 private $datasetcache = array();
443 public function __construct($logger) {
444 $this->logger = $logger;
447 protected function load_question($questionid, $quizid) {
451 $question = $DB->get_record_sql("
452 SELECT q.*, qqi.grade AS maxmark
454 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
455 WHERE q.id = $questionid AND qqi.quiz = $quizid");
457 $question = $DB->get_record('question', array('id' => $questionid));
464 if (empty($question->defaultmark)) {
465 if (!empty($question->defaultgrade)) {
466 $question->defaultmark = $question->defaultgrade;
468 $question->defaultmark = 0;
470 unset($question->defaultgrade);
473 $qtype = question_bank::get_qtype($question->qtype, false);
474 if ($qtype->name() === 'missingtype') {
475 $this->logger->log_assumption("Dealing with question id {$question->id}
476 that is of an unknown type {$question->qtype}.");
477 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
478 '</p>' . $question->questiontext;
481 $qtype->get_question_options($question);
486 public function get_question($questionid, $quizid) {
487 if (isset($this->cache[$questionid])) {
488 return $this->cache[$questionid];
491 $question = $this->load_question($questionid, $quizid);
494 $this->logger->log_assumption("Dealing with question id {$questionid}
495 that was missing from the database.");
496 $question = new stdClass();
497 $question->id = $questionid;
498 $question->qtype = 'deleted';
499 $question->maxmark = 1; // Guess, but that is all we can do.
500 $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
503 $this->cache[$questionid] = $question;
504 return $this->cache[$questionid];
507 public function load_dataset($questionid, $selecteditem) {
510 if (isset($this->datasetcache[$questionid][$selecteditem])) {
511 return $this->datasetcache[$questionid][$selecteditem];
514 $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
515 SELECT qdd.name, qdi.value
516 FROM {question_dataset_items} qdi
517 JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
518 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
519 WHERE qd.question = ?
520 AND qdi.itemnumber = ?
521 ', array($questionid, $selecteditem));
522 return $this->datasetcache[$questionid][$selecteditem];
528 * Base class for the classes that convert the question-type specific bits of
531 * @copyright 2010 The Open University
532 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
534 abstract class question_qtype_attempt_updater {
535 /** @var object the question definition data. */
537 /** @var question_behaviour_attempt_updater */
539 /** @var question_engine_assumption_logger */
541 /** @var question_engine_attempt_upgrader */
542 protected $qeupdater;
544 public function __construct($updater, $question, $logger, $qeupdater) {
545 $this->updater = $updater;
546 $this->question = $question;
547 $this->logger = $logger;
548 $this->qeupdater = $qeupdater;
551 public function discard() {
552 // Help the garbage collector, which seems to be struggling.
553 $this->updater = null;
554 $this->question = null;
555 $this->logger = null;
556 $this->qeupdater = null;
559 protected function to_text($html) {
560 return $this->updater->to_text($html);
563 public function question_summary() {
564 return $this->to_text($this->question->questiontext);
567 public function compare_answers($answer1, $answer2) {
568 return $answer1 == $answer2;
571 public function is_blank_answer($state) {
572 return $state->answer == '';
575 public abstract function right_answer();
576 public abstract function response_summary($state);
577 public abstract function was_answered($state);
578 public abstract function set_first_step_data_elements($state, &$data);
579 public abstract function set_data_elements_for_step($state, &$data);
580 public abstract function supply_missing_first_step_data(&$data);
584 class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
585 public function right_answer() {
589 public function response_summary($state) {
590 return $state->answer;
593 public function was_answered($state) {
594 return !empty($state->answer);
597 public function set_first_step_data_elements($state, &$data) {
598 $data['upgradedfromdeletedquestion'] = $state->answer;
601 public function supply_missing_first_step_data(&$data) {
604 public function set_data_elements_for_step($state, &$data) {
605 $data['upgradedfromdeletedquestion'] = $state->answer;