AutorÃa | Ultima modificación | Ver Log |
<?php// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>./*** Calculated question definition class.** @package qtype* @subpackage calculated* @copyright 2011 The Open University* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/defined('MOODLE_INTERNAL') || die();require_once($CFG->dirroot . '/question/type/questionbase.php');require_once($CFG->dirroot . '/question/type/numerical/question.php');require_once($CFG->dirroot . '/question/type/calculated/questiontype.php');/*** Represents a calculated question.** @copyright 2011 The Open University* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class qtype_calculated_question extends qtype_numerical_questionimplements qtype_calculated_question_with_expressions {/** @var qtype_calculated_dataset_loader helper for loading the dataset. */public $datasetloader;/** @var qtype_calculated_variable_substituter stores the dataset we are using. */public $vs;/*** @var bool wheter the dataset item to use should be chose based on attempt* start time, rather than randomly.*/public $synchronised;public function start_attempt(question_attempt_step $step, $variant) {qtype_calculated_question_helper::start_attempt($this, $step, $variant);parent::start_attempt($step, $variant);}public function apply_attempt_state(question_attempt_step $step) {qtype_calculated_question_helper::apply_attempt_state($this, $step);parent::apply_attempt_state($step);}public function calculate_all_expressions() {$this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext);$this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback);foreach ($this->answers as $ans) {if ($ans->answer && $ans->answer !== '*') {$ans->answer = $this->vs->calculate($ans->answer,$ans->correctanswerlength, $ans->correctanswerformat);}$ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback,$ans->correctanswerlength, $ans->correctanswerformat);}// Replace expressions in hints referring MDL-36733.// Calculation through calculation() function in replace_expressions_in_text() function.// Validation through qtype_calculated_find_formula_errors() function in calculate() function.foreach ($this->hints as $hint) {$hint->hint = $this->vs->replace_expressions_in_text($hint->hint);}}public function get_num_variants() {return $this->datasetloader->get_number_of_items();}public function get_variants_selection_seed() {if (!empty($this->synchronised) &&$this->datasetloader->datasets_are_synchronised($this->category)) {return 'category' . $this->category;} else {return parent::get_variants_selection_seed();}}public function get_correct_response() {$answer = $this->get_correct_answer();if (!$answer) {return array();}$response = array('answer' => $this->vs->format_float($answer->answer,$answer->correctanswerlength, $answer->correctanswerformat));if ($this->has_separate_unit_field()) {$response['unit'] = $this->ap->get_default_unit();} else if ($this->unitdisplay == qtype_numerical::UNITINPUT) {$response['answer'] = $this->ap->add_unit($response['answer']);}return $response;}}/*** This interface defines the method that a quetsion type must implement if it* is to work with {@link qtype_calculated_question_helper}.** As well as this method, the class that implements this interface must have* fields* public $datasetloader; // of type qtype_calculated_dataset_loader* public $vs; // of type qtype_calculated_variable_substituter** @copyright 2011 The Open University* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/interface qtype_calculated_question_with_expressions {/*** Replace all the expression in the question definition with the values* computed from the selected dataset by calling $this->vs->calculate() and* $this->vs->replace_expressions_in_text() on the parts of the question* that require it.*/public function calculate_all_expressions();}/*** Helper class for questions that use datasets. Works with the interface* {@link qtype_calculated_question_with_expressions} and the class* {@link qtype_calculated_dataset_loader} to set up the value of each variable* in start_attempt, and restore that in apply_attempt_state.** @copyright 2011 The Open University* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/abstract class qtype_calculated_question_helper {public static function start_attempt(qtype_calculated_question_with_expressions $question,question_attempt_step $step, $variant) {$question->vs = new qtype_calculated_variable_substituter($question->datasetloader->get_values($variant),get_string('decsep', 'langconfig'));$question->calculate_all_expressions();foreach ($question->vs->get_values() as $name => $value) {$step->set_qt_var('_var_' . $name, $value);}}public static function apply_attempt_state(qtype_calculated_question_with_expressions $question, question_attempt_step $step) {$values = array();foreach ($step->get_qt_data() as $name => $value) {if (substr($name, 0, 5) === '_var_') {$values[substr($name, 5)] = $value;}}$question->vs = new qtype_calculated_variable_substituter($values, get_string('decsep', 'langconfig'));$question->calculate_all_expressions();}}/*** This class is responsible for loading the dataset that a question needs from* the database.** @copyright 2011 The Open University* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class qtype_calculated_dataset_loader {/** @var int the id of the question we are helping. */protected $questionid;/** @var int the id of the question we are helping. */protected $itemsavailable = null;/*** Constructor* @param int $questionid the question to load datasets for.*/public function __construct($questionid) {$this->questionid = $questionid;}/*** Get the number of items (different values) in each dataset used by this* question. This is the minimum number of items in any dataset used by this* question.* @return int the number of items available.*/public function get_number_of_items() {global $DB;if (is_null($this->itemsavailable)) {$this->itemsavailable = $DB->get_field_sql('SELECT MIN(qdd.itemcount)FROM {question_dataset_definitions} qddJOIN {question_datasets} qd ON qdd.id = qd.datasetdefinitionWHERE qd.question = ?', array($this->questionid), MUST_EXIST);}return $this->itemsavailable;}/*** Actually query the database for the values.* @param int $itemnumber which set of values to load.* @return array name => value;*/protected function load_values($itemnumber) {global $DB;return $DB->get_records_sql_menu('SELECT qdd.name, qdi.valueFROM {question_dataset_items} qdiJOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definitionJOIN {question_datasets} qd ON qdd.id = qd.datasetdefinitionWHERE qd.question = ?AND qdi.itemnumber = ?', array($this->questionid, $itemnumber));}/*** Load a particular set of values for each dataset used by this question.* @param int $itemnumber which set of values to load.* 0 < $itemnumber <= {@link get_number_of_items()}.* @return array name => value.*/public function get_values($itemnumber) {if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) {$a = new stdClass();$a->id = $this->questionid;$a->item = $itemnumber;throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a);}return $this->load_values($itemnumber);}public function datasets_are_synchronised($category) {global $DB;// We need to ensure that there are synchronised datasets, and that they// all use the right category.$categories = $DB->get_record_sql('SELECT MAX(qdd.category) AS max,MIN(qdd.category) AS minFROM {question_dataset_definitions} qddJOIN {question_datasets} qd ON qdd.id = qd.datasetdefinitionWHERE qd.question = ?AND qdd.category <> 0', array($this->questionid));return $categories && $categories->max == $category && $categories->min == $category;}}/*** This class holds the current values of all the variables used by a calculated* question.** It can compute formulae using those values, and can substitute equations* embedded in text.** @copyright 2011 The Open University* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class qtype_calculated_variable_substituter {/** @var array variable name => value */protected $values;/** @var string character to use for the decimal point in displayed numbers. */protected $decimalpoint;/** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */protected $search;/*** @var array variable values, with negative numbers wrapped in (...).* Used by {@link substitute_values()}.*/protected $safevalue;/*** @var array variable values, with negative numbers wrapped in (...).* Used by {@link substitute_values()}.*/protected $prettyvalue;/*** Constructor* @param array $values variable name => value.*/public function __construct(array $values, $decimalpoint) {$this->values = $values;$this->decimalpoint = $decimalpoint;// Prepare an array for {@link substitute_values()}.$this->search = array();foreach ($values as $name => $value) {if (!is_numeric($value)) {$a = new stdClass();$a->name = '{' . $name . '}';$a->value = $value;throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);}$this->search[] = '{' . $name . '}';$this->safevalue[] = '(' . $value . ')';$this->prettyvalue[] = $this->format_float($value);}}/*** Display a float properly formatted with a certain number of decimal places.* @param number $x the number to format* @param int $length restrict to this many decimal places or significant* figures. If null, the number is not rounded.* @param int format 1 => decimalformat, 2 => significantfigures.* @return string formtted number.*/public function format_float($x, $length = null, $format = null) {if (is_nan($x)) {$x = 'NAN';} else if (is_infinite($x)) {$x = ($x < 0) ? '-INF' : 'INF';} else if (!is_null($length) && !is_null($format)) {if ($format == '1' ) { // Answer is to have $length decimals.// Decimal places.$x = sprintf('%.' . $length . 'F', $x);} else if ($x) { // Significant figures does only apply if the result is non-zero.$answer = $x;// Convert to positive answer.if ($answer < 0) {$answer = -$answer;$sign = '-';} else {$sign = '';}// Determine the format 0.[1-9][0-9]* for the answer...$p10 = 0;while ($answer < 1) {--$p10;$answer *= 10;}while ($answer >= 1) {++$p10;$answer /= 10;}// ... and have the answer rounded of to the correct length.$answer = round($answer, $length);// If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.if ($answer >= 1) {++$p10;$answer /= 10;}// Have the answer written on a suitable format.// Either scientific or plain numeric.if (-2 > $p10 || 4 < $p10) {// Use scientific format.$exponent = 'e'.--$p10;$answer *= 10;if (1 == $length) {$x = $sign.$answer.$exponent;} else {// Attach additional zeros at the end of $answer.$answer .= (1 == strlen($answer) ? '.' : ''). '00000000000000000000000000000000000000000x';$x = $sign.substr($answer, 0, $length +1).$exponent;}} else {// Stick to plain numeric format.$answer *= "1e{$p10}";if (0.1 <= $answer / "1e{$length}") {$x = $sign.$answer;} else {// Could be an idea to add some zeros here.$answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : ''). '00000000000000000000000000000000000000000x';$oklen = $length + ($p10 < 1 ? 2-$p10 : 1);$x = $sign.substr($answer, 0, $oklen);}}} else {$x = 0.0;}}return str_replace('.', $this->decimalpoint, $x);}/*** Return an array of the variables and their values.* @return array name => value.*/public function get_values() {return $this->values;}/*** Evaluate an expression using the variable values.* @param string $expression the expression. A PHP expression with placeholders* like {a} for where the variables need to go.* @return float the computed result.*/public function calculate($expression) {// Make sure no malicious code is present in the expression. Refer MDL-46148 for details.if ($error = qtype_calculated_find_formula_errors($expression)) {throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error);}$expression = $this->substitute_values_for_eval($expression);if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) {// Some placeholders were not substituted.throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '','{' . reset($datasets) . '}');}return $this->calculate_raw($expression);}/*** Evaluate an expression after the variable values have been substituted.* @param string $expression the expression. A PHP expression with placeholders* like {a} for where the variables need to go.* @return float the computed result.*/protected function calculate_raw($expression) {try {// In older PHP versions this this is a way to validate code passed to eval.// The trick came from http://php.net/manual/en/function.eval.php.if (@eval('return true; $result = ' . $expression . ';')) {return eval('return ' . $expression . ';');}} catch (Throwable $e) {// PHP7 and later now throws ParseException and friends from eval(),// which is much better.}// In either case of an invalid $expression, we end here.throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);}/*** Substitute variable placehodlers like {a} with their value wrapped in ().* @param string $expression the expression. A PHP expression with placeholders* like {a} for where the variables need to go.* @return string the expression with each placeholder replaced by the* corresponding value.*/protected function substitute_values_for_eval($expression) {return str_replace($this->search, $this->safevalue, $expression);}/*** Substitute variable placehodlers like {a} with their value without wrapping* the value in anything.* @param string $text some content with placeholders* like {a} for where the variables need to go.* @return string the expression with each placeholder replaced by the* corresponding value.*/protected function substitute_values_pretty($text) {return str_replace($this->search, $this->prettyvalue, $text);}/*** Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})* in some text with the corresponding values.* @param string $text the text to process.* @return string the text with values substituted.*/public function replace_expressions_in_text($text, $length = null, $format = null) {$vs = $this; // Can't use $this in a PHP closure.$text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,function ($matches) use ($vs, $format, $length) {return $vs->format_float($vs->calculate($matches[1]), $length, $format);}, $text);return $this->substitute_values_pretty($text);}}