MDL-28686 QE2 upgrade: recognize manually graded, deleted questions
[moodle.git] / question / engine / upgrade / upgradelib.php
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/>.
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  */
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.
39  *
40  * @copyright  2010 The Open University
41  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42  */
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.
60      */
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();
65         }
67         gc_collect_cycles(); // This was really helpful in PHP 5.2. Perhaps remove.
68         $a = new stdClass();
69         $a->done = $done;
70         $a->outof = $outof;
71         $a->info = $quizid;
72         $this->progressbar->update($done, $outof, get_string('upgradingquizattempts', 'quiz', $a));
73     }
75     protected function prevent_timeout() {
76         set_time_limit(300);
77         if ($this->doingbackup) {
78             return;
79         }
80         echo '.';
81         $this->dotcounter += 1;
82         if ($this->dotcounter % 100 == 0) {
83             echo '<br />';
84         }
85     }
87     protected function get_quiz_ids() {
88         global $CFG, $DB;
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)) {
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         }
111         // Otherwise, upgrade all attempts.
112         return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
113     }
115     public function convert_all_quiz_attempts() {
116         global $DB;
118         $quizids = $this->get_quiz_ids();
119         if (empty($quizids)) {
120             return true;
121         }
123         $done = 0;
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);
133             $done += 1;
134         }
136         $this->print_progress($outof, $outof, 'All done!');
137         $this->logger = null;
138     }
140     public function get_attempts_extra_where() {
141         return ' AND needsupgradetonewqe = 1';
142     }
144     public function update_all_attempts_at_quiz($quiz) {
145         global $DB;
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("
157                 SELECT *
158                 FROM {question_sessions}
159                 WHERE attemptid IN (
160                     SELECT uniqueid FROM {quiz_attempts} WHERE $where)
161                 ORDER BY attemptid, questionid
162         ", $params);
164         $questionsstatesrs = $DB->get_recordset_sql("
165                 SELECT *
166                 FROM {question_states}
167                 WHERE attempt IN (
168                     SELECT uniqueid FROM {quiz_attempts} WHERE $where)
169                 ORDER BY attempt, question, seq_number, id
170         ", $params);
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);
177         }
179         $quizattemptsrs->close();
180         $questionsessionsrs->close();
181         $questionsstatesrs->close();
183         $transaction->allow_commit();
184     }
186     protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
187             moodle_recordset $questionsstatesrs) {
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 {
194                 $qas[$qsession->questionid] = $this->convert_question_attempt(
195                         $quiz, $attempt, $question, $qsession, $qstates);
196             } catch (Exception $e) {
197                 notify($e->getMessage());
198             }
199         }
200         $this->logger->set_current_attempt_id(null);
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 {
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());
217                 }
218             }
219         }
221         return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
222     }
224     public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
225         $missing = array();
227         $layout = explode(',', $attempt->layout);
228         $questionkeys = array_combine(array_values($layout), array_keys($layout));
230         $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
232         $i = 0;
233         foreach (explode(',', $quizlayout) as $questionid) {
234             if ($questionid == 0) {
235                 continue;
236             }
237             $i++;
239             if (!array_key_exists($questionid, $qas)) {
240                 $missing[] = $questionid;
241                 $layout[$questionkeys[$questionid]] = $questionid;
242                 continue;
243             }
245             $qa = $qas[$questionid];
246             $qa->questionusageid = $attempt->uniqueid;
247             $qa->slot = $i;
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);
261                 }
262             }
263         }
265         $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
267         if ($missing) {
268             notify("Question sessions for questions " .
269                     implode(', ', $missing) .
270                     " were missing when upgrading question usage {$attempt->uniqueid}.");
271         }
272     }
274     protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
275         global $DB;
276         $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
277                 array('id' => $qubaid));
278     }
280     protected function set_quiz_attempt_layout($qubaid, $layout) {
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));
284     }
286     protected function delete_quiz_attempt($qubaid) {
287         global $DB;
288         $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
289         $DB->delete_records('question_attempts', array('id' => $qubaid));
290     }
292     protected function insert_record($table, $record, $saveid = true) {
293         global $DB;
294         $newid = $DB->insert_record($table, $record, $saveid);
295         if ($saveid) {
296             $record->id = $newid;
297         }
298         return $newid;
299     }
301     public function load_question($questionid, $quizid = null) {
302         return $this->questionloader->get_question($questionid, $quizid);
303     }
305     public function load_dataset($questionid, $selecteditem) {
306         return $this->questionloader->load_dataset($questionid, $selecteditem);
307     }
309     public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
310         if (!$questionsessionsrs->valid()) {
311             return false;
312         }
314         $qsession = $questionsessionsrs->current();
315         if ($qsession->attemptid != $attempt->uniqueid) {
316             // No more question sessions belonging to this attempt.
317             return false;
318         }
320         // Session found, move the pointer in the RS and return the record.
321         $questionsessionsrs->next();
322         return $qsession;
323     }
325     public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
326         $qstates = array();
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.
333                 break;
334             }
336             // Add the new state to the array, and advance.
337             $qstates[] = $state;
338             $questionsstatesrs->next();
339         }
341         return $qstates;
342     }
344     protected function get_converter_class_name($question, $quiz, $qsessionid) {
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         }
354         if ($question->qtype == 'essay') {
355             return 'qbehaviour_manualgraded_converter';
356         } else if ($question->qtype == 'description') {
357             return 'qbehaviour_informationitem_converter';
358         } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
359             return 'qbehaviour_deferredfeedback_converter';
360         } else if ($quiz->preferredbehaviour == 'adaptive') {
361             return 'qbehaviour_adaptive_converter';
362         } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
363             return 'qbehaviour_adaptivenopenalty_converter';
364         } else {
365             throw new coding_exception("Question session {$qsessionid}
366                     has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
367         }
368     }
370     public function supply_missing_question_attempt($quiz, $attempt, $question) {
371         if ($question->qtype == 'random') {
372             throw new coding_exception("Cannot supply a missing qsession for question
373                     {$question->id} in attempt {$attempt->id}.");
374         }
376         $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
378         $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
379                 null, null, $this->logger, $this);
380         $qa = $qbehaviourupdater->supply_missing_qa();
381         $qbehaviourupdater->discard();
382         return $qa;
383     }
385     public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
386         $this->prevent_timeout();
388         if ($question->qtype == 'random') {
389             list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
390             $qsession->questionid = $question->id;
391         }
393         $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
395         $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
396                 $qstates, $this->logger, $this);
397         $qa = $qbehaviourupdater->get_converted_qa();
398         $qbehaviourupdater->discard();
399         return $qa;
400     }
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) {
415                 throw new coding_exception("Question session {$this->qsession->id}
416                         for random question points to two different real questions
417                         {$realquestionid} and {$newquestionid}.");
418             }
419             $qstates[$i]->answer = $realanswer;
420         }
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         }
428         $newquestion = $this->load_question($newquestionid);
429         $newquestion->maxmark = $maxmark;
430         return array($newquestion, $qstates);
431     }
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     }
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  */
448 class question_engine_upgrade_question_loader {
449     private $cache = array();
450     private $datasetcache = array();
452     public function __construct($logger) {
453         $this->logger = $logger;
454     }
456     protected function load_question($questionid, $quizid) {
457         global $DB;
459         if ($quizid) {
460             $question = $DB->get_record_sql("
461                 SELECT q.*, qqi.grade AS maxmark
462                 FROM {question} q
463                 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
464                 WHERE q.id = $questionid AND qqi.quiz = $quizid");
465         } else {
466             $question = $DB->get_record('question', array('id' => $questionid));
467         }
469         if (!$question) {
470             return null;
471         }
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         }
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}.");
486             $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
487                     '</p>' . $question->questiontext;
488         }
490         $qtype->get_question_options($question);
492         return $question;
493     }
495     public function get_question($questionid, $quizid) {
496         if (isset($this->cache[$questionid])) {
497             return $this->cache[$questionid];
498         }
500         $question = $this->load_question($questionid, $quizid);
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         }
512         $this->cache[$questionid] = $question;
513         return $this->cache[$questionid];
514     }
516     public function load_dataset($questionid, $selecteditem) {
517         global $DB;
519         if (isset($this->datasetcache[$questionid][$selecteditem])) {
520             return $this->datasetcache[$questionid][$selecteditem];
521         }
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     }
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  */
543 abstract class question_qtype_attempt_updater {
544     /** @var object the question definition data. */
545     protected $question;
546     /** @var question_behaviour_attempt_updater */
547     protected $updater;
548     /** @var question_engine_assumption_logger */
549     protected $logger;
550     /** @var question_engine_attempt_upgrader */
551     protected $qeupdater;
553     public function __construct($updater, $question, $logger, $qeupdater) {
554         $this->updater = $updater;
555         $this->question = $question;
556         $this->logger = $logger;
557         $this->qeupdater = $qeupdater;
558     }
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;
565         $this->qeupdater = null;
566     }
568     protected function to_text($html) {
569         return $this->updater->to_text($html);
570     }
572     public function question_summary() {
573         return $this->to_text($this->question->questiontext);
574     }
576     public function compare_answers($answer1, $answer2) {
577         return $answer1 == $answer2;
578     }
580     public function is_blank_answer($state) {
581         return $state->answer == '';
582     }
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);
593 class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
594     public function right_answer() {
595         return '';
596     }
598     public function response_summary($state) {
599         return $state->answer;
600     }
602     public function was_answered($state) {
603         return !empty($state->answer);
604     }
606     public function set_first_step_data_elements($state, &$data) {
607         $data['upgradedfromdeletedquestion'] = $state->answer;
608     }
610     public function supply_missing_first_step_data(&$data) {
611     }
613     public function set_data_elements_for_step($state, &$data) {
614         $data['upgradedfromdeletedquestion'] = $state->answer;
615     }