Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 401] [Versions 310 and 402] [Versions 310 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   * Upgrade library code for the calculated question type.
  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  
  30  /**
  31   * Class for converting attempt data for calculated questions when upgrading
  32   * 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_calculated_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      public function question_summary() {
  60          return ''; // Done later, after we know which dataset is used.
  61      }
  62  
  63      public function right_answer() {
  64          foreach ($this->question->options->answers as $ans) {
  65              if ($ans->fraction > 0.999) {
  66                  $right = $this->calculate($ans->answer);
  67  
  68                  if (empty($this->question->options->units)) {
  69                      return $right;
  70                  }
  71  
  72                  $unit = reset($this->question->options->units);
  73                  $unit = $unit->unit;
  74                  if (!empty($this->question->options->unitsleft)) {
  75                      return $unit . ' ' . $right;
  76                  } else {
  77                      return $right . ' ' . $unit;
  78                  }
  79              }
  80          }
  81      }
  82  
  83      protected function parse_response($state) {
  84          if (strpos($state->answer, '-') < 7) {
  85              // Broken state, skip it.
  86              throw new coding_exception("Brokes state {$state->id} for calculated
  87                      question {$state->question}. (It did not specify a dataset.");
  88          }
  89          list($datasetbit, $realanswer) = explode('-', $state->answer, 2);
  90          $selecteditem = substr($datasetbit, 7);
  91  
  92          if (is_null($this->selecteditem)) {
  93              $this->load_dataset($selecteditem);
  94          } else if ($this->selecteditem != $selecteditem) {
  95              $this->logger->log_assumption("Different states for calculated question
  96                      {$state->question} used different dataset items. Ignoring the change
  97                      in state {$state->id} and coninuting to use item {$this->selecteditem}.");
  98          }
  99  
 100          if (!$realanswer) {
 101              return array('', '');
 102          }
 103  
 104          if (strpos($realanswer, '|||||') === false) {
 105              $answer = $realanswer;
 106              $unit = '';
 107          } else {
 108              list($answer, $unit) = explode('|||||', $realanswer, 2);
 109          }
 110  
 111          return array($answer, $unit);
 112      }
 113  
 114      public function response_summary($state) {
 115          list($answer, $unit) = $this->parse_response($state);
 116  
 117          if (empty($answer) && empty($unit)) {
 118              $resp = null;
 119          } else {
 120              $resp = $answer;
 121          }
 122  
 123          if (!empty($unit)) {
 124              if (!empty($this->question->options->unitsleft)) {
 125                  $resp = trim($unit . ' ' . $resp);
 126              } else {
 127                  $resp = trim($resp . ' ' . $unit);
 128              }
 129          }
 130  
 131          return $resp;
 132      }
 133  
 134      public function was_answered($state) {
 135          return !empty($state->answer);
 136      }
 137  
 138      public function set_first_step_data_elements($state, &$data) {
 139          $this->parse_response($state);
 140          $this->updater->qa->questionsummary = $this->to_text(
 141                  $this->replace_expressions_in_text($this->question->questiontext));
 142          $this->updater->qa->rightanswer = $this->right_answer($this->question);
 143  
 144          foreach ($this->values as $name => $value) {
 145              $data['_var_' . $name] = $value;
 146          }
 147  
 148          $data['_separators'] = '.$,';
 149      }
 150  
 151      public function supply_missing_first_step_data(&$data) {
 152          $data['_separators'] = '.$,';
 153      }
 154  
 155      public function set_data_elements_for_step($state, &$data) {
 156          if (empty($state->answer)) {
 157              return;
 158          }
 159  
 160          list($answer, $unit) = $this->parse_response($state);
 161          if (!empty($this->question->options->showunits) &&
 162                  $this->question->options->showunits == 1) {
 163              // Multichoice units.
 164              $data['answer'] = $answer;
 165              $data['unit'] = $unit;
 166          } else if (!empty($this->question->options->unitsleft)) {
 167              if (!empty($unit)) {
 168                  $data['answer'] = $unit . ' ' . $answer;
 169              } else {
 170                  $data['answer'] = $answer;
 171              }
 172          } else {
 173              if (!empty($unit)) {
 174                  $data['answer'] = $answer . ' ' . $unit;
 175              } else {
 176                  $data['answer'] = $answer;
 177              }
 178          }
 179      }
 180  
 181      public function load_dataset($selecteditem) {
 182          $this->selecteditem = $selecteditem;
 183          $this->updater->qa->variant = $selecteditem;
 184          $this->values = $this->qeupdater->load_dataset(
 185                  $this->question->id, $selecteditem);
 186  
 187          // Prepare an array for {@link substitute_values()}.
 188          $this->search = array();
 189          $this->safevalue = array();
 190          $this->prettyvalue = array();
 191          foreach ($this->values as $name => $value) {
 192              if (!is_numeric($value)) {
 193                  $a = new stdClass();
 194                  $a->name = '{' . $name . '}';
 195                  $a->value = $value;
 196                  throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
 197              }
 198  
 199              $this->search[] = '{' . $name . '}';
 200              $this->safevalue[] = '(' . $value . ')';
 201              $this->prettyvalue[] = $this->format_float($value);
 202          }
 203      }
 204  
 205      /**
 206       * This function should be identical to
 207       * {@link qtype_calculated_variable_substituter::format_float()}. Except that
 208       * we do not try to do locale-aware replacement of the decimal point.
 209       *
 210       * Having to copy it here is a pain, but it is the standard rule about not
 211       * using library code (which may change in future) in upgrade code, which
 212       * exists at a point in time.
 213       *
 214       * Display a float properly formatted with a certain number of decimal places.
 215       * @param number $x the number to format
 216       * @param int $length restrict to this many decimal places or significant
 217       *      figures. If null, the number is not rounded.
 218       * @param int format 1 => decimalformat, 2 => significantfigures.
 219       * @return string formtted number.
 220       */
 221      public function format_float($x, $length = null, $format = null) {
 222          if (!is_null($length) && !is_null($format)) {
 223              if ($format == 1) {
 224                  // Decimal places.
 225                  $x = sprintf('%.' . $length . 'F', $x);
 226              } else if ($format == 2) {
 227                  // Significant figures.
 228                  $x = sprintf('%.' . $length . 'g', $x);
 229              }
 230          }
 231          return $x;
 232      }
 233  
 234      /**
 235       * Evaluate an expression using the variable values.
 236       * @param string $expression the expression. A PHP expression with placeholders
 237       *      like {a} for where the variables need to go.
 238       * @return float the computed result.
 239       */
 240      public function calculate($expression) {
 241          return $this->calculate_raw($this->substitute_values_for_eval($expression));
 242      }
 243  
 244      /**
 245       * Evaluate an expression after the variable values have been substituted.
 246       * @param string $expression the expression. A PHP expression with placeholders
 247       *      like {a} for where the variables need to go.
 248       * @return float the computed result.
 249       */
 250      protected function calculate_raw($expression) {
 251          try {
 252              // In older PHP versions this this is a way to validate code passed to eval.
 253              // The trick came from http://php.net/manual/en/function.eval.php.
 254              if (@eval('return true; $result = ' . $expression . ';')) {
 255                  return eval('return ' . $expression . ';');
 256              }
 257          } catch (Throwable $e) {
 258              // PHP7 and later now throws ParseException and friends from eval(),
 259              // which is much better.
 260          }
 261          // In either case of an invalid $expression, we end here.
 262          return '[Invalid expression ' . $expression . ']';
 263      }
 264  
 265      /**
 266       * Substitute variable placehodlers like {a} with their value wrapped in ().
 267       * @param string $expression the expression. A PHP expression with placeholders
 268       *      like {a} for where the variables need to go.
 269       * @return string the expression with each placeholder replaced by the
 270       *      corresponding value.
 271       */
 272      protected function substitute_values_for_eval($expression) {
 273          return str_replace($this->search, $this->safevalue, $expression);
 274      }
 275  
 276      /**
 277       * Substitute variable placehodlers like {a} with their value without wrapping
 278       * the value in anything.
 279       * @param string $text some content with placeholders
 280       *      like {a} for where the variables need to go.
 281       * @return string the expression with each placeholder replaced by the
 282       *      corresponding value.
 283       */
 284      protected function substitute_values_pretty($text) {
 285          return str_replace($this->search, $this->prettyvalue, $text);
 286      }
 287  
 288      /**
 289       * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
 290       * in some text with the corresponding values.
 291       * @param string $text the text to process.
 292       * @return string the text with values substituted.
 293       */
 294      public function replace_expressions_in_text($text, $length = null, $format = null) {
 295          $vs = $this; // Can't see to use $this in a PHP closure.
 296          $text = preg_replace_callback('~\{=([^{}]*(?:\{[^{}]+}[^{}]*)*)}~',
 297                  function ($matches) use ($vs, $format, $length) {
 298                      return $vs->format_float($vs->calculate($matches[1]), $length, $format);
 299                  }, $text);
 300          return $this->substitute_values_pretty($text);
 301      }
 302  }