Merge branch 'MDL-70326-MOODLE_310_STABLE' of https://github.com/durzo/moodle into...
[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;
49     public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
50         global $OUTPUT;
52         $missing = array();
54         $layout = explode(',', $attempt->layout);
55         $questionkeys = array_combine(array_values($layout), array_keys($layout));
57         $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
59         $i = 0;
60         foreach (explode(',', $quizlayout) as $questionid) {
61             if ($questionid == 0) {
62                 continue;
63             }
64             $i++;
66             if (!array_key_exists($questionid, $qas)) {
67                 $missing[] = $questionid;
68                 $layout[$questionkeys[$questionid]] = $questionid;
69                 continue;
70             }
72             $qa = $qas[$questionid];
73             $qa->questionusageid = $attempt->uniqueid;
74             $qa->slot = $i;
75             if (core_text::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
76                 // It seems some people write very long quesions! MDL-30760
77                 $qa->questionsummary = core_text::substr($qa->questionsummary,
78                         0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
79             }
80             $this->insert_record('question_attempts', $qa);
81             $layout[$questionkeys[$questionid]] = $qa->slot;
83             foreach ($qa->steps as $step) {
84                 $step->questionattemptid = $qa->id;
85                 $this->insert_record('question_attempt_steps', $step);
87                 foreach ($step->data as $name => $value) {
88                     $datum = new stdClass();
89                     $datum->attemptstepid = $step->id;
90                     $datum->name = $name;
91                     $datum->value = $value;
92                     $this->insert_record('question_attempt_step_data', $datum, false);
93                 }
94             }
95         }
97         $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
99         if ($missing) {
100             $message = "Question sessions for questions " .
101                     implode(', ', $missing) .
102                     " were missing when upgrading question usage {$attempt->uniqueid}.";
103             echo $OUTPUT->notification($message);
104         }
105     }
107     protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
108         global $DB;
109         $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
110                 array('id' => $qubaid));
111     }
113     protected function set_quiz_attempt_layout($qubaid, $layout) {
114         global $DB;
115         $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
116     }
118     protected function delete_quiz_attempt($qubaid) {
119         global $DB;
120         $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
121         $DB->delete_records('question_attempts', array('id' => $qubaid));
122     }
124     protected function insert_record($table, $record, $saveid = true) {
125         global $DB;
126         $newid = $DB->insert_record($table, $record, $saveid);
127         if ($saveid) {
128             $record->id = $newid;
129         }
130         return $newid;
131     }
133     public function load_question($questionid, $quizid = null) {
134         return $this->questionloader->get_question($questionid, $quizid);
135     }
137     public function load_dataset($questionid, $selecteditem) {
138         return $this->questionloader->load_dataset($questionid, $selecteditem);
139     }
141     public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
142         if (!$questionsessionsrs->valid()) {
143             return false;
144         }
146         $qsession = $questionsessionsrs->current();
147         if ($qsession->attemptid != $attempt->uniqueid) {
148             // No more question sessions belonging to this attempt.
149             return false;
150         }
152         // Session found, move the pointer in the RS and return the record.
153         $questionsessionsrs->next();
154         return $qsession;
155     }
157     public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
158         $qstates = array();
160         while ($questionsstatesrs->valid()) {
161             $state = $questionsstatesrs->current();
162             if ($state->attempt != $attempt->uniqueid ||
163                     $state->question != $question->id) {
164                 // We have found all the states for this attempt. Stop.
165                 break;
166             }
168             // Add the new state to the array, and advance.
169             $qstates[] = $state;
170             $questionsstatesrs->next();
171         }
173         return $qstates;
174     }
176     protected function get_converter_class_name($question, $quiz, $qsessionid) {
177         global $DB;
178         if ($question->qtype == 'deleted') {
179             $where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7';
180             $params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%");
181             if ($DB->record_exists_select('question_states', $where, $params)) {
182                 $this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded.");
183                 return 'qbehaviour_manualgraded_converter';
184             }
185         }
186         $qtype = question_bank::get_qtype($question->qtype, false);
187         if ($qtype->is_manual_graded()) {
188             return 'qbehaviour_manualgraded_converter';
189         } else if ($question->qtype == 'description') {
190             return 'qbehaviour_informationitem_converter';
191         } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
192             return 'qbehaviour_deferredfeedback_converter';
193         } else if ($quiz->preferredbehaviour == 'adaptive') {
194             return 'qbehaviour_adaptive_converter';
195         } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
196             return 'qbehaviour_adaptivenopenalty_converter';
197         } else {
198             throw new coding_exception("Question session {$qsessionid}
199                     has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
200         }
201     }
203     public function supply_missing_question_attempt($quiz, $attempt, $question) {
204         if ($question->qtype == 'random') {
205             throw new coding_exception("Cannot supply a missing qsession for question
206                     {$question->id} in attempt {$attempt->id}.");
207         }
209         $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
211         $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
212                 null, null, $this->logger, $this);
213         $qa = $qbehaviourupdater->supply_missing_qa();
214         $qbehaviourupdater->discard();
215         return $qa;
216     }
218     public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
220         if ($question->qtype == 'random') {
221             list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
222             $qsession->questionid = $question->id;
223         }
225         $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
227         $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
228                 $qstates, $this->logger, $this);
229         $qa = $qbehaviourupdater->get_converted_qa();
230         $qbehaviourupdater->discard();
231         return $qa;
232     }
234     protected function decode_random_attempt($qstates, $maxmark) {
235         $realquestionid = null;
236         foreach ($qstates as $i => $state) {
237             if (strpos($state->answer, '-') < 6) {
238                 // Broken state, skip it.
239                 $this->logger->log_assumption("Had to skip brokes state {$state->id}
240                         for question {$state->question}.");
241                 unset($qstates[$i]);
242                 continue;
243             }
244             list($randombit, $realanswer) = explode('-', $state->answer, 2);
245             $newquestionid = substr($randombit, 6);
246             if ($realquestionid && $realquestionid != $newquestionid) {
247                 throw new coding_exception("Question session {$this->qsession->id}
248                         for random question points to two different real questions
249                         {$realquestionid} and {$newquestionid}.");
250             }
251             $qstates[$i]->answer = $realanswer;
252         }
254         if (empty($newquestionid)) {
255             // This attempt only had broken states. Set a fake $newquestionid to
256             // prevent a null DB error later.
257             $newquestionid = 0;
258         }
260         $newquestion = $this->load_question($newquestionid);
261         $newquestion->maxmark = $maxmark;
262         return array($newquestion, $qstates);
263     }
265     public function prepare_to_restore() {
266         $this->logger = new dummy_question_engine_assumption_logger();
267         $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
268     }
272 /**
273  * This class deals with loading (and caching) question definitions during the
274  * question engine upgrade.
275  *
276  * @copyright  2010 The Open University
277  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
278  */
279 class question_engine_upgrade_question_loader {
280     protected $cache = array();
281     protected $datasetcache = array();
283     public function __construct($logger) {
284         $this->logger = $logger;
285     }
287     protected function load_question($questionid, $quizid) {
288         global $DB;
290         if ($quizid) {
291             $question = $DB->get_record_sql("
292                 SELECT q.*, slot.maxmark
293                 FROM {question} q
294                 JOIN {quiz_slots} slot ON slot.questionid = q.id
295                 WHERE q.id = ? AND slot.quizid = ?", array($questionid, $quizid));
296         } else {
297             $question = $DB->get_record('question', array('id' => $questionid));
298         }
300         if (!$question) {
301             return null;
302         }
304         if (empty($question->defaultmark)) {
305             if (!empty($question->defaultgrade)) {
306                 $question->defaultmark = $question->defaultgrade;
307             } else {
308                 $question->defaultmark = 0;
309             }
310             unset($question->defaultgrade);
311         }
313         $qtype = question_bank::get_qtype($question->qtype, false);
314         if ($qtype->name() === 'missingtype') {
315             $this->logger->log_assumption("Dealing with question id {$question->id}
316                     that is of an unknown type {$question->qtype}.");
317             $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
318                     '</p>' . $question->questiontext;
319         }
321         $qtype->get_question_options($question);
323         return $question;
324     }
326     public function get_question($questionid, $quizid) {
327         if (isset($this->cache[$questionid])) {
328             return $this->cache[$questionid];
329         }
331         $question = $this->load_question($questionid, $quizid);
333         if (!$question) {
334             $this->logger->log_assumption("Dealing with question id {$questionid}
335                     that was missing from the database.");
336             $question = new stdClass();
337             $question->id = $questionid;
338             $question->qtype = 'deleted';
339             $question->maxmark = 1; // Guess, but that is all we can do.
340             $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
341         }
343         $this->cache[$questionid] = $question;
344         return $this->cache[$questionid];
345     }
347     public function load_dataset($questionid, $selecteditem) {
348         global $DB;
350         if (isset($this->datasetcache[$questionid][$selecteditem])) {
351             return $this->datasetcache[$questionid][$selecteditem];
352         }
354         $this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
355                 SELECT qdd.name, qdi.value
356                   FROM {question_dataset_items} qdi
357                   JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
358                   JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
359                  WHERE qd.question = ?
360                    AND qdi.itemnumber = ?
361                 ', array($questionid, $selecteditem));
362         return $this->datasetcache[$questionid][$selecteditem];
363     }
367 /**
368  * Base class for the classes that convert the question-type specific bits of
369  * the attempt data.
370  *
371  * @copyright  2010 The Open University
372  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
373  */
374 abstract class question_qtype_attempt_updater {
375     /** @var object the question definition data. */
376     protected $question;
377     /** @var question_behaviour_attempt_updater */
378     protected $updater;
379     /** @var question_engine_assumption_logger */
380     protected $logger;
381     /** @var question_engine_attempt_upgrader */
382     protected $qeupdater;
384     public function __construct($updater, $question, $logger, $qeupdater) {
385         $this->updater = $updater;
386         $this->question = $question;
387         $this->logger = $logger;
388         $this->qeupdater = $qeupdater;
389     }
391     public function discard() {
392         // Help the garbage collector, which seems to be struggling.
393         $this->updater = null;
394         $this->question = null;
395         $this->logger = null;
396         $this->qeupdater = null;
397     }
399     protected function to_text($html) {
400         return $this->updater->to_text($html);
401     }
403     public function question_summary() {
404         return $this->to_text($this->question->questiontext);
405     }
407     public function compare_answers($answer1, $answer2) {
408         return $answer1 == $answer2;
409     }
411     public function is_blank_answer($state) {
412         return $state->answer == '';
413     }
415     public abstract function right_answer();
416     public abstract function response_summary($state);
417     public abstract function was_answered($state);
418     public abstract function set_first_step_data_elements($state, &$data);
419     public abstract function set_data_elements_for_step($state, &$data);
420     public abstract function supply_missing_first_step_data(&$data);
424 class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
425     public function right_answer() {
426         return '';
427     }
429     public function response_summary($state) {
430         return $state->answer;
431     }
433     public function was_answered($state) {
434         return !empty($state->answer);
435     }
437     public function set_first_step_data_elements($state, &$data) {
438         $data['upgradedfromdeletedquestion'] = $state->answer;
439     }
441     public function supply_missing_first_step_data(&$data) {
442     }
444     public function set_data_elements_for_step($state, &$data) {
445         $data['upgradedfromdeletedquestion'] = $state->answer;
446     }
449 /**
450  * This check verifies that all quiz attempts were upgraded since following
451  * the question engine upgrade in Moodle 2.1.
452  *
453  * Note: This custom check (and its environment.xml declaration) will be safely
454  *       removed once we raise min required Moodle version to be 2.7 or newer.
455  *
456  * @param environment_results object to update, if relevant.
457  * @return environment_results updated results object, or null if this test is not relevant.
458  */
459 function quiz_attempts_upgraded(environment_results $result) {
460     global $DB;
462     $dbman = $DB->get_manager();
463     $table = new xmldb_table('quiz_attempts');
464     $field = new xmldb_field('needsupgradetonewqe');
466     if (!$dbman->table_exists($table) || !$dbman->field_exists($table, $field)) {
467         // DB already upgraded. This test is no longer relevant.
468         return null;
469     }
471     if (!$DB->record_exists('quiz_attempts', array('needsupgradetonewqe' => 1))) {
472         // No 1s present in that column means there are no problems.
473         return null;
474     }
476     // Only display anything if the admins need to be aware of the problem.
477     $result->setStatus(false);
478     return $result;