Merge branch 'MDL-70326-MOODLE_310_STABLE' of https://github.com/durzo/moodle into...
[moodle.git] / question / engine / upgrade / behaviourconverters.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 classes for handling the different question behaviours
19  * during upgrade.
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();
31 /**
32  * Base class for managing the upgrade of a question using a particular behaviour.
33  *
34  * This class takes as input:
35  * 1. Various backgroud data like $quiz, $attempt and $question.
36  * 2. The data about the question session to upgrade $qsession and $qstates.
37  * Working through that data, it builds up
38  * 3. The equivalent new data $qa. This has roughly the same data as a
39  * question_attempt object belonging to the new question engine would have, but
40  * $this->qa is built up from stdClass objects.
41  *
42  * @copyright  2010 The Open University
43  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44  */
45 abstract class question_behaviour_attempt_updater {
46     /** @var question_qtype_attempt_updater */
47     protected $qtypeupdater;
48     /** @var question_engine_assumption_logger */
49     protected $logger;
50     /** @var question_engine_attempt_upgrader */
51     protected $qeupdater;
53     /**
54      * @var object this is the data for the upgraded questions attempt that
55      * we are building.
56      */
57     public $qa;
59     /** @var object the quiz settings. */
60     protected $quiz;
61     /** @var object the quiz attempt data. */
62     protected $attempt;
63     /** @var object the question definition data. */
64     protected $question;
65     /** @var object the question session to be upgraded. */
66     protected $qsession;
67     /** @var array the question states for the session to be upgraded. */
68     protected $qstates;
70     /**
71      * @var int counts the question_steps as they are converted to
72      * question_attempt_steps.
73      */
74     protected $sequencenumber;
75     /** @var object pointer to the state that has already finished this attempt. */
76     protected $finishstate;
78     public function __construct($quiz, $attempt, $question, $qsession, $qstates, $logger, $qeupdater) {
79         $this->quiz = $quiz;
80         $this->attempt = $attempt;
81         $this->question = $question;
82         $this->qsession = $qsession;
83         $this->qstates = $qstates;
84         $this->logger = $logger;
85         $this->qeupdater = $qeupdater;
86     }
88     public function discard() {
89         // Help the garbage collector, which seems to be struggling.
90         $this->quiz = null;
91         $this->attempt = null;
92         $this->question = null;
93         $this->qsession = null;
94         $this->qstates = null;
95         $this->qa = null;
96         $this->qtypeupdater->discard();
97         $this->qtypeupdater = null;
98         $this->logger = null;
99         $this->qeupdater = null;
100     }
102     protected abstract function behaviour_name();
104     public function get_converted_qa() {
105         $this->initialise_qa();
106         $this->convert_steps();
107         return $this->qa;
108     }
110     protected function create_missing_first_step() {
111         $step = new stdClass();
112         $step->state = 'todo';
113         $step->data = array();
114         $step->fraction = null;
115         $step->timecreated = $this->attempt->timestart ? $this->attempt->timestart : time();
116         $step->userid = $this->attempt->userid;
117         $this->qtypeupdater->supply_missing_first_step_data($step->data);
118         return $step;
119     }
121     public function supply_missing_qa() {
122         $this->initialise_qa();
123         $this->qa->timemodified = $this->attempt->timestart;
124         $this->sequencenumber = 0;
125         $this->add_step($this->create_missing_first_step());
126         return $this->qa;
127     }
129     protected function initialise_qa() {
130         $this->qtypeupdater = $this->make_qtype_updater();
132         $qa = new stdClass();
133         $qa->questionid = $this->question->id;
134         $qa->variant = 1;
135         $qa->behaviour = $this->behaviour_name();
136         $qa->questionsummary = $this->qtypeupdater->question_summary($this->question);
137         $qa->rightanswer = $this->qtypeupdater->right_answer($this->question);
138         $qa->maxmark = $this->question->maxmark;
139         $qa->minfraction = 0;
140         $qa->maxfraction = 1;
141         $qa->flagged = 0;
142         $qa->responsesummary = '';
143         $qa->timemodified = 0;
144         $qa->steps = array();
146         $this->qa = $qa;
147     }
149     protected function convert_steps() {
150         $this->finishstate = null;
151         $this->startstate = null;
152         $this->sequencenumber = 0;
153         foreach ($this->qstates as $state) {
154             $this->process_state($state);
155         }
156         $this->finish_up();
157     }
159     protected function process_state($state) {
160         $step = $this->make_step($state);
161         $method = 'process' . $state->event;
162         $this->$method($step, $state);
163     }
165     protected function finish_up() {
166     }
168     protected function add_step($step) {
169         $step->sequencenumber = $this->sequencenumber;
170         $this->qa->steps[] = $step;
171         $this->sequencenumber++;
172     }
174     protected function discard_last_state() {
175         array_pop($this->qa->steps);
176         $this->sequencenumber--;
177     }
179     protected function unexpected_event($state) {
180         throw new coding_exception("Unexpected event {$state->event} in state {$state->id} in question session {$this->qsession->id}.");
181     }
183     protected function process0($step, $state) {
184         if ($this->startstate) {
185             if ($state->answer == reset($this->qstates)->answer) {
186                 return;
187             } else if ($this->quiz->attemptonlast && $this->sequencenumber == 1) {
188                 // There was a bug in attemptonlast in the past, which meant that
189                 // it created two inconsistent open states, with the second taking
190                 // priority. Simulate that be discarding the first open state, then
191                 // continuing.
192                 $this->logger->log_assumption("Ignoring bogus state in attempt at question {$state->question}");
193                 $this->sequencenumber = 0;
194                 $this->qa->steps = array();
195             } else if ($this->qtypeupdater->is_blank_answer($state)) {
196                 $this->logger->log_assumption("Ignoring second start state with blank answer in attempt at question {$state->question}");
197                 return;
198             } else {
199                 throw new coding_exception("Two inconsistent open states for question session {$this->qsession->id}.");
200             }
201         }
202         $step->state = 'todo';
203         $this->startstate = $state;
204         $this->add_step($step);
205     }
207     protected function process1($step, $state) {
208         $this->unexpected_event($state);
209     }
211     protected function process2($step, $state) {
212         if ($this->qtypeupdater->was_answered($state)) {
213             $step->state = 'complete';
214         } else {
215             $step->state = 'todo';
216         }
217         $this->add_step($step);
218     }
220     protected function process3($step, $state) {
221         return $this->process6($step, $state);
222     }
224     protected function process4($step, $state) {
225         $this->unexpected_event($state);
226     }
228     protected function process5($step, $state) {
229         $this->unexpected_event($state);
230     }
232     protected abstract function process6($step, $state);
233     protected abstract function process7($step, $state);
235     protected function process8($step, $state) {
236         return $this->process6($step, $state);
237     }
239     protected function process9($step, $state) {
240         if (!$this->finishstate) {
241             $submitstate = clone($state);
242             $submitstate->event = 8;
243             $submitstate->grade = 0;
244             $this->process_state($submitstate);
245         }
247         $step->data['-comment'] = $this->qsession->manualcomment;
248         if ($this->question->maxmark > 0) {
249             $step->fraction = $state->grade / $this->question->maxmark;
250             $step->state = $this->manual_graded_state_for_fraction($step->fraction);
251             $step->data['-mark'] = $state->grade;
252             $step->data['-maxmark'] = $this->question->maxmark;
253         } else {
254             $step->state = 'manfinished';
255         }
256         unset($step->data['answer']);
257         $step->userid = null;
258         $this->add_step($step);
259     }
261     /**
262      * @param object $question a question definition
263      * @return qtype_updater
264      */
265     protected function make_qtype_updater() {
266         global $CFG;
268         if ($this->question->qtype == 'deleted') {
269             return new question_deleted_question_attempt_updater(
270                     $this, $this->question, $this->logger, $this->qeupdater);
271         }
273         $path = $CFG->dirroot . '/question/type/' . $this->question->qtype . '/db/upgradelib.php';
274         if (!is_readable($path)) {
275             throw new coding_exception("Question type {$this->question->qtype}
276                     is missing important code (the file {$path})
277                     required to run the upgrade to the new question engine.");
278         }
279         include_once($path);
280         $class = 'qtype_' . $this->question->qtype . '_qe2_attempt_updater';
281         if (!class_exists($class)) {
282             throw new coding_exception("Question type {$this->question->qtype}
283                     is missing important code (the class {$class})
284                     required to run the upgrade to the new question engine.");
285         }
286         return new $class($this, $this->question, $this->logger, $this->qeupdater);
287     }
289     public function to_text($html) {
290         return trim(html_to_text($html, 0, false));
291     }
293     protected function graded_state_for_fraction($fraction) {
294         if ($fraction < 0.000001) {
295             return 'gradedwrong';
296         } else if ($fraction > 0.999999) {
297             return 'gradedright';
298         } else {
299             return 'gradedpartial';
300         }
301     }
303     protected function manual_graded_state_for_fraction($fraction) {
304         if ($fraction < 0.000001) {
305             return 'mangrwrong';
306         } else if ($fraction > 0.999999) {
307             return 'mangrright';
308         } else {
309             return 'mangrpartial';
310         }
311     }
313     protected function make_step($state){
314         $step = new stdClass();
315         $step->data = array();
317         if ($state->event == 0 || $this->sequencenumber == 0) {
318             $this->qtypeupdater->set_first_step_data_elements($state, $step->data);
319         } else {
320             $this->qtypeupdater->set_data_elements_for_step($state, $step->data);
321         }
323         $step->fraction = null;
324         $step->timecreated = $state->timestamp ? $state->timestamp : time();
325         $step->userid = $this->attempt->userid;
327         $summary = $this->qtypeupdater->response_summary($state);
328         if (!is_null($summary)) {
329             $this->qa->responsesummary = $summary;
330         }
331         $this->qa->timemodified = max($this->qa->timemodified, $state->timestamp);
333         return $step;
334     }
338 class qbehaviour_deferredfeedback_converter extends question_behaviour_attempt_updater {
339     protected function behaviour_name() {
340         return 'deferredfeedback';
341     }
343     protected function process6($step, $state) {
344         if (!$this->startstate) {
345             $this->logger->log_assumption("Ignoring bogus submit before open in attempt at question {$state->question}");
346             // WTF, but this has happened a few times in our DB. It seems it is safe to ignore.
347             return;
348         }
350         if ($this->finishstate) {
351             if ($this->finishstate->answer != $state->answer ||
352                     $this->finishstate->grade != $state->grade ||
353                     $this->finishstate->raw_grade != $state->raw_grade ||
354                     $this->finishstate->penalty != $state->penalty) {
355                 $this->logger->log_assumption("Two inconsistent finish states found for question session {$this->qsession->id} in attempt at question {$state->question} keeping the later one.");
356                 $this->discard_last_state();
357             } else {
358                 $this->logger->log_assumption("Ignoring extra finish states in attempt at question {$state->question}");
359                 return;
360             }
361         }
363         if ($this->question->maxmark > 0) {
364             $step->fraction = $state->grade / $this->question->maxmark;
365             $step->state = $this->graded_state_for_fraction($step->fraction);
366         } else {
367             $step->state = 'finished';
368         }
369         $step->data['-finish'] = '1';
370         $this->finishstate = $state;
371         $this->add_step($step);
372     }
374     protected function process7($step, $state) {
375         $this->unexpected_event($state);
376     }
380 class qbehaviour_manualgraded_converter extends question_behaviour_attempt_updater {
381     protected function behaviour_name() {
382         return 'manualgraded';
383     }
385     protected function process6($step, $state) {
386         $step->state = 'needsgrading';
387         if (!$this->finishstate) {
388             $step->data['-finish'] = '1';
389             $this->finishstate = $state;
390         }
391         $this->add_step($step);
392     }
394     protected function process7($step, $state) {
395         return $this->process2($step, $state);
396     }
400 class qbehaviour_informationitem_converter extends question_behaviour_attempt_updater {
401     protected function behaviour_name() {
402         return 'informationitem';
403     }
405     protected function process0($step, $state) {
406         if ($this->startstate) {
407             return;
408         }
409         $step->state = 'todo';
410         $this->startstate = $state;
411         $this->add_step($step);
412     }
414     protected function process2($step, $state) {
415         $this->unexpected_event($state);
416     }
418     protected function process3($step, $state) {
419         $this->unexpected_event($state);
420     }
422     protected function process6($step, $state) {
423         if ($this->finishstate) {
424             return;
425         }
427         $step->state = 'finished';
428         $step->data['-finish'] = '1';
429         $this->finishstate = $state;
430         $this->add_step($step);
431     }
433     protected function process7($step, $state) {
434         return $this->process6($step, $state);
435     }
437     protected function process8($step, $state) {
438         return $this->process6($step, $state);
439     }
443 class qbehaviour_adaptive_converter extends question_behaviour_attempt_updater {
444     protected $try;
445     protected $laststepwasatry = false;
446     protected $finished = false;
447     protected $bestrawgrade = 0;
449     protected function behaviour_name() {
450         return 'adaptive';
451     }
453     protected function finish_up() {
454         parent::finish_up();
455         if ($this->finishstate || !$this->attempt->timefinish) {
456             return;
457         }
459         $state = end($this->qstates);
460         $step = $this->make_step($state);
461         $this->process6($step, $state);
462     }
464     protected function process0($step, $state) {
465         $this->try = 1;
466         $this->laststepwasatry = false;
467         parent::process0($step, $state);
468     }
470     protected function process2($step, $state) {
471         if ($this->finishstate) {
472             $this->logger->log_assumption("Ignoring bogus save after submit in an " .
473                     "adaptive attempt at question {$state->question} " .
474                     "(question session {$this->qsession->id})");
475             return;
476         }
478         if ($this->question->maxmark > 0) {
479             $step->fraction = $state->grade / $this->question->maxmark;
480         }
482         $this->laststepwasatry = false;
483         parent::process2($step, $state);
484     }
486     protected function process3($step, $state) {
487         if ($this->question->maxmark > 0) {
488             $step->fraction = $state->grade / $this->question->maxmark;
489             if ($this->graded_state_for_fraction($step->fraction) == 'gradedright') {
490                 $step->state = 'complete';
491             } else {
492                 $step->state = 'todo';
493             }
494         } else {
495             $step->state = 'complete';
496         }
498         $this->bestrawgrade = max($state->raw_grade, $this->bestrawgrade);
500         $step->data['-_try'] = $this->try;
501         $this->try += 1;
502         $this->laststepwasatry = true;
503         if ($this->question->maxmark > 0) {
504             $step->data['-_rawfraction'] = $state->raw_grade / $this->question->maxmark;
505         } else {
506             $step->data['-_rawfraction'] = 0;
507         }
508         $step->data['-submit'] = 1;
510         $this->add_step($step);
511     }
513     protected function process6($step, $state) {
514         if ($this->finishstate) {
515             if (!$this->qtypeupdater->compare_answers($this->finishstate->answer, $state->answer) ||
516                     $this->finishstate->grade != $state->grade ||
517                     $this->finishstate->raw_grade != $state->raw_grade ||
518                     $this->finishstate->penalty != $state->penalty) {
519                 throw new coding_exception("Two inconsistent finish states found for question session {$this->qsession->id}.");
520             } else {
521                 $this->logger->log_assumption("Ignoring extra finish states in attempt at question {$state->question}");
522                 return;
523             }
524         }
526         $this->bestrawgrade = max($state->raw_grade, $this->bestrawgrade);
528         if ($this->question->maxmark > 0) {
529             $step->fraction = $state->grade / $this->question->maxmark;
530             $step->state = $this->graded_state_for_fraction(
531                     $this->bestrawgrade / $this->question->maxmark);
532         } else {
533             $step->state = 'finished';
534         }
536         $step->data['-finish'] = 1;
537         if ($this->laststepwasatry) {
538             $this->try -= 1;
539         }
540         $step->data['-_try'] = $this->try;
541         if ($this->question->maxmark > 0) {
542             $step->data['-_rawfraction'] = $state->raw_grade / $this->question->maxmark;
543         } else {
544             $step->data['-_rawfraction'] = 0;
545         }
547         $this->finishstate = $state;
548         $this->add_step($step);
549     }
551     protected function process7($step, $state) {
552         $this->unexpected_event($state);
553     }
557 class qbehaviour_adaptivenopenalty_converter extends qbehaviour_adaptive_converter {
558     protected function behaviour_name() {
559         return 'adaptivenopenalty';
560     }