Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401]

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