5ba8e1c93fa32a2382d40e2addb472fe1a084f4c
[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         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';
355         } else {
356             throw new coding_exception("Question session {$qsessionid}
357                     has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
358         }
359     }
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}.");
365         }
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();
373         return $qa;
374     }
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;
382         }
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();
390         return $qa;
391     }
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}.");
400                 unset($qstates[$i]);
401                 continue;
402             }
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}.");
409             }
410             $qstates[$i]->answer = $realanswer;
411         }
413         if (empty($newquestionid)) {
414             // This attempt only had broken states. Set a fake $newquestionid to
415             // prevent a null DB error later.
416             $newquestionid = 0;
417         }
419         $newquestion = $this->load_question($newquestionid);
420         $newquestion->maxmark = $maxmark;
421         return array($newquestion, $qstates);
422     }
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);
428     }
432 /**
433  * This class deals with loading (and caching) question definitions during the
434  * question engine upgrade.
435  *
436  * @copyright  2010 The Open University
437  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
438  */
439 class question_engine_upgrade_question_loader {
440     private $cache = array();
441     private $datasetcache = array();
443     public function __construct($logger) {
444         $this->logger = $logger;
445     }
447     protected function load_question($questionid, $quizid) {
448         global $DB;
450         if ($quizid) {
451             $question = $DB->get_record_sql("
452                 SELECT q.*, qqi.grade AS maxmark
453                 FROM {question} q
454                 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
455                 WHERE q.id = $questionid AND qqi.quiz = $quizid");
456         } else {
457             $question = $DB->get_record('question', array('id' => $questionid));
458         }
460         if (!$question) {
461             return null;
462         }
464         if (empty($question->defaultmark)) {
465             if (!empty($question->defaultgrade)) {
466                 $question->defaultmark = $question->defaultgrade;
467             } else {
468                 $question->defaultmark = 0;
469             }
470             unset($question->defaultgrade);
471         }
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;
479         }
481         $qtype->get_question_options($question);
483         return $question;
484     }
486     public function get_question($questionid, $quizid) {
487         if (isset($this->cache[$questionid])) {
488             return $this->cache[$questionid];
489         }
491         $question = $this->load_question($questionid, $quizid);
493         if (!$question) {
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');
501         }
503         $this->cache[$questionid] = $question;
504         return $this->cache[$questionid];
505     }
507     public function load_dataset($questionid, $selecteditem) {
508         global $DB;
510         if (isset($this->datasetcache[$questionid][$selecteditem])) {
511             return $this->datasetcache[$questionid][$selecteditem];
512         }
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];
523     }
527 /**
528  * Base class for the classes that convert the question-type specific bits of
529  * the attempt data.
530  *
531  * @copyright  2010 The Open University
532  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
533  */
534 abstract class question_qtype_attempt_updater {
535     /** @var object the question definition data. */
536     protected $question;
537     /** @var question_behaviour_attempt_updater */
538     protected $updater;
539     /** @var question_engine_assumption_logger */
540     protected $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;
549     }
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;
557     }
559     protected function to_text($html) {
560         return $this->updater->to_text($html);
561     }
563     public function question_summary() {
564         return $this->to_text($this->question->questiontext);
565     }
567     public function compare_answers($answer1, $answer2) {
568         return $answer1 == $answer2;
569     }
571     public function is_blank_answer($state) {
572         return $state->answer == '';
573     }
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() {
586         return '';
587     }
589     public function response_summary($state) {
590         return $state->answer;
591     }
593     public function was_answered($state) {
594         return !empty($state->answer);
595     }
597     public function set_first_step_data_elements($state, &$data) {
598         $data['upgradedfromdeletedquestion'] = $state->answer;
599     }
601     public function supply_missing_first_step_data(&$data) {
602     }
604     public function set_data_elements_for_step($state, &$data) {
605         $data['upgradedfromdeletedquestion'] = $state->answer;
606     }