MDL-28686 QE2 upgrade: recognize manually graded, deleted questions
[moodle.git] / question / engine / upgrade / upgradelib.php
CommitLineData
0859ff65
TH
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/**
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.
20 *
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
25 */
26
27
28defined('MOODLE_INTERNAL') || die();
29
30global $CFG;
39759ac4 31require_once($CFG->dirroot . '/question/engine/bank.php');
0859ff65
TH
32require_once($CFG->dirroot . '/question/engine/upgrade/logger.php');
33require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php');
34
35
36/**
37 * This class manages upgrading all the question attempts from the old database
38 * structure to the new question engine.
39 *
40 * @copyright 2010 The Open University
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42 */
43class question_engine_attempt_upgrader {
44 /** @var question_engine_upgrade_question_loader */
45 protected $questionloader;
cd300cf3 46 /** @var question_engine_assumption_logger */
0859ff65 47 protected $logger;
cd300cf3
TH
48 /** @var int used by {@link prevent_timeout()}. */
49 protected $dotcounter = 0;
bb28e3bc
TH
50 /** @var progress_bar */
51 protected $progressbar = null;
18ab06ba
TH
52 /** @var boolean */
53 protected $doingbackup = false;
cd300cf3
TH
54
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.
60 */
0859ff65 61 protected function print_progress($done, $outof, $quizid) {
bb28e3bc
TH
62 if (is_null($this->progressbar)) {
63 $this->progressbar = new progress_bar('qe2upgrade');
08c458d1 64 $this->progressbar->create();
bb28e3bc
TH
65 }
66
cd300cf3 67 gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove.
bb28e3bc
TH
68 $a = new stdClass();
69 $a->done = $done;
69384c19 70 $a->outof = $outof;
bb28e3bc
TH
71 $a->info = $quizid;
72 $this->progressbar->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a));
0859ff65
TH
73 }
74
75 protected function prevent_timeout() {
76 set_time_limit(300);
18ab06ba
TH
77 if ($this->doingbackup) {
78 return;
79 }
cd300cf3
TH
80 echo '.';
81 $this->dotcounter += 1;
82 if ($this->dotcounter % 100 == 0) {
83 echo '<br />';
84 }
0859ff65
TH
85 }
86
87 protected function get_quiz_ids() {
10273012
TH
88 global $CFG, $DB;
89
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();
97
98 // Ignore any quiz ids that do not acually exist.
99 if (empty($quizids)) {
100 return array();
101 }
102 list($test, $params) = $DB->get_in_or_equal($quizids);
103 return $DB->get_fieldset_sql("
104 SELECT id
105 FROM {quiz}
106 WHERE id $test
107 ORDER BY id", $params);
108 }
109 }
110
111 // Otherwise, upgrade all attempts.
112 return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
0859ff65
TH
113 }
114
115 public function convert_all_quiz_attempts() {
cd300cf3
TH
116 global $DB;
117
0859ff65 118 $quizids = $this->get_quiz_ids();
cd300cf3 119 if (empty($quizids)) {
0859ff65
TH
120 return true;
121 }
122
123 $done = 0;
124 $outof = count($quizids);
125 $this->logger = new question_engine_assumption_logger();
126
10273012 127 foreach ($quizids as $quizid) {
0859ff65
TH
128 $this->print_progress($done, $outof, $quizid);
129
cd300cf3
TH
130 $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST);
131 $this->update_all_attempts_at_quiz($quiz);
0859ff65
TH
132
133 $done += 1;
134 }
135
136 $this->print_progress($outof, $outof, 'All done!');
137 $this->logger = null;
0859ff65
TH
138 }
139
cd300cf3
TH
140 public function get_attempts_extra_where() {
141 return ' AND needsupgradetonewqe = 1';
0859ff65
TH
142 }
143
cd300cf3
TH
144 public function update_all_attempts_at_quiz($quiz) {
145 global $DB;
0859ff65
TH
146
147 // Wipe question loader cache.
148 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
149
cd300cf3 150 $transaction = $DB->start_delegated_transaction();
0859ff65 151
cd300cf3
TH
152 $params = array('quizid' => $quiz->id);
153 $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where();
0859ff65 154
cd300cf3
TH
155 $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid');
156 $questionsessionsrs = $DB->get_recordset_sql("
0859ff65 157 SELECT *
cd300cf3 158 FROM {question_sessions}
0859ff65 159 WHERE attemptid IN (
cd300cf3 160 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
0859ff65 161 ORDER BY attemptid, questionid
cd300cf3 162 ", $params);
0859ff65 163
cd300cf3 164 $questionsstatesrs = $DB->get_recordset_sql("
0859ff65 165 SELECT *
cd300cf3 166 FROM {question_states}
0859ff65 167 WHERE attempt IN (
cd300cf3 168 SELECT uniqueid FROM {quiz_attempts} WHERE $where)
0859ff65 169 ORDER BY attempt, question, seq_number, id
cd300cf3 170 ", $params);
0859ff65 171
cd300cf3
TH
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);
0859ff65
TH
177 }
178
cd300cf3
TH
179 $quizattemptsrs->close();
180 $questionsessionsrs->close();
181 $questionsstatesrs->close();
0859ff65 182
cd300cf3 183 $transaction->allow_commit();
0859ff65
TH
184 }
185
cd300cf3
TH
186 protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
187 moodle_recordset $questionsstatesrs) {
0859ff65
TH
188 $qas = array();
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);
193 try {
9c197f44
TH
194 $qas[$qsession->questionid] = $this->convert_question_attempt(
195 $quiz, $attempt, $question, $qsession, $qstates);
0859ff65
TH
196 } catch (Exception $e) {
197 notify($e->getMessage());
198 }
199 }
200 $this->logger->set_current_attempt_id(null);
201
0859ff65
TH
202 $questionorder = array();
203 foreach (explode(',', $quiz->questions) as $questionid) {
204 if ($questionid == 0) {
205 continue;
206 }
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);
211 try {
1645ad22 212 $question = $this->load_question($questionid, $quiz->id);
0859ff65
TH
213 $qas[$questionid] = $this->supply_missing_question_attempt(
214 $quiz, $attempt, $question);
215 } catch (Exception $e) {
216 notify($e->getMessage());
217 }
218 }
219 }
220
221 return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
222 }
223
18ab06ba 224 public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
0859ff65 225 $missing = array();
0859ff65
TH
226
227 $layout = explode(',', $attempt->layout);
228 $questionkeys = array_combine(array_values($layout), array_keys($layout));
229
cd300cf3 230 $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
0859ff65
TH
231
232 $i = 0;
233 foreach (explode(',', $quizlayout) as $questionid) {
234 if ($questionid == 0) {
235 continue;
236 }
237 $i++;
238
239 if (!array_key_exists($questionid, $qas)) {
240 $missing[] = $questionid;
ffe4d23a 241 $layout[$questionkeys[$questionid]] = $questionid;
0859ff65
TH
242 continue;
243 }
244
245 $qa = $qas[$questionid];
246 $qa->questionusageid = $attempt->uniqueid;
247 $qa->slot = $i;
cd300cf3 248 $this->insert_record('question_attempts', $qa);
0859ff65
TH
249 $layout[$questionkeys[$questionid]] = $qa->slot;
250
251 foreach ($qa->steps as $step) {
252 $step->questionattemptid = $qa->id;
cd300cf3 253 $this->insert_record('question_attempt_steps', $step);
0859ff65
TH
254
255 foreach ($step->data as $name => $value) {
256 $datum = new stdClass();
257 $datum->attemptstepid = $step->id;
258 $datum->name = $name;
259 $datum->value = $value;
cd300cf3 260 $this->insert_record('question_attempt_step_data', $datum, false);
0859ff65
TH
261 }
262 }
263 }
264
cd300cf3 265 $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
0859ff65
TH
266
267 if ($missing) {
268 notify("Question sessions for questions " .
269 implode(', ', $missing) .
270 " were missing when upgrading question usage {$attempt->uniqueid}.");
271 }
0859ff65
TH
272 }
273
274 protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
cd300cf3
TH
275 global $DB;
276 $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
277 array('id' => $qubaid));
0859ff65
TH
278 }
279
280 protected function set_quiz_attempt_layout($qubaid, $layout) {
cd300cf3
TH
281 global $DB;
282 $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
283 $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid));
0859ff65
TH
284 }
285
286 protected function delete_quiz_attempt($qubaid) {
cd300cf3
TH
287 global $DB;
288 $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
289 $DB->delete_records('question_attempts', array('id' => $qubaid));
0859ff65
TH
290 }
291
292 protected function insert_record($table, $record, $saveid = true) {
cd300cf3
TH
293 global $DB;
294 $newid = $DB->insert_record($table, $record, $saveid);
0859ff65
TH
295 if ($saveid) {
296 $record->id = $newid;
297 }
298 return $newid;
299 }
300
301 public function load_question($questionid, $quizid = null) {
302 return $this->questionloader->get_question($questionid, $quizid);
303 }
304
667cdde3
TH
305 public function load_dataset($questionid, $selecteditem) {
306 return $this->questionloader->load_dataset($questionid, $selecteditem);
307 }
308
cd300cf3 309 public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
39759ac4
TH
310 if (!$questionsessionsrs->valid()) {
311 return false;
312 }
313
cd300cf3 314 $qsession = $questionsessionsrs->current();
39759ac4 315 if ($qsession->attemptid != $attempt->uniqueid) {
0859ff65
TH
316 // No more question sessions belonging to this attempt.
317 return false;
318 }
319
320 // Session found, move the pointer in the RS and return the record.
cd300cf3 321 $questionsessionsrs->next();
0859ff65
TH
322 return $qsession;
323 }
324
cd300cf3 325 public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
0859ff65
TH
326 $qstates = array();
327
39759ac4
TH
328 while ($questionsstatesrs->valid()) {
329 $state = $questionsstatesrs->current();
330 if ($state->attempt != $attempt->uniqueid ||
0859ff65
TH
331 $state->question != $question->id) {
332 // We have found all the states for this attempt. Stop.
333 break;
334 }
335
336 // Add the new state to the array, and advance.
ceb4d2ed 337 $qstates[] = $state;
cd300cf3 338 $questionsstatesrs->next();
0859ff65
TH
339 }
340
341 return $qstates;
342 }
343
344 protected function get_converter_class_name($question, $quiz, $qsessionid) {
987e0e91
HB
345 global $DB;
346 if ($question->qtype == 'deleted') {
347 $where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7';
348 $params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%");
349 if ($DB->record_exists_select('question_states', $where, $params)) {
350 $this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded.");
351 return 'qbehaviour_manualgraded_converter';
352 }
353 }
cd300cf3 354 if ($question->qtype == 'essay') {
0859ff65
TH
355 return 'qbehaviour_manualgraded_converter';
356 } else if ($question->qtype == 'description') {
357 return 'qbehaviour_informationitem_converter';
0859ff65
TH
358 } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
359 return 'qbehaviour_deferredfeedback_converter';
cd300cf3
TH
360 } else if ($quiz->preferredbehaviour == 'adaptive') {
361 return 'qbehaviour_adaptive_converter';
362 } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
363 return 'qbehaviour_adaptivenopenalty_converter';
0859ff65 364 } else {
cd300cf3
TH
365 throw new coding_exception("Question session {$qsessionid}
366 has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
0859ff65
TH
367 }
368 }
369
370 public function supply_missing_question_attempt($quiz, $attempt, $question) {
371 if ($question->qtype == 'random') {
cd300cf3
TH
372 throw new coding_exception("Cannot supply a missing qsession for question
373 {$question->id} in attempt {$attempt->id}.");
0859ff65
TH
374 }
375
376 $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
377
cd300cf3 378 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
667cdde3 379 null, null, $this->logger, $this);
0859ff65
TH
380 $qa = $qbehaviourupdater->supply_missing_qa();
381 $qbehaviourupdater->discard();
382 return $qa;
383 }
384
385 public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
386 $this->prevent_timeout();
387
388 if ($question->qtype == 'random') {
389 list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
390 $qsession->questionid = $question->id;
391 }
392
393 $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
394
cd300cf3 395 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
667cdde3 396 $qstates, $this->logger, $this);
0859ff65
TH
397 $qa = $qbehaviourupdater->get_converted_qa();
398 $qbehaviourupdater->discard();
399 return $qa;
400 }
401
402 protected function decode_random_attempt($qstates, $maxmark) {
403 $realquestionid = null;
404 foreach ($qstates as $i => $state) {
405 if (strpos($state->answer, '-') < 6) {
406 // Broken state, skip it.
407 $this->logger->log_assumption("Had to skip brokes state {$state->id}
408 for question {$state->question}.");
409 unset($qstates[$i]);
410 continue;
411 }
412 list($randombit, $realanswer) = explode('-', $state->answer, 2);
413 $newquestionid = substr($randombit, 6);
414 if ($realquestionid && $realquestionid != $newquestionid) {
cd300cf3
TH
415 throw new coding_exception("Question session {$this->qsession->id}
416 for random question points to two different real questions
417 {$realquestionid} and {$newquestionid}.");
0859ff65
TH
418 }
419 $qstates[$i]->answer = $realanswer;
420 }
421
422 if (empty($newquestionid)) {
423 // This attempt only had broken states. Set a fake $newquestionid to
424 // prevent a null DB error later.
425 $newquestionid = 0;
426 }
427
428 $newquestion = $this->load_question($newquestionid);
429 $newquestion->maxmark = $maxmark;
430 return array($newquestion, $qstates);
431 }
18ab06ba
TH
432
433 public function prepare_to_restore() {
434 $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade.
435 $this->logger = new dummy_question_engine_assumption_logger();
436 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
437 }
0859ff65
TH
438}
439
440
441/**
442 * This class deals with loading (and caching) question definitions during the
443 * question engine upgrade.
444 *
445 * @copyright 2010 The Open University
446 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
447 */
448class question_engine_upgrade_question_loader {
449 private $cache = array();
667cdde3 450 private $datasetcache = array();
0859ff65
TH
451
452 public function __construct($logger) {
453 $this->logger = $logger;
454 }
455
456 protected function load_question($questionid, $quizid) {
cd300cf3 457 global $DB;
0859ff65
TH
458
459 if ($quizid) {
cd300cf3 460 $question = $DB->get_record_sql("
0859ff65 461 SELECT q.*, qqi.grade AS maxmark
cd300cf3
TH
462 FROM {question} q
463 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
0859ff65
TH
464 WHERE q.id = $questionid AND qqi.quiz = $quizid");
465 } else {
cd300cf3 466 $question = $DB->get_record('question', array('id' => $questionid));
0859ff65
TH
467 }
468
469 if (!$question) {
470 return null;
471 }
472
473 if (empty($question->defaultmark)) {
474 if (!empty($question->defaultgrade)) {
475 $question->defaultmark = $question->defaultgrade;
476 } else {
477 $question->defaultmark = 0;
478 }
479 unset($question->defaultgrade);
480 }
481
482 $qtype = question_bank::get_qtype($question->qtype, false);
483 if ($qtype->name() === 'missingtype') {
484 $this->logger->log_assumption("Dealing with question id {$question->id}
485 that is of an unknown type {$question->qtype}.");
cd300cf3
TH
486 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
487 '</p>' . $question->questiontext;
0859ff65
TH
488 }
489
490 $qtype->get_question_options($question);
491
492 return $question;
493 }
494
495 public function get_question($questionid, $quizid) {
496 if (isset($this->cache[$questionid])) {
497 return $this->cache[$questionid];
498 }
499
500 $question = $this->load_question($questionid, $quizid);
501
502 if (!$question) {
503 $this->logger->log_assumption("Dealing with question id {$questionid}
504 that was missing from the database.");
505 $question = new stdClass();
506 $question->id = $questionid;
507 $question->qtype = 'deleted';
508 $question->maxmark = 1; // Guess, but that is all we can do.
509 $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
510 }
511
512 $this->cache[$questionid] = $question;
513 return $this->cache[$questionid];
514 }
667cdde3
TH
515
516 public function load_dataset($questionid, $selecteditem) {
517 global $DB;
518
519 if (isset($this->datasetcache[$questionid][$selecteditem])) {
520 return $this->datasetcache[$questionid][$selecteditem];
521 }
522
523 $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
524 SELECT qdd.name, qdi.value
525 FROM {question_dataset_items} qdi
526 JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
527 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
528 WHERE qd.question = ?
529 AND qdi.itemnumber = ?
530 ', array($questionid, $selecteditem));
531 return $this->datasetcache[$questionid][$selecteditem];
532 }
0859ff65
TH
533}
534
535
cd300cf3
TH
536/**
537 * Base class for the classes that convert the question-type specific bits of
538 * the attempt data.
539 *
540 * @copyright 2010 The Open University
541 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
542 */
0859ff65 543abstract class question_qtype_attempt_updater {
cd300cf3 544 /** @var object the question definition data. */
0859ff65 545 protected $question;
cd300cf3 546 /** @var question_behaviour_attempt_updater */
0859ff65
TH
547 protected $updater;
548 /** @var question_engine_assumption_logger */
549 protected $logger;
667cdde3
TH
550 /** @var question_engine_attempt_upgrader */
551 protected $qeupdater;
0859ff65 552
667cdde3 553 public function __construct($updater, $question, $logger, $qeupdater) {
0859ff65
TH
554 $this->updater = $updater;
555 $this->question = $question;
556 $this->logger = $logger;
667cdde3 557 $this->qeupdater = $qeupdater;
0859ff65
TH
558 }
559
560 public function discard() {
561 // Help the garbage collector, which seems to be struggling.
562 $this->updater = null;
563 $this->question = null;
564 $this->logger = null;
667cdde3 565 $this->qeupdater = null;
0859ff65
TH
566 }
567
568 protected function to_text($html) {
569 return $this->updater->to_text($html);
570 }
571
572 public function question_summary() {
573 return $this->to_text($this->question->questiontext);
574 }
575
576 public function compare_answers($answer1, $answer2) {
577 return $answer1 == $answer2;
578 }
579
fbf51a48
HB
580 public function is_blank_answer($state) {
581 return $state->answer == '';
582 }
d12d4d59 583
0859ff65
TH
584 public abstract function right_answer();
585 public abstract function response_summary($state);
586 public abstract function was_answered($state);
587 public abstract function set_first_step_data_elements($state, &$data);
588 public abstract function set_data_elements_for_step($state, &$data);
589 public abstract function supply_missing_first_step_data(&$data);
590}
591
592
593class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
594 public function right_answer() {
595 return '';
596 }
597
598 public function response_summary($state) {
599 return $state->answer;
600 }
601
602 public function was_answered($state) {
603 return !empty($state->answer);
604 }
605
606 public function set_first_step_data_elements($state, &$data) {
607 $data['upgradedfromdeletedquestion'] = $state->answer;
608 }
609
610 public function supply_missing_first_step_data(&$data) {
611 }
612
613 public function set_data_elements_for_step($state, &$data) {
614 $data['upgradedfromdeletedquestion'] = $state->answer;
615 }
616}