Merge branch 'wip-mdl-30980' of git://github.com/rajeshtaneja/moodle
[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 . '/' . $CFG->admin .
92                 '/tool/qeupgradehelper/partialupgrade.php';
93         $partialupgradefunction = 'tool_qeupgradehelper_get_quizzes_to_upgrade';
94         if (is_readable($partialupgradefile)) {
95             include_once($partialupgradefile);
96             if (function_exists($partialupgradefunction)) {
97                 $quizids = $partialupgradefunction();
99                 // Ignore any quiz ids that do not acually exist.
100                 if (empty($quizids)) {
101                     return array();
102                 }
103                 list($test, $params) = $DB->get_in_or_equal($quizids);
104                 return $DB->get_fieldset_sql("
105                         SELECT id
106                           FROM {quiz}
107                          WHERE id $test
108                       ORDER BY id", $params);
109             }
110         }
112         // Otherwise, upgrade all attempts.
113         return $DB->get_fieldset_sql('SELECT id FROM {quiz} ORDER BY id');
114     }
116     public function convert_all_quiz_attempts() {
117         global $DB;
119         $quizids = $this->get_quiz_ids();
120         if (empty($quizids)) {
121             return true;
122         }
124         $done = 0;
125         $outof = count($quizids);
126         $this->logger = new question_engine_assumption_logger();
128         foreach ($quizids as $quizid) {
129             $this->print_progress($done, $outof, $quizid);
131             $quiz = $DB->get_record('quiz', array('id' => $quizid), '*', MUST_EXIST);
132             $this->update_all_attempts_at_quiz($quiz);
134             $done += 1;
135         }
137         $this->print_progress($outof, $outof, 'All done!');
138         $this->logger = null;
139     }
141     public function get_attempts_extra_where() {
142         return ' AND needsupgradetonewqe = 1';
143     }
145     public function update_all_attempts_at_quiz($quiz) {
146         global $DB;
148         // Wipe question loader cache.
149         $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
151         $transaction = $DB->start_delegated_transaction();
153         $params = array('quizid' => $quiz->id);
154         $where = 'quiz = :quizid AND preview = 0' . $this->get_attempts_extra_where();
156         $quizattemptsrs = $DB->get_recordset_select('quiz_attempts', $where, $params, 'uniqueid');
157         $questionsessionsrs = $DB->get_recordset_sql("
158                 SELECT *
159                 FROM {question_sessions}
160                 WHERE attemptid IN (
161                     SELECT uniqueid FROM {quiz_attempts} WHERE $where)
162                 ORDER BY attemptid, questionid
163         ", $params);
165         $questionsstatesrs = $DB->get_recordset_sql("
166                 SELECT *
167                 FROM {question_states}
168                 WHERE attempt IN (
169                     SELECT uniqueid FROM {quiz_attempts} WHERE $where)
170                 ORDER BY attempt, question, seq_number, id
171         ", $params);
173         $datatodo = $quizattemptsrs && $questionsessionsrs && $questionsstatesrs;
174         while ($datatodo && $quizattemptsrs->valid()) {
175             $attempt = $quizattemptsrs->current();
176             $quizattemptsrs->next();
177             $this->convert_quiz_attempt($quiz, $attempt, $questionsessionsrs, $questionsstatesrs);
178         }
180         $quizattemptsrs->close();
181         $questionsessionsrs->close();
182         $questionsstatesrs->close();
184         $transaction->allow_commit();
185     }
187     protected function convert_quiz_attempt($quiz, $attempt, moodle_recordset $questionsessionsrs,
188             moodle_recordset $questionsstatesrs) {
189         $qas = array();
190         $this->logger->set_current_attempt_id($attempt->id);
191         while ($qsession = $this->get_next_question_session($attempt, $questionsessionsrs)) {
192             $question = $this->load_question($qsession->questionid, $quiz->id);
193             $qstates = $this->get_question_states($attempt, $question, $questionsstatesrs);
194             try {
195                 $qas[$qsession->questionid] = $this->convert_question_attempt(
196                         $quiz, $attempt, $question, $qsession, $qstates);
197             } catch (Exception $e) {
198                 notify($e->getMessage());
199             }
200         }
201         $this->logger->set_current_attempt_id(null);
203         $questionorder = array();
204         foreach (explode(',', $quiz->questions) as $questionid) {
205             if ($questionid == 0) {
206                 continue;
207             }
208             if (!array_key_exists($questionid, $qas)) {
209                 $this->logger->log_assumption("Supplying minimal open state for
210                         question {$questionid} in attempt {$attempt->id} at quiz
211                         {$attempt->quiz}, since the session was missing.", $attempt->id);
212                 try {
213                     $question = $this->load_question($questionid, $quiz->id);
214                     $qas[$questionid] = $this->supply_missing_question_attempt(
215                             $quiz, $attempt, $question);
216                 } catch (Exception $e) {
217                     notify($e->getMessage());
218                 }
219             }
220         }
222         return $this->save_usage($quiz->preferredbehaviour, $attempt, $qas, $quiz->questions);
223     }
225     public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
226         $missing = array();
228         $layout = explode(',', $attempt->layout);
229         $questionkeys = array_combine(array_values($layout), array_keys($layout));
231         $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
233         $i = 0;
234         foreach (explode(',', $quizlayout) as $questionid) {
235             if ($questionid == 0) {
236                 continue;
237             }
238             $i++;
240             if (!array_key_exists($questionid, $qas)) {
241                 $missing[] = $questionid;
242                 $layout[$questionkeys[$questionid]] = $questionid;
243                 continue;
244             }
246             $qa = $qas[$questionid];
247             $qa->questionusageid = $attempt->uniqueid;
248             $qa->slot = $i;
249             if (textlib::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
250                 // It seems some people write very long quesions! MDL-30760
251                 $qa->questionsummary = textlib::substr($qa->questionsummary,
252                         0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
253             }
254             $this->insert_record('question_attempts', $qa);
255             $layout[$questionkeys[$questionid]] = $qa->slot;
257             foreach ($qa->steps as $step) {
258                 $step->questionattemptid = $qa->id;
259                 $this->insert_record('question_attempt_steps', $step);
261                 foreach ($step->data as $name => $value) {
262                     $datum = new stdClass();
263                     $datum->attemptstepid = $step->id;
264                     $datum->name = $name;
265                     $datum->value = $value;
266                     $this->insert_record('question_attempt_step_data', $datum, false);
267                 }
268             }
269         }
271         $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
273         if ($missing) {
274             notify("Question sessions for questions " .
275                     implode(', ', $missing) .
276                     " were missing when upgrading question usage {$attempt->uniqueid}.");
277         }
278     }
280     protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
281         global $DB;
282         $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
283                 array('id' => $qubaid));
284     }
286     protected function set_quiz_attempt_layout($qubaid, $layout) {
287         global $DB;
288         $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
289         $DB->set_field('quiz_attempts', 'needsupgradetonewqe', 0, array('uniqueid' => $qubaid));
290     }
292     protected function delete_quiz_attempt($qubaid) {
293         global $DB;
294         $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
295         $DB->delete_records('question_attempts', array('id' => $qubaid));
296     }
298     protected function insert_record($table, $record, $saveid = true) {
299         global $DB;
300         $newid = $DB->insert_record($table, $record, $saveid);
301         if ($saveid) {
302             $record->id = $newid;
303         }
304         return $newid;
305     }
307     public function load_question($questionid, $quizid = null) {
308         return $this->questionloader->get_question($questionid, $quizid);
309     }
311     public function load_dataset($questionid, $selecteditem) {
312         return $this->questionloader->load_dataset($questionid, $selecteditem);
313     }
315     public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
316         if (!$questionsessionsrs->valid()) {
317             return false;
318         }
320         $qsession = $questionsessionsrs->current();
321         if ($qsession->attemptid != $attempt->uniqueid) {
322             // No more question sessions belonging to this attempt.
323             return false;
324         }
326         // Session found, move the pointer in the RS and return the record.
327         $questionsessionsrs->next();
328         return $qsession;
329     }
331     public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
332         $qstates = array();
334         while ($questionsstatesrs->valid()) {
335             $state = $questionsstatesrs->current();
336             if ($state->attempt != $attempt->uniqueid ||
337                     $state->question != $question->id) {
338                 // We have found all the states for this attempt. Stop.
339                 break;
340             }
342             // Add the new state to the array, and advance.
343             $qstates[] = $state;
344             $questionsstatesrs->next();
345         }
347         return $qstates;
348     }
350     protected function get_converter_class_name($question, $quiz, $qsessionid) {
351         global $DB;
352         if ($question->qtype == 'deleted') {
353             $where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7';
354             $params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%");
355             if ($DB->record_exists_select('question_states', $where, $params)) {
356                 $this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded.");
357                 return 'qbehaviour_manualgraded_converter';
358             }
359         }
360         if ($question->qtype == 'essay') {
361             return 'qbehaviour_manualgraded_converter';
362         } else if ($question->qtype == 'description') {
363             return 'qbehaviour_informationitem_converter';
364         } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
365             return 'qbehaviour_deferredfeedback_converter';
366         } else if ($quiz->preferredbehaviour == 'adaptive') {
367             return 'qbehaviour_adaptive_converter';
368         } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
369             return 'qbehaviour_adaptivenopenalty_converter';
370         } else {
371             throw new coding_exception("Question session {$qsessionid}
372                     has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
373         }
374     }
376     public function supply_missing_question_attempt($quiz, $attempt, $question) {
377         if ($question->qtype == 'random') {
378             throw new coding_exception("Cannot supply a missing qsession for question
379                     {$question->id} in attempt {$attempt->id}.");
380         }
382         $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
384         $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
385                 null, null, $this->logger, $this);
386         $qa = $qbehaviourupdater->supply_missing_qa();
387         $qbehaviourupdater->discard();
388         return $qa;
389     }
391     public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
392         $this->prevent_timeout();
394         if ($question->qtype == 'random') {
395             list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
396             $qsession->questionid = $question->id;
397         }
399         $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
401         $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
402                 $qstates, $this->logger, $this);
403         $qa = $qbehaviourupdater->get_converted_qa();
404         $qbehaviourupdater->discard();
405         return $qa;
406     }
408     protected function decode_random_attempt($qstates, $maxmark) {
409         $realquestionid = null;
410         foreach ($qstates as $i => $state) {
411             if (strpos($state->answer, '-') < 6) {
412                 // Broken state, skip it.
413                 $this->logger->log_assumption("Had to skip brokes state {$state->id}
414                         for question {$state->question}.");
415                 unset($qstates[$i]);
416                 continue;
417             }
418             list($randombit, $realanswer) = explode('-', $state->answer, 2);
419             $newquestionid = substr($randombit, 6);
420             if ($realquestionid && $realquestionid != $newquestionid) {
421                 throw new coding_exception("Question session {$this->qsession->id}
422                         for random question points to two different real questions
423                         {$realquestionid} and {$newquestionid}.");
424             }
425             $qstates[$i]->answer = $realanswer;
426         }
428         if (empty($newquestionid)) {
429             // This attempt only had broken states. Set a fake $newquestionid to
430             // prevent a null DB error later.
431             $newquestionid = 0;
432         }
434         $newquestion = $this->load_question($newquestionid);
435         $newquestion->maxmark = $maxmark;
436         return array($newquestion, $qstates);
437     }
439     public function prepare_to_restore() {
440         $this->doingbackup = true; // Prevent printing of dots to stop timeout on upgrade.
441         $this->logger = new dummy_question_engine_assumption_logger();
442         $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
443     }
447 /**
448  * This class deals with loading (and caching) question definitions during the
449  * question engine upgrade.
450  *
451  * @copyright  2010 The Open University
452  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
453  */
454 class question_engine_upgrade_question_loader {
455     private $cache = array();
456     private $datasetcache = array();
458     public function __construct($logger) {
459         $this->logger = $logger;
460     }
462     protected function load_question($questionid, $quizid) {
463         global $DB;
465         if ($quizid) {
466             $question = $DB->get_record_sql("
467                 SELECT q.*, qqi.grade AS maxmark
468                 FROM {question} q
469                 JOIN {quiz_question_instances} qqi ON qqi.question = q.id
470                 WHERE q.id = $questionid AND qqi.quiz = $quizid");
471         } else {
472             $question = $DB->get_record('question', array('id' => $questionid));
473         }
475         if (!$question) {
476             return null;
477         }
479         if (empty($question->defaultmark)) {
480             if (!empty($question->defaultgrade)) {
481                 $question->defaultmark = $question->defaultgrade;
482             } else {
483                 $question->defaultmark = 0;
484             }
485             unset($question->defaultgrade);
486         }
488         $qtype = question_bank::get_qtype($question->qtype, false);
489         if ($qtype->name() === 'missingtype') {
490             $this->logger->log_assumption("Dealing with question id {$question->id}
491                     that is of an unknown type {$question->qtype}.");
492             $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
493                     '</p>' . $question->questiontext;
494         }
496         $qtype->get_question_options($question);
498         return $question;
499     }
501     public function get_question($questionid, $quizid) {
502         if (isset($this->cache[$questionid])) {
503             return $this->cache[$questionid];
504         }
506         $question = $this->load_question($questionid, $quizid);
508         if (!$question) {
509             $this->logger->log_assumption("Dealing with question id {$questionid}
510                     that was missing from the database.");
511             $question = new stdClass();
512             $question->id = $questionid;
513             $question->qtype = 'deleted';
514             $question->maxmark = 1; // Guess, but that is all we can do.
515             $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
516         }
518         $this->cache[$questionid] = $question;
519         return $this->cache[$questionid];
520     }
522     public function load_dataset($questionid, $selecteditem) {
523         global $DB;
525         if (isset($this->datasetcache[$questionid][$selecteditem])) {
526             return $this->datasetcache[$questionid][$selecteditem];
527         }
529         $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
530                 SELECT qdd.name, qdi.value
531                   FROM {question_dataset_items} qdi
532                   JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
533                   JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
534                  WHERE qd.question = ?
535                    AND qdi.itemnumber = ?
536                 ', array($questionid, $selecteditem));
537         return $this->datasetcache[$questionid][$selecteditem];
538     }
542 /**
543  * Base class for the classes that convert the question-type specific bits of
544  * the attempt data.
545  *
546  * @copyright  2010 The Open University
547  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
548  */
549 abstract class question_qtype_attempt_updater {
550     /** @var object the question definition data. */
551     protected $question;
552     /** @var question_behaviour_attempt_updater */
553     protected $updater;
554     /** @var question_engine_assumption_logger */
555     protected $logger;
556     /** @var question_engine_attempt_upgrader */
557     protected $qeupdater;
559     public function __construct($updater, $question, $logger, $qeupdater) {
560         $this->updater = $updater;
561         $this->question = $question;
562         $this->logger = $logger;
563         $this->qeupdater = $qeupdater;
564     }
566     public function discard() {
567         // Help the garbage collector, which seems to be struggling.
568         $this->updater = null;
569         $this->question = null;
570         $this->logger = null;
571         $this->qeupdater = null;
572     }
574     protected function to_text($html) {
575         return $this->updater->to_text($html);
576     }
578     public function question_summary() {
579         return $this->to_text($this->question->questiontext);
580     }
582     public function compare_answers($answer1, $answer2) {
583         return $answer1 == $answer2;
584     }
586     public function is_blank_answer($state) {
587         return $state->answer == '';
588     }
590     public abstract function right_answer();
591     public abstract function response_summary($state);
592     public abstract function was_answered($state);
593     public abstract function set_first_step_data_elements($state, &$data);
594     public abstract function set_data_elements_for_step($state, &$data);
595     public abstract function supply_missing_first_step_data(&$data);
599 class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
600     public function right_answer() {
601         return '';
602     }
604     public function response_summary($state) {
605         return $state->answer;
606     }
608     public function was_answered($state) {
609         return !empty($state->answer);
610     }
612     public function set_first_step_data_elements($state, &$data) {
613         $data['upgradedfromdeletedquestion'] = $state->answer;
614     }
616     public function supply_missing_first_step_data(&$data) {
617     }
619     public function set_data_elements_for_step($state, &$data) {
620         $data['upgradedfromdeletedquestion'] = $state->answer;
621     }