Merge branch 'MDL-70248-310' of https://github.com/HuongNV13/moodle into MOODLE_310_S...
[moodle.git] / question / type / calculatedmulti / db / upgradelib.php
CommitLineData
667cdde3
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 * 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 */
25
26
27defined('MOODLE_INTERNAL') || die();
28
29
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 */
39class qtype_calculatedmulti_qe2_attempt_updater extends question_qtype_attempt_updater {
40 protected $selecteditem = null;
41 /** @var array variable name => value */
42 protected $values;
43
44 /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
45 protected $search;
46
47 /**
48 * @var array variable values, with negative numbers wrapped in (...).
49 * Used by {@link substitute_values()}.
50 */
51 protected $safevalue;
52
53 /**
54 * @var array variable values, with negative numbers wrapped in (...).
55 * Used by {@link substitute_values()}.
56 */
57 protected $prettyvalue;
58
59 protected $order;
60
61 public function question_summary() {
62 return ''; // Done later, after we know which dataset is used.
63 }
64
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 }
72
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 }
83
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);
92
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 }
100
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 }
112
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 }
129
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 }
151
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 }
160
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);
166
667cdde3
TH
167 foreach ($this->values as $name => $value) {
168 $data['_var_' . $name] = $value;
169 }
170
171 list($datasetbit, $answer) = explode('-', $state->answer, 2);
172 list($order, $responses) = explode(':', $answer);
173 $data['_order'] = $order;
174 $this->order = explode(',', $order);
175 }
176
177 public function supply_missing_first_step_data(&$data) {
178 $data['_order'] = implode(',', array_keys($this->question->options->answers));
179 }
180
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 }
192
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 }
204
205 public function load_dataset($selecteditem) {
206 $this->selecteditem = $selecteditem;
c014b989 207 $this->updater->qa->variant = $selecteditem;
667cdde3
TH
208 $this->values = $this->qeupdater->load_dataset(
209 $this->question->id, $selecteditem);
210
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 }
222
223 $this->search[] = '{' . $name . '}';
224 $this->safevalue[] = '(' . $value . ')';
225 $this->prettyvalue[] = $this->format_float($value);
226 }
227 }
228
229 /**
01533e9c
TH
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 *
667cdde3 238 * Display a float properly formatted with a certain number of decimal places.
01533e9c
TH
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.
667cdde3
TH
244 */
245 public function format_float($x, $length = null, $format = null) {
3aa15970 246 if (!is_null($length) && !is_null($format)) {
667cdde3
TH
247 if ($format == 1) {
248 // Decimal places.
249 $x = sprintf('%.' . $length . 'F', $x);
01533e9c 250 } else if ($format == 2) {
667cdde3
TH
251 // Significant figures.
252 $x = sprintf('%.' . $length . 'g', $x);
667cdde3
TH
253 }
254 }
255 return $x;
256 }
257
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 }
267
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) {
c6f91070
TL
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.
667cdde3 284 }
c6f91070
TL
285 // In either case of an invalid $expression, we end here.
286 return '[Invalid expression ' . $expression . ']';
667cdde3
TH
287 }
288
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 }
299
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 }
311
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.
75aa674b 320 $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
667cdde3
TH
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 }
326}