Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]
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 foreach ($values as $name => $value) { 315 if (!is_numeric($value)) { 316 $a = new stdClass(); 317 $a->name = '{' . $name . '}'; 318 $a->value = $value; 319 throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a); 320 } 321 322 $this->search[] = '{' . $name . '}'; 323 $this->safevalue[] = '(' . $value . ')'; 324 $this->prettyvalue[] = $this->format_float($value); 325 } 326 } 327 328 /** 329 * Display a float properly formatted with a certain number of decimal places. 330 * @param number $x the number to format 331 * @param int $length restrict to this many decimal places or significant 332 * figures. If null, the number is not rounded. 333 * @param int format 1 => decimalformat, 2 => significantfigures. 334 * @return string formtted number. 335 */ 336 public function format_float($x, $length = null, $format = null) { 337 if (is_nan($x)) { 338 $x = 'NAN'; 339 } else if (is_infinite($x)) { 340 $x = ($x < 0) ? '-INF' : 'INF'; 341 } else if (!is_null($length) && !is_null($format)) { 342 if ($format == '1' ) { // Answer is to have $length decimals. 343 // Decimal places. 344 $x = sprintf('%.' . $length . 'F', $x); 345 346 } else if ($x) { // Significant figures does only apply if the result is non-zero. 347 $answer = $x; 348 // Convert to positive answer. 349 if ($answer < 0) { 350 $answer = -$answer; 351 $sign = '-'; 352 } else { 353 $sign = ''; 354 } 355 356 // Determine the format 0.[1-9][0-9]* for the answer... 357 $p10 = 0; 358 while ($answer < 1) { 359 --$p10; 360 $answer *= 10; 361 } 362 while ($answer >= 1) { 363 ++$p10; 364 $answer /= 10; 365 } 366 // ... and have the answer rounded of to the correct length. 367 $answer = round($answer, $length); 368 369 // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. 370 if ($answer >= 1) { 371 ++$p10; 372 $answer /= 10; 373 } 374 375 // Have the answer written on a suitable format. 376 // Either scientific or plain numeric. 377 if (-2 > $p10 || 4 < $p10) { 378 // Use scientific format. 379 $exponent = 'e'.--$p10; 380 $answer *= 10; 381 if (1 == $length) { 382 $x = $sign.$answer.$exponent; 383 } else { 384 // Attach additional zeros at the end of $answer. 385 $answer .= (1 == strlen($answer) ? '.' : '') 386 . '00000000000000000000000000000000000000000x'; 387 $x = $sign 388 .substr($answer, 0, $length +1).$exponent; 389 } 390 } else { 391 // Stick to plain numeric format. 392 $answer *= "1e{$p10}"; 393 if (0.1 <= $answer / "1e{$length}") { 394 $x = $sign.$answer; 395 } else { 396 // Could be an idea to add some zeros here. 397 $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') 398 . '00000000000000000000000000000000000000000x'; 399 $oklen = $length + ($p10 < 1 ? 2-$p10 : 1); 400 $x = $sign.substr($answer, 0, $oklen); 401 } 402 } 403 404 } else { 405 $x = 0.0; 406 } 407 } 408 return str_replace('.', $this->decimalpoint, $x); 409 } 410 411 /** 412 * Return an array of the variables and their values. 413 * @return array name => value. 414 */ 415 public function get_values() { 416 return $this->values; 417 } 418 419 /** 420 * Evaluate an expression using the variable values. 421 * @param string $expression the expression. A PHP expression with placeholders 422 * like {a} for where the variables need to go. 423 * @return float the computed result. 424 */ 425 public function calculate($expression) { 426 // Make sure no malicious code is present in the expression. Refer MDL-46148 for details. 427 if ($error = qtype_calculated_find_formula_errors($expression)) { 428 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error); 429 } 430 $expression = $this->substitute_values_for_eval($expression); 431 if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) { 432 // Some placeholders were not substituted. 433 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', 434 '{' . reset($datasets) . '}'); 435 } 436 return $this->calculate_raw($expression); 437 } 438 439 /** 440 * Evaluate an expression after the variable values have been substituted. 441 * @param string $expression the expression. A PHP expression with placeholders 442 * like {a} for where the variables need to go. 443 * @return float the computed result. 444 */ 445 protected function calculate_raw($expression) { 446 try { 447 // In older PHP versions this this is a way to validate code passed to eval. 448 // The trick came from http://php.net/manual/en/function.eval.php. 449 if (@eval('return true; $result = ' . $expression . ';')) { 450 return eval('return ' . $expression . ';'); 451 } 452 } catch (Throwable $e) { 453 // PHP7 and later now throws ParseException and friends from eval(), 454 // which is much better. 455 } 456 // In either case of an invalid $expression, we end here. 457 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression); 458 } 459 460 /** 461 * Substitute variable placehodlers like {a} with their value wrapped in (). 462 * @param string $expression the expression. A PHP expression with placeholders 463 * like {a} for where the variables need to go. 464 * @return string the expression with each placeholder replaced by the 465 * corresponding value. 466 */ 467 protected function substitute_values_for_eval($expression) { 468 return str_replace($this->search, $this->safevalue, $expression); 469 } 470 471 /** 472 * Substitute variable placehodlers like {a} with their value without wrapping 473 * the value in anything. 474 * @param string $text some content with placeholders 475 * like {a} for where the variables need to go. 476 * @return string the expression with each placeholder replaced by the 477 * corresponding value. 478 */ 479 protected function substitute_values_pretty($text) { 480 return str_replace($this->search, $this->prettyvalue, $text); 481 } 482 483 /** 484 * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}}) 485 * in some text with the corresponding values. 486 * @param string $text the text to process. 487 * @return string the text with values substituted. 488 */ 489 public function replace_expressions_in_text($text, $length = null, $format = null) { 490 $vs = $this; // Can't use $this in a PHP closure. 491 $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX, 492 function ($matches) use ($vs, $format, $length) { 493 return $vs->format_float($vs->calculate($matches[1]), $length, $format); 494 }, $text); 495 return $this->substitute_values_pretty($text); 496 } 497 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body