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');
66 gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove.
71 $this->progressbar
->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a));
74 protected function prevent_timeout() {
76 if ($this->doingbackup
) {
80 $this->dotcounter +
= 1;
81 if ($this->dotcounter %
100 == 0) {
86 protected function get_quiz_ids() {
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)) {
101 list($test, $params) = $DB->get_in_or_equal($quizids);
102 return $DB->get_fieldset_sql("
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() {
117 $quizids = $this->get_quiz_ids();
118 if (empty($quizids)) {
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);
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) {
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("
157 FROM {question_sessions}
159 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
160 ORDER BY attemptid, questionid
163 $questionsstatesrs = $DB->get_recordset_sql("
165 FROM {question_states}
167 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
168 ORDER BY attempt, question, seq_number, id
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) {
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);
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) {
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
);
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) {
225 $layout = explode(',', $attempt->layout
);
226 $questionkeys = array_combine(array_values($layout), array_keys($layout));
228 $this->set_quba_preferred_behaviour($attempt->uniqueid
, $preferredbehaviour);
231 foreach (explode(',', $quizlayout) as $questionid) {
232 if ($questionid == 0) {
237 if (!array_key_exists($questionid, $qas)) {
238 $missing[] = $questionid;
239 $layout[$questionkeys[$questionid]] = $questionid;
243 $qa = $qas[$questionid];
244 $qa->questionusageid
= $attempt->uniqueid
;
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));
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) {
274 $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
275 array('id' => $qubaid));
278 protected function set_quiz_attempt_layout($qubaid, $layout) {
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) {
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) {
292 $newid = $DB->insert_record($table, $record, $saveid);
294 $record->id
= $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()) {
312 $qsession = $questionsessionsrs->current();
313 if ($qsession->attemptid
!= $attempt->uniqueid
) {
314 // No more question sessions belonging to this attempt.
318 // Session found, move the pointer in the RS and return the record.
319 $questionsessionsrs->next();
323 public function get_question_states($attempt, $question, moodle_recordset
$questionsstatesrs) {
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.
334 // Add the new state to the array, and advance.
335 $qstates[$state->seq_number
] = $state;
336 $questionsstatesrs->next();
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';
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();
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();
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}.");
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.
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) {
449 $question = $DB->get_record_sql("
450 SELECT q.*, qqi.grade AS maxmark
452 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
453 WHERE q.id = $questionid AND qqi.quiz = $quizid");
455 $question = $DB->get_record('question', array('id' => $questionid));
462 if (empty($question->defaultmark
)) {
463 if (!empty($question->defaultgrade
)) {
464 $question->defaultmark
= $question->defaultgrade
;
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);
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);
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) {
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
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. */
535 /** @var question_behaviour_attempt_updater */
537 /** @var question_engine_assumption_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() {
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
;