Merge branch 'MDL-70326-MOODLE_310_STABLE' of https://github.com/durzo/moodle into...
[moodle.git] / question / engine / upgrade / upgradelib.php
CommitLineData
0859ff65
TH
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * This file contains the code required to upgrade all the attempt data from
19 * old versions of Moodle into the tables used by the new question engine.
20 *
21 * @package moodlecore
22 * @subpackage questionengine
23 * @copyright 2010 The Open University
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27
28defined('MOODLE_INTERNAL') || die();
29
30global $CFG;
39759ac4 31require_once($CFG->dirroot . '/question/engine/bank.php');
0859ff65
TH
32require_once($CFG->dirroot . '/question/engine/upgrade/logger.php');
33require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php');
34
35
36/**
37 * This class manages upgrading all the question attempts from the old database
38 * structure to the new question engine.
39 *
40 * @copyright 2010 The Open University
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42 */
43class question_engine_attempt_upgrader {
44 /** @var question_engine_upgrade_question_loader */
45 protected $questionloader;
cd300cf3 46 /** @var question_engine_assumption_logger */
0859ff65 47 protected $logger;
0859ff65 48
18ab06ba 49 public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
c917b53a
AA
50 global $OUTPUT;
51
0859ff65 52 $missing = array();
0859ff65
TH
53
54 $layout = explode(',', $attempt->layout);
55 $questionkeys = array_combine(array_values($layout), array_keys($layout));
56
cd300cf3 57 $this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
0859ff65
TH
58
59 $i = 0;
60 foreach (explode(',', $quizlayout) as $questionid) {
61 if ($questionid == 0) {
62 continue;
63 }
64 $i++;
65
66 if (!array_key_exists($questionid, $qas)) {
67 $missing[] = $questionid;
ffe4d23a 68 $layout[$questionkeys[$questionid]] = $questionid;
0859ff65
TH
69 continue;
70 }
71
72 $qa = $qas[$questionid];
73 $qa->questionusageid = $attempt->uniqueid;
74 $qa->slot = $i;
2f1e464a 75 if (core_text::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
c83ed025 76 // It seems some people write very long quesions! MDL-30760
2f1e464a 77 $qa->questionsummary = core_text::substr($qa->questionsummary,
c83ed025
TH
78 0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
79 }
cd300cf3 80 $this->insert_record('question_attempts', $qa);
0859ff65
TH
81 $layout[$questionkeys[$questionid]] = $qa->slot;
82
83 foreach ($qa->steps as $step) {
84 $step->questionattemptid = $qa->id;
cd300cf3 85 $this->insert_record('question_attempt_steps', $step);
0859ff65
TH
86
87 foreach ($step->data as $name => $value) {
88 $datum = new stdClass();
89 $datum->attemptstepid = $step->id;
90 $datum->name = $name;
91 $datum->value = $value;
cd300cf3 92 $this->insert_record('question_attempt_step_data', $datum, false);
0859ff65
TH
93 }
94 }
95 }
96
cd300cf3 97 $this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
0859ff65
TH
98
99 if ($missing) {
c917b53a 100 $message = "Question sessions for questions " .
0859ff65 101 implode(', ', $missing) .
c917b53a
AA
102 " were missing when upgrading question usage {$attempt->uniqueid}.";
103 echo $OUTPUT->notification($message);
0859ff65 104 }
0859ff65
TH
105 }
106
107 protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
cd300cf3
TH
108 global $DB;
109 $DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
110 array('id' => $qubaid));
0859ff65
TH
111 }
112
113 protected function set_quiz_attempt_layout($qubaid, $layout) {
cd300cf3
TH
114 global $DB;
115 $DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
0859ff65
TH
116 }
117
118 protected function delete_quiz_attempt($qubaid) {
cd300cf3
TH
119 global $DB;
120 $DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
121 $DB->delete_records('question_attempts', array('id' => $qubaid));
0859ff65
TH
122 }
123
124 protected function insert_record($table, $record, $saveid = true) {
cd300cf3
TH
125 global $DB;
126 $newid = $DB->insert_record($table, $record, $saveid);
0859ff65
TH
127 if ($saveid) {
128 $record->id = $newid;
129 }
130 return $newid;
131 }
132
133 public function load_question($questionid, $quizid = null) {
134 return $this->questionloader->get_question($questionid, $quizid);
135 }
136
667cdde3
TH
137 public function load_dataset($questionid, $selecteditem) {
138 return $this->questionloader->load_dataset($questionid, $selecteditem);
139 }
140
cd300cf3 141 public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
39759ac4
TH
142 if (!$questionsessionsrs->valid()) {
143 return false;
144 }
145
cd300cf3 146 $qsession = $questionsessionsrs->current();
39759ac4 147 if ($qsession->attemptid != $attempt->uniqueid) {
0859ff65
TH
148 // No more question sessions belonging to this attempt.
149 return false;
150 }
151
152 // Session found, move the pointer in the RS and return the record.
cd300cf3 153 $questionsessionsrs->next();
0859ff65
TH
154 return $qsession;
155 }
156
cd300cf3 157 public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
0859ff65
TH
158 $qstates = array();
159
39759ac4
TH
160 while ($questionsstatesrs->valid()) {
161 $state = $questionsstatesrs->current();
162 if ($state->attempt != $attempt->uniqueid ||
0859ff65
TH
163 $state->question != $question->id) {
164 // We have found all the states for this attempt. Stop.
165 break;
166 }
167
168 // Add the new state to the array, and advance.
ceb4d2ed 169 $qstates[] = $state;
cd300cf3 170 $questionsstatesrs->next();
0859ff65
TH
171 }
172
173 return $qstates;
174 }
175
176 protected function get_converter_class_name($question, $quiz, $qsessionid) {
987e0e91
HB
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 }
1892a356
DS
186 $qtype = question_bank::get_qtype($question->qtype, false);
187 if ($qtype->is_manual_graded()) {
0859ff65
TH
188 return 'qbehaviour_manualgraded_converter';
189 } else if ($question->qtype == 'description') {
190 return 'qbehaviour_informationitem_converter';
0859ff65
TH
191 } else if ($quiz->preferredbehaviour == 'deferredfeedback') {
192 return 'qbehaviour_deferredfeedback_converter';
cd300cf3
TH
193 } else if ($quiz->preferredbehaviour == 'adaptive') {
194 return 'qbehaviour_adaptive_converter';
195 } else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
196 return 'qbehaviour_adaptivenopenalty_converter';
0859ff65 197 } else {
cd300cf3
TH
198 throw new coding_exception("Question session {$qsessionid}
199 has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
0859ff65
TH
200 }
201 }
202
203 public function supply_missing_question_attempt($quiz, $attempt, $question) {
204 if ($question->qtype == 'random') {
cd300cf3
TH
205 throw new coding_exception("Cannot supply a missing qsession for question
206 {$question->id} in attempt {$attempt->id}.");
0859ff65
TH
207 }
208
209 $converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
210
cd300cf3 211 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
667cdde3 212 null, null, $this->logger, $this);
0859ff65
TH
213 $qa = $qbehaviourupdater->supply_missing_qa();
214 $qbehaviourupdater->discard();
215 return $qa;
216 }
217
218 public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
0859ff65
TH
219
220 if ($question->qtype == 'random') {
221 list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
222 $qsession->questionid = $question->id;
223 }
224
225 $converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
226
cd300cf3 227 $qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
667cdde3 228 $qstates, $this->logger, $this);
0859ff65
TH
229 $qa = $qbehaviourupdater->get_converted_qa();
230 $qbehaviourupdater->discard();
231 return $qa;
232 }
233
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) {
cd300cf3
TH
247 throw new coding_exception("Question session {$this->qsession->id}
248 for random question points to two different real questions
249 {$realquestionid} and {$newquestionid}.");
0859ff65
TH
250 }
251 $qstates[$i]->answer = $realanswer;
252 }
253
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 }
259
260 $newquestion = $this->load_question($newquestionid);
261 $newquestion->maxmark = $maxmark;
262 return array($newquestion, $qstates);
263 }
18ab06ba
TH
264
265 public function prepare_to_restore() {
18ab06ba
TH
266 $this->logger = new dummy_question_engine_assumption_logger();
267 $this->questionloader = new question_engine_upgrade_question_loader($this->logger);
268 }
0859ff65
TH
269}
270
271
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 */
279class question_engine_upgrade_question_loader {
a4d53e4b
PS
280 protected $cache = array();
281 protected $datasetcache = array();
0859ff65
TH
282
283 public function __construct($logger) {
284 $this->logger = $logger;
285 }
286
287 protected function load_question($questionid, $quizid) {
cd300cf3 288 global $DB;
0859ff65
TH
289
290 if ($quizid) {
cd300cf3 291 $question = $DB->get_record_sql("
ccba5b88 292 SELECT q.*, slot.maxmark
cd300cf3 293 FROM {question} q
ccba5b88
TH
294 JOIN {quiz_slots} slot ON slot.questionid = q.id
295 WHERE q.id = ? AND slot.quizid = ?", array($questionid, $quizid));
0859ff65 296 } else {
cd300cf3 297 $question = $DB->get_record('question', array('id' => $questionid));
0859ff65
TH
298 }
299
300 if (!$question) {
301 return null;
302 }
303
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 }
312
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}.");
cd300cf3
TH
317 $question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
318 '</p>' . $question->questiontext;
0859ff65
TH
319 }
320
321 $qtype->get_question_options($question);
322
323 return $question;
324 }
325
326 public function get_question($questionid, $quizid) {
327 if (isset($this->cache[$questionid])) {
328 return $this->cache[$questionid];
329 }
330
331 $question = $this->load_question($questionid, $quizid);
332
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 }
342
343 $this->cache[$questionid] = $question;
344 return $this->cache[$questionid];
345 }
667cdde3
TH
346
347 public function load_dataset($questionid, $selecteditem) {
348 global $DB;
349
350 if (isset($this->datasetcache[$questionid][$selecteditem])) {
351 return $this->datasetcache[$questionid][$selecteditem];
352 }
353
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 }
0859ff65
TH
364}
365
366
cd300cf3
TH
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 */
0859ff65 374abstract class question_qtype_attempt_updater {
cd300cf3 375 /** @var object the question definition data. */
0859ff65 376 protected $question;
cd300cf3 377 /** @var question_behaviour_attempt_updater */
0859ff65
TH
378 protected $updater;
379 /** @var question_engine_assumption_logger */
380 protected $logger;
667cdde3
TH
381 /** @var question_engine_attempt_upgrader */
382 protected $qeupdater;
0859ff65 383
667cdde3 384 public function __construct($updater, $question, $logger, $qeupdater) {
0859ff65
TH
385 $this->updater = $updater;
386 $this->question = $question;
387 $this->logger = $logger;
667cdde3 388 $this->qeupdater = $qeupdater;
0859ff65
TH
389 }
390
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;
667cdde3 396 $this->qeupdater = null;
0859ff65
TH
397 }
398
399 protected function to_text($html) {
400 return $this->updater->to_text($html);
401 }
402
403 public function question_summary() {
404 return $this->to_text($this->question->questiontext);
405 }
406
407 public function compare_answers($answer1, $answer2) {
408 return $answer1 == $answer2;
409 }
410
fbf51a48
HB
411 public function is_blank_answer($state) {
412 return $state->answer == '';
413 }
d12d4d59 414
0859ff65
TH
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);
421}
422
423
424class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
425 public function right_answer() {
426 return '';
427 }
428
429 public function response_summary($state) {
430 return $state->answer;
431 }
432
433 public function was_answered($state) {
434 return !empty($state->answer);
435 }
436
437 public function set_first_step_data_elements($state, &$data) {
438 $data['upgradedfromdeletedquestion'] = $state->answer;
439 }
440
441 public function supply_missing_first_step_data(&$data) {
442 }
443
444 public function set_data_elements_for_step($state, &$data) {
445 $data['upgradedfromdeletedquestion'] = $state->answer;
446 }
447}
e4c20157
TH
448
449/**
450 * This check verifies that all quiz attempts were upgraded since following
451 * the question engine upgrade in Moodle 2.1.
452 *
ca09f076
EL
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 *
e4c20157
TH
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 */
459function quiz_attempts_upgraded(environment_results $result) {
460 global $DB;
461
462 $dbman = $DB->get_manager();
463 $table = new xmldb_table('quiz_attempts');
464 $field = new xmldb_field('needsupgradetonewqe');
465
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 }
470
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 }
475
476 // Only display anything if the admins need to be aware of the problem.
477 $result->setStatus(false);
478 return $result;
479}