Merge branch 'MDL-70248-310' of https://github.com/HuongNV13/moodle into MOODLE_310_S...
[moodle.git] / question / type / calculatedmulti / db / 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  * Upgrade library code for the calculated multiple-choice question type.
19  *
20  * @package    qtype
21  * @subpackage calculatedmulti
22  * @copyright  2011 The Open University
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
30 /**
31  * Class for converting attempt data for calculated multiple-choice questions
32  * when upgrading attempts to the new question engine.
33  *
34  * This class is used by the code in question/engine/upgrade/upgradelib.php.
35  *
36  * @copyright  2011 The Open University
37  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class qtype_calculatedmulti_qe2_attempt_updater extends question_qtype_attempt_updater {
40     protected $selecteditem = null;
41     /** @var array variable name => value */
42     protected $values;
44     /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
45     protected $search;
47     /**
48      * @var array variable values, with negative numbers wrapped in (...).
49      * Used by {@link substitute_values()}.
50      */
51     protected $safevalue;
53     /**
54      * @var array variable values, with negative numbers wrapped in (...).
55      * Used by {@link substitute_values()}.
56      */
57     protected $prettyvalue;
59     protected $order;
61     public function question_summary() {
62         return ''; // Done later, after we know which dataset is used.
63     }
65     public function right_answer() {
66         if ($this->question->options->single) {
67             foreach ($this->question->options->answers as $ans) {
68                 if ($ans->fraction > 0.999) {
69                     return $this->to_text($this->replace_expressions_in_text($ans->answer));
70                 }
71             }
73         } else {
74             $rightbits = array();
75             foreach ($this->question->options->answers as $ans) {
76                 if ($ans->fraction >= 0.000001) {
77                     $rightbits[] = $this->to_text($this->replace_expressions_in_text($ans->answer));
78                 }
79             }
80             return implode('; ', $rightbits);
81         }
82     }
84     protected function explode_answer($state) {
85         if (strpos($state->answer, '-') < 7) {
86             // Broken state, skip it.
87             throw new coding_exception("Brokes state {$state->id} for calcluatedmulti
88                     question {$state->question}. (It did not specify a dataset.");
89         }
90         list($datasetbit, $answer) = explode('-', $state->answer, 2);
91         $selecteditem = substr($datasetbit, 7);
93         if (is_null($this->selecteditem)) {
94             $this->load_dataset($selecteditem);
95         } else if ($this->selecteditem != $selecteditem) {
96             $this->logger->log_assumption("Different states for calcluatedmulti question
97                     {$state->question} used different dataset items. Ignoring the change
98                     in state {$state->id} and coninuting to use item {$this->selecteditem}.");
99         }
101         if (strpos($answer, ':') !== false) {
102             list($order, $responses) = explode(':', $answer);
103             return $responses;
104         } else {
105             // Sometimes, a bug means that a state is missing the <order>: bit,
106             // We need to deal with that.
107             $this->logger->log_assumption("Dealing with missing order information
108                     in attempt at multiple choice question {$this->question->id}");
109             return $answer;
110         }
111     }
113     public function response_summary($state) {
114         $responses = $this->explode_answer($state);
115         if ($this->question->options->single) {
116             if (is_numeric($responses)) {
117                 if (array_key_exists($responses, $this->question->options->answers)) {
118                     return $this->to_text($this->replace_expressions_in_text(
119                             $this->question->options->answers[$responses]->answer));
120                 } else {
121                     $this->logger->log_assumption("Dealing with a place where the
122                             student selected a choice that was later deleted for
123                             multiple choice question {$this->question->id}");
124                     return '[CHOICE THAT WAS LATER DELETED]';
125                 }
126             } else {
127                 return null;
128             }
130         } else {
131             if (!empty($responses)) {
132                 $responses = explode(',', $responses);
133                 $bits = array();
134                 foreach ($responses as $response) {
135                     if (array_key_exists($response, $this->question->options->answers)) {
136                         $bits[] = $this->to_text($this->replace_expressions_in_text(
137                                 $this->question->options->answers[$response]->answer));
138                     } else {
139                         $this->logger->log_assumption("Dealing with a place where the
140                                 student selected a choice that was later deleted for
141                                 multiple choice question {$this->question->id}");
142                         $bits[] = '[CHOICE THAT WAS LATER DELETED]';
143                     }
144                 }
145                 return implode('; ', $bits);
146             } else {
147                 return null;
148             }
149         }
150     }
152     public function was_answered($state) {
153         $responses = $this->explode_answer($state);
154         if ($this->question->options->single) {
155             return is_numeric($responses);
156         } else {
157             return !empty($responses);
158         }
159     }
161     public function set_first_step_data_elements($state, &$data) {
162         $this->explode_answer($state);
163         $this->updater->qa->questionsummary = $this->to_text(
164                 $this->replace_expressions_in_text($this->question->questiontext));
165         $this->updater->qa->rightanswer = $this->right_answer($this->question);
167         foreach ($this->values as $name => $value) {
168             $data['_var_' . $name] = $value;
169         }
171         list($datasetbit, $answer) = explode('-', $state->answer, 2);
172         list($order, $responses) = explode(':', $answer);
173         $data['_order'] = $order;
174         $this->order = explode(',', $order);
175     }
177     public function supply_missing_first_step_data(&$data) {
178         $data['_order'] = implode(',', array_keys($this->question->options->answers));
179     }
181     public function set_data_elements_for_step($state, &$data) {
182         $responses = $this->explode_answer($state);
183         if ($this->question->options->single) {
184             if (is_numeric($responses)) {
185                 $flippedorder = array_combine(array_values($this->order), array_keys($this->order));
186                 if (array_key_exists($responses, $flippedorder)) {
187                     $data['answer'] = $flippedorder[$responses];
188                 } else {
189                     $data['answer'] = '-1';
190                 }
191             }
193         } else {
194             $responses = explode(',', $responses);
195             foreach ($this->order as $key => $ansid) {
196                 if (in_array($ansid, $responses)) {
197                     $data['choice' . $key] = 1;
198                 } else {
199                     $data['choice' . $key] = 0;
200                 }
201             }
202         }
203     }
205     public function load_dataset($selecteditem) {
206         $this->selecteditem = $selecteditem;
207         $this->updater->qa->variant = $selecteditem;
208         $this->values = $this->qeupdater->load_dataset(
209                 $this->question->id, $selecteditem);
211         // Prepare an array for {@link substitute_values()}.
212         $this->search = array();
213         $this->safevalue = array();
214         $this->prettyvalue = array();
215         foreach ($this->values as $name => $value) {
216             if (!is_numeric($value)) {
217                 $a = new stdClass();
218                 $a->name = '{' . $name . '}';
219                 $a->value = $value;
220                 throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
221             }
223             $this->search[] = '{' . $name . '}';
224             $this->safevalue[] = '(' . $value . ')';
225             $this->prettyvalue[] = $this->format_float($value);
226         }
227     }
229     /**
230      * This function should be identical to
231      * {@link qtype_calculated_variable_substituter::format_float()}. Except that
232      * we do not try to do locale-aware replacement of the decimal point.
233      *
234      * Having to copy it here is a pain, but it is the standard rule about not
235      * using library code (which may change in future) in upgrade code, which
236      * exists at a point in time.
237      *
238      * Display a float properly formatted with a certain number of decimal places.
239      * @param number $x the number to format
240      * @param int $length restrict to this many decimal places or significant
241      *      figures. If null, the number is not rounded.
242      * @param int format 1 => decimalformat, 2 => significantfigures.
243      * @return string formtted number.
244      */
245     public function format_float($x, $length = null, $format = null) {
246         if (!is_null($length) && !is_null($format)) {
247             if ($format == 1) {
248                 // Decimal places.
249                 $x = sprintf('%.' . $length . 'F', $x);
250             } else if ($format == 2) {
251                 // Significant figures.
252                 $x = sprintf('%.' . $length . 'g', $x);
253             }
254         }
255         return $x;
256     }
258     /**
259      * Evaluate an expression using the variable values.
260      * @param string $expression the expression. A PHP expression with placeholders
261      *      like {a} for where the variables need to go.
262      * @return float the computed result.
263      */
264     public function calculate($expression) {
265         return $this->calculate_raw($this->substitute_values_for_eval($expression));
266     }
268     /**
269      * Evaluate an expression after the variable values have been substituted.
270      * @param string $expression the expression. A PHP expression with placeholders
271      *      like {a} for where the variables need to go.
272      * @return float the computed result.
273      */
274     protected function calculate_raw($expression) {
275         try {
276             // In older PHP versions this this is a way to validate code passed to eval.
277             // The trick came from http://php.net/manual/en/function.eval.php.
278             if (@eval('return true; $result = ' . $expression . ';')) {
279                 return eval('return ' . $expression . ';');
280             }
281         } catch (Throwable $e) {
282             // PHP7 and later now throws ParseException and friends from eval(),
283             // which is much better.
284         }
285         // In either case of an invalid $expression, we end here.
286         return '[Invalid expression ' . $expression . ']';
287     }
289     /**
290      * Substitute variable placehodlers like {a} with their value wrapped in ().
291      * @param string $expression the expression. A PHP expression with placeholders
292      *      like {a} for where the variables need to go.
293      * @return string the expression with each placeholder replaced by the
294      *      corresponding value.
295      */
296     protected function substitute_values_for_eval($expression) {
297         return str_replace($this->search, $this->safevalue, $expression);
298     }
300     /**
301      * Substitute variable placehodlers like {a} with their value without wrapping
302      * the value in anything.
303      * @param string $text some content with placeholders
304      *      like {a} for where the variables need to go.
305      * @return string the expression with each placeholder replaced by the
306      *      corresponding value.
307      */
308     protected function substitute_values_pretty($text) {
309         return str_replace($this->search, $this->prettyvalue, $text);
310     }
312     /**
313      * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
314      * in some text with the corresponding values.
315      * @param string $text the text to process.
316      * @return string the text with values substituted.
317      */
318     public function replace_expressions_in_text($text, $length = null, $format = null) {
319         $vs = $this; // Can't see to use $this in a PHP closure.
320         $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
321                 function ($matches) use ($vs, $format, $length) {
322                     return $vs->format_float($vs->calculate($matches[1]), $length, $format);
323                 }, $text);
324         return $this->substitute_values_pretty($text);
325     }