See Release Notes
Long Term Support Release
Differences Between: [Versions 39 and 401] [Versions 401 and 402] [Versions 401 and 403]
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 * Calculated question definition class. 19 * 20 * @package qtype 21 * @subpackage calculated 22 * @copyright 2011 The Open University 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 require_once($CFG->dirroot . '/question/type/questionbase.php'); 30 require_once($CFG->dirroot . '/question/type/numerical/question.php'); 31 require_once($CFG->dirroot . '/question/type/calculated/questiontype.php'); 32 33 /** 34 * Represents a calculated question. 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_calculated_question extends qtype_numerical_question 40 implements qtype_calculated_question_with_expressions { 41 42 /** @var qtype_calculated_dataset_loader helper for loading the dataset. */ 43 public $datasetloader; 44 45 /** @var qtype_calculated_variable_substituter stores the dataset we are using. */ 46 public $vs; 47 48 /** 49 * @var bool wheter the dataset item to use should be chose based on attempt 50 * start time, rather than randomly. 51 */ 52 public $synchronised; 53 54 public function start_attempt(question_attempt_step $step, $variant) { 55 qtype_calculated_question_helper::start_attempt($this, $step, $variant); 56 parent::start_attempt($step, $variant); 57 } 58 59 public function apply_attempt_state(question_attempt_step $step) { 60 qtype_calculated_question_helper::apply_attempt_state($this, $step); 61 parent::apply_attempt_state($step); 62 } 63 64 public function calculate_all_expressions() { 65 $this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext); 66 $this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback); 67 68 foreach ($this->answers as $ans) { 69 if ($ans->answer && $ans->answer !== '*') { 70 $ans->answer = $this->vs->calculate($ans->answer, 71 $ans->correctanswerlength, $ans->correctanswerformat); 72 } 73 $ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback, 74 $ans->correctanswerlength, $ans->correctanswerformat); 75 } 76 } 77 78 public function get_num_variants() { 79 return $this->datasetloader->get_number_of_items(); 80 } 81 82 public function get_variants_selection_seed() { 83 if (!empty($this->synchronised) && 84 $this->datasetloader->datasets_are_synchronised($this->category)) { 85 return 'category' . $this->category; 86 } else { 87 return parent::get_variants_selection_seed(); 88 } 89 } 90 91 public function get_correct_response() { 92 $answer = $this->get_correct_answer(); 93 if (!$answer) { 94 return array(); 95 } 96 97 $response = array('answer' => $this->vs->format_float($answer->answer, 98 $answer->correctanswerlength, $answer->correctanswerformat)); 99 100 if ($this->has_separate_unit_field()) { 101 $response['unit'] = $this->ap->get_default_unit(); 102 } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) { 103 $response['answer'] = $this->ap->add_unit($response['answer']); 104 } 105 106 return $response; 107 } 108 109 } 110 111 112 /** 113 * This interface defines the method that a quetsion type must implement if it 114 * is to work with {@link qtype_calculated_question_helper}. 115 * 116 * As well as this method, the class that implements this interface must have 117 * fields 118 * public $datasetloader; // of type qtype_calculated_dataset_loader 119 * public $vs; // of type qtype_calculated_variable_substituter 120 * 121 * @copyright 2011 The Open University 122 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 123 */ 124 interface qtype_calculated_question_with_expressions { 125 /** 126 * Replace all the expression in the question definition with the values 127 * computed from the selected dataset by calling $this->vs->calculate() and 128 * $this->vs->replace_expressions_in_text() on the parts of the question 129 * that require it. 130 */ 131 public function calculate_all_expressions(); 132 } 133 134 135 /** 136 * Helper class for questions that use datasets. Works with the interface 137 * {@link qtype_calculated_question_with_expressions} and the class 138 * {@link qtype_calculated_dataset_loader} to set up the value of each variable 139 * in start_attempt, and restore that in apply_attempt_state. 140 * 141 * @copyright 2011 The Open University 142 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 143 */ 144 abstract class qtype_calculated_question_helper { 145 public static function start_attempt( 146 qtype_calculated_question_with_expressions $question, 147 question_attempt_step $step, $variant) { 148 149 $question->vs = new qtype_calculated_variable_substituter( 150 $question->datasetloader->get_values($variant), 151 get_string('decsep', 'langconfig')); 152 $question->calculate_all_expressions(); 153 154 foreach ($question->vs->get_values() as $name => $value) { 155 $step->set_qt_var('_var_' . $name, $value); 156 } 157 } 158 159 public static function apply_attempt_state( 160 qtype_calculated_question_with_expressions $question, question_attempt_step $step) { 161 $values = array(); 162 foreach ($step->get_qt_data() as $name => $value) { 163 if (substr($name, 0, 5) === '_var_') { 164 $values[substr($name, 5)] = $value; 165 } 166 } 167 168 $question->vs = new qtype_calculated_variable_substituter( 169 $values, get_string('decsep', 'langconfig')); 170 $question->calculate_all_expressions(); 171 } 172 } 173 174 175 /** 176 * This class is responsible for loading the dataset that a question needs from 177 * the database. 178 * 179 * @copyright 2011 The Open University 180 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 181 */ 182 class qtype_calculated_dataset_loader { 183 /** @var int the id of the question we are helping. */ 184 protected $questionid; 185 186 /** @var int the id of the question we are helping. */ 187 protected $itemsavailable = null; 188 189 /** 190 * Constructor 191 * @param int $questionid the question to load datasets for. 192 */ 193 public function __construct($questionid) { 194 $this->questionid = $questionid; 195 } 196 197 /** 198 * Get the number of items (different values) in each dataset used by this 199 * question. This is the minimum number of items in any dataset used by this 200 * question. 201 * @return int the number of items available. 202 */ 203 public function get_number_of_items() { 204 global $DB; 205 206 if (is_null($this->itemsavailable)) { 207 $this->itemsavailable = $DB->get_field_sql(' 208 SELECT MIN(qdd.itemcount) 209 FROM {question_dataset_definitions} qdd 210 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition 211 WHERE qd.question = ? 212 ', array($this->questionid), MUST_EXIST); 213 } 214 215 return $this->itemsavailable; 216 } 217 218 /** 219 * Actually query the database for the values. 220 * @param int $itemnumber which set of values to load. 221 * @return array name => value; 222 */ 223 protected function load_values($itemnumber) { 224 global $DB; 225 226 return $DB->get_records_sql_menu(' 227 SELECT qdd.name, qdi.value 228 FROM {question_dataset_items} qdi 229 JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition 230 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition 231 WHERE qd.question = ? 232 AND qdi.itemnumber = ? 233 ', array($this->questionid, $itemnumber)); 234 } 235 236 /** 237 * Load a particular set of values for each dataset used by this question. 238 * @param int $itemnumber which set of values to load. 239 * 0 < $itemnumber <= {@link get_number_of_items()}. 240 * @return array name => value. 241 */ 242 public function get_values($itemnumber) { 243 if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) { 244 $a = new stdClass(); 245 $a->id = $this->questionid; 246 $a->item = $itemnumber; 247 throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a); 248 } 249 250 return $this->load_values($itemnumber); 251 } 252 253 public function datasets_are_synchronised($category) { 254 global $DB; 255 // We need to ensure that there are synchronised datasets, and that they 256 // all use the right category. 257 $categories = $DB->get_record_sql(' 258 SELECT MAX(qdd.category) AS max, 259 MIN(qdd.category) AS min 260 FROM {question_dataset_definitions} qdd 261 JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition 262 WHERE qd.question = ? 263 AND qdd.category <> 0 264 ', array($this->questionid)); 265 266 return $categories && $categories->max == $category && $categories->min == $category; 267 } 268 } 269 270 271 /** 272 * This class holds the current values of all the variables used by a calculated 273 * question. 274 * 275 * It can compute formulae using those values, and can substitute equations 276 * embedded in text. 277 * 278 * @copyright 2011 The Open University 279 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 280 */ 281 class qtype_calculated_variable_substituter { 282 283 /** @var array variable name => value */ 284 protected $values; 285 286 /** @var string character to use for the decimal point in displayed numbers. */ 287 protected $decimalpoint; 288 289 /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */ 290 protected $search; 291 292 /** 293 * @var array variable values, with negative numbers wrapped in (...). 294 * Used by {@link substitute_values()}. 295 */ 296 protected $safevalue; 297 298 /** 299 * @var array variable values, with negative numbers wrapped in (...). 300 * Used by {@link substitute_values()}. 301 */ 302 protected $prettyvalue; 303 304 /** 305 * Constructor 306 * @param array $values variable name => value. 307 */ 308 public function __construct(array $values, $decimalpoint) { 309 $this->values = $values; 310 $this->decimalpoint = $decimalpoint; 311 312 // Prepare an array for {@link substitute_values()}. 313 $this->search = array(); 314 $this->replace = array(); 315 foreach ($values as $name => $value) { 316 if (!is_numeric($value)) { 317 $a = new stdClass(); 318 $a->name = '{' . $name . '}'; 319 $a->value = $value; 320 throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a); 321 } 322 323 $this->search[] = '{' . $name . '}'; 324 $this->safevalue[] = '(' . $value . ')'; 325 $this->prettyvalue[] = $this->format_float($value); 326 } 327 } 328 329 /** 330 * Display a float properly formatted with a certain number of decimal places. 331 * @param number $x the number to format 332 * @param int $length restrict to this many decimal places or significant 333 * figures. If null, the number is not rounded. 334 * @param int format 1 => decimalformat, 2 => significantfigures. 335 * @return string formtted number. 336 */ 337 public function format_float($x, $length = null, $format = null) { 338 if (is_nan($x)) { 339 $x = 'NAN'; 340 } else if (is_infinite($x)) { 341 $x = ($x < 0) ? '-INF' : 'INF'; 342 } else if (!is_null($length) && !is_null($format)) { 343 if ($format == '1' ) { // Answer is to have $length decimals. 344 // Decimal places. 345 $x = sprintf('%.' . $length . 'F', $x); 346 347 } else if ($x) { // Significant figures does only apply if the result is non-zero. 348 $answer = $x; 349 // Convert to positive answer. 350 if ($answer < 0) { 351 $answer = -$answer; 352 $sign = '-'; 353 } else { 354 $sign = ''; 355 } 356 357 // Determine the format 0.[1-9][0-9]* for the answer... 358 $p10 = 0; 359 while ($answer < 1) { 360 --$p10; 361 $answer *= 10; 362 } 363 while ($answer >= 1) { 364 ++$p10; 365 $answer /= 10; 366 } 367 // ... and have the answer rounded of to the correct length. 368 $answer = round($answer, $length); 369 370 // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. 371 if ($answer >= 1) { 372 ++$p10; 373 $answer /= 10; 374 } 375 376 // Have the answer written on a suitable format. 377 // Either scientific or plain numeric. 378 if (-2 > $p10 || 4 < $p10) { 379 // Use scientific format. 380 $exponent = 'e'.--$p10; 381 $answer *= 10; 382 if (1 == $length) { 383 $x = $sign.$answer.$exponent; 384 } else { 385 // Attach additional zeros at the end of $answer. 386 $answer .= (1 == strlen($answer) ? '.' : '') 387 . '00000000000000000000000000000000000000000x'; 388 $x = $sign 389 .substr($answer, 0, $length +1).$exponent; 390 } 391 } else { 392 // Stick to plain numeric format. 393 $answer *= "1e{$p10}"; 394 if (0.1 <= $answer / "1e{$length}") { 395 $x = $sign.$answer; 396 } else { 397 // Could be an idea to add some zeros here. 398 $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') 399 . '00000000000000000000000000000000000000000x'; 400 $oklen = $length + ($p10 < 1 ? 2-$p10 : 1); 401 $x = $sign.substr($answer, 0, $oklen); 402 } 403 } 404 405 } else { 406 $x = 0.0; 407 } 408 } 409 return str_replace('.', $this->decimalpoint, $x); 410 } 411 412 /** 413 * Return an array of the variables and their values. 414 * @return array name => value. 415 */ 416 public function get_values() { 417 return $this->values; 418 } 419 420 /** 421 * Evaluate an expression using the variable values. 422 * @param string $expression the expression. A PHP expression with placeholders 423 * like {a} for where the variables need to go. 424 * @return float the computed result. 425 */ 426 public function calculate($expression) { 427 // Make sure no malicious code is present in the expression. Refer MDL-46148 for details. 428 if ($error = qtype_calculated_find_formula_errors($expression)) { 429 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error); 430 } 431 $expression = $this->substitute_values_for_eval($expression); 432 if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) { 433 // Some placeholders were not substituted. 434 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', 435 '{' . reset($datasets) . '}'); 436 } 437 return $this->calculate_raw($expression); 438 } 439 440 /** 441 * Evaluate an expression after the variable values have been substituted. 442 * @param string $expression the expression. A PHP expression with placeholders 443 * like {a} for where the variables need to go. 444 * @return float the computed result. 445 */ 446 protected function calculate_raw($expression) { 447 try { 448 // In older PHP versions this this is a way to validate code passed to eval. 449 // The trick came from http://php.net/manual/en/function.eval.php. 450 if (@eval('return true; $result = ' . $expression . ';')) { 451 return eval('return ' . $expression . ';'); 452 } 453 } catch (Throwable $e) { 454 // PHP7 and later now throws ParseException and friends from eval(), 455 // which is much better. 456 } 457 // In either case of an invalid $expression, we end here. 458 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression); 459 } 460 461 /** 462 * Substitute variable placehodlers like {a} with their value wrapped in (). 463 * @param string $expression the expression. A PHP expression with placeholders 464 * like {a} for where the variables need to go. 465 * @return string the expression with each placeholder replaced by the 466 * corresponding value. 467 */ 468 protected function substitute_values_for_eval($expression) { 469 return str_replace($this->search, $this->safevalue, $expression); 470 } 471 472 /** 473 * Substitute variable placehodlers like {a} with their value without wrapping 474 * the value in anything. 475 * @param string $text some content with placeholders 476 * like {a} for where the variables need to go. 477 * @return string the expression with each placeholder replaced by the 478 * corresponding value. 479 */ 480 protected function substitute_values_pretty($text) { 481 return str_replace($this->search, $this->prettyvalue, $text); 482 } 483 484 /** 485 * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}}) 486 * in some text with the corresponding values. 487 * @param string $text the text to process. 488 * @return string the text with values substituted. 489 */ 490 public function replace_expressions_in_text($text, $length = null, $format = null) { 491 $vs = $this; // Can't use $this in a PHP closure. 492 $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX, 493 function ($matches) use ($vs, $format, $length) { 494 return $vs->format_float($vs->calculate($matches[1]), $length, $format); 495 }, $text); 496 return $this->substitute_values_pretty($text); 497 } 498 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body