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 multiple-choice questions
  19   * when upgrading 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_calculatedmulti
  24   * @copyright  2011 The Open University
  25   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  26   */
  27  class qtype_calculatedmulti_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      protected $order;
  48  
  49      public function question_summary() {
  50          return ''; // Done later, after we know which dataset is used.
  51      }
  52  
  53      public function right_answer() {
  54          if ($this->question->options->single) {
  55              foreach ($this->question->options->answers as $ans) {
  56                  if ($ans->fraction > 0.999) {
  57                      return $this->to_text($this->replace_expressions_in_text($ans->answer));
  58                  }
  59              }
  60          } else {
  61              $rightbits = [];
  62              foreach ($this->question->options->answers as $ans) {
  63                  if ($ans->fraction >= 0.000001) {
  64                      $rightbits[] = $this->to_text($this->replace_expressions_in_text($ans->answer));
  65                  }
  66              }
  67              return implode('; ', $rightbits);
  68          }
  69      }
  70  
  71      protected function explode_answer($state) {
  72          if (strpos($state->answer, '-') < 7) {
  73              // Broken state, skip it.
  74              throw new coding_exception("Brokes state {$state->id} for calcluatedmulti
  75                      question {$state->question}. (It did not specify a dataset.");
  76          }
  77          [$datasetbit, $answer] = 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 calcluatedmulti 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 (strpos($answer, ':') !== false) {
  89              [$order, $responses] = explode(':', $answer);
  90              return $responses;
  91          } else {
  92              // Sometimes, a bug means that a state is missing the <order>: bit,
  93              // We need to deal with that.
  94              $this->logger->log_assumption("Dealing with missing order information
  95                      in attempt at multiple choice question {$this->question->id}");
  96              return $answer;
  97          }
  98      }
  99  
 100      public function response_summary($state) {
 101          $responses = $this->explode_answer($state);
 102          if ($this->question->options->single) {
 103              if (is_numeric($responses)) {
 104                  if (array_key_exists($responses, $this->question->options->answers)) {
 105                      return $this->to_text($this->replace_expressions_in_text(
 106                          $this->question->options->answers[$responses]->answer
 107                      ));
 108                  } else {
 109                      $this->logger->log_assumption("Dealing with a place where the
 110                              student selected a choice that was later deleted for
 111                              multiple choice question {$this->question->id}");
 112                      return '[CHOICE THAT WAS LATER DELETED]';
 113                  }
 114              } else {
 115                  return null;
 116              }
 117          } else {
 118              if (!empty($responses)) {
 119                  $responses = explode(',', $responses);
 120                  $bits = [];
 121                  foreach ($responses as $response) {
 122                      if (array_key_exists($response, $this->question->options->answers)) {
 123                          $bits[] = $this->to_text($this->replace_expressions_in_text(
 124                              $this->question->options->answers[$response]->answer
 125                          ));
 126                      } else {
 127                          $this->logger->log_assumption("Dealing with a place where the
 128                                  student selected a choice that was later deleted for
 129                                  multiple choice question {$this->question->id}");
 130                          $bits[] = '[CHOICE THAT WAS LATER DELETED]';
 131                      }
 132                  }
 133                  return implode('; ', $bits);
 134              } else {
 135                  return null;
 136              }
 137          }
 138      }
 139  
 140      public function was_answered($state) {
 141          $responses = $this->explode_answer($state);
 142          if ($this->question->options->single) {
 143              return is_numeric($responses);
 144          } else {
 145              return !empty($responses);
 146          }
 147      }
 148  
 149      public function set_first_step_data_elements($state, &$data) {
 150          $this->explode_answer($state);
 151          $this->updater->qa->questionsummary = $this->to_text(
 152              $this->replace_expressions_in_text($this->question->questiontext)
 153          );
 154          $this->updater->qa->rightanswer = $this->right_answer($this->question);
 155  
 156          foreach ($this->values as $name => $value) {
 157              $data['_var_' . $name] = $value;
 158          }
 159  
 160          [$datasetbit, $answer] = explode('-', $state->answer, 2);
 161          [$order, $responses] = explode(':', $answer);
 162          $data['_order'] = $order;
 163          $this->order = explode(',', $order);
 164      }
 165  
 166      public function supply_missing_first_step_data(&$data) {
 167          $data['_order'] = implode(',', array_keys($this->question->options->answers));
 168      }
 169  
 170      public function set_data_elements_for_step($state, &$data) {
 171          $responses = $this->explode_answer($state);
 172          if ($this->question->options->single) {
 173              if (is_numeric($responses)) {
 174                  $flippedorder = array_combine(array_values($this->order), array_keys($this->order));
 175                  if (array_key_exists($responses, $flippedorder)) {
 176                      $data['answer'] = $flippedorder[$responses];
 177                  } else {
 178                      $data['answer'] = '-1';
 179                  }
 180              }
 181          } else {
 182              $responses = explode(',', $responses);
 183              foreach ($this->order as $key => $ansid) {
 184                  if (in_array($ansid, $responses)) {
 185                      $data['choice' . $key] = 1;
 186                  } else {
 187                      $data['choice' . $key] = 0;
 188                  }
 189              }
 190          }
 191      }
 192  
 193      public function load_dataset($selecteditem) {
 194          $this->selecteditem = $selecteditem;
 195          $this->updater->qa->variant = $selecteditem;
 196          $this->values = $this->qeupdater->load_dataset(
 197              $this->question->id,
 198              $selecteditem
 199          );
 200  
 201          // Prepare an array for {@link substitute_values()}.
 202          $this->search = [];
 203          $this->safevalue = [];
 204          $this->prettyvalue = [];
 205          foreach ($this->values as $name => $value) {
 206              if (!is_numeric($value)) {
 207                  $a = new stdClass();
 208                  $a->name = '{' . $name . '}';
 209                  $a->value = $value;
 210                  throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
 211              }
 212  
 213              $this->search[] = '{' . $name . '}';
 214              $this->safevalue[] = '(' . $value . ')';
 215              $this->prettyvalue[] = $this->format_float($value);
 216          }
 217      }
 218  
 219      /**
 220       * This function should be identical to
 221       * {@link qtype_calculated_variable_substituter::format_float()}. Except that
 222       * we do not try to do locale-aware replacement of the decimal point.
 223       *
 224       * Having to copy it here is a pain, but it is the standard rule about not
 225       * using library code (which may change in future) in upgrade code, which
 226       * exists at a point in time.
 227       *
 228       * Display a float properly formatted with a certain number of decimal places.
 229       * @param number $x the number to format
 230       * @param int $length restrict to this many decimal places or significant
 231       *      figures. If null, the number is not rounded.
 232       * @param int format 1 => decimalformat, 2 => significantfigures.
 233       * @return string formtted number.
 234       */
 235      public function format_float($x, $length = null, $format = null) {
 236          if (!is_null($length) && !is_null($format)) {
 237              if ($format == 1) {
 238                  // Decimal places.
 239                  $x = sprintf('%.' . $length . 'F', $x);
 240              } else if ($format == 2) {
 241                  // Significant figures.
 242                  $x = sprintf('%.' . $length . 'g', $x);
 243              }
 244          }
 245          return $x;
 246      }
 247  
 248      /**
 249       * Evaluate an expression using the variable values.
 250       * @param string $expression the expression. A PHP expression with placeholders
 251       *      like {a} for where the variables need to go.
 252       * @return float the computed result.
 253       */
 254      public function calculate($expression) {
 255          return $this->calculate_raw($this->substitute_values_for_eval($expression));
 256      }
 257  
 258      /**
 259       * Evaluate an expression after the variable values have been substituted.
 260       * @param string $expression the expression. A PHP expression with placeholders
 261       *      like {a} for where the variables need to go.
 262       * @return float the computed result.
 263       */
 264      protected function calculate_raw($expression) {
 265          try {
 266              // In older PHP versions this this is a way to validate code passed to eval.
 267              // The trick came from http://php.net/manual/en/function.eval.php.
 268              if (@eval('return true; $result = ' . $expression . ';')) {
 269                  return eval('return ' . $expression . ';');
 270              }
 271          } catch (Throwable $e) {
 272              // PHP7 and later now throws ParseException and friends from eval(),
 273              // which is much better.
 274          }
 275          // In either case of an invalid $expression, we end here.
 276          return '[Invalid expression ' . $expression . ']';
 277      }
 278  
 279      /**
 280       * Substitute variable placehodlers like {a} with their value wrapped in ().
 281       * @param string $expression the expression. A PHP expression with placeholders
 282       *      like {a} for where the variables need to go.
 283       * @return string the expression with each placeholder replaced by the
 284       *      corresponding value.
 285       */
 286      protected function substitute_values_for_eval($expression) {
 287          return str_replace($this->search, $this->safevalue, $expression ?? '');
 288      }
 289  
 290      /**
 291       * Substitute variable placehodlers like {a} with their value without wrapping
 292       * the value in anything.
 293       * @param string $text some content with placeholders
 294       *      like {a} for where the variables need to go.
 295       * @return string the expression with each placeholder replaced by the
 296       *      corresponding value.
 297       */
 298      protected function substitute_values_pretty($text) {
 299          return str_replace($this->search, $this->prettyvalue, $text ?? '');
 300      }
 301  
 302      /**
 303       * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
 304       * in some text with the corresponding values.
 305       * @param string $text the text to process.
 306       * @return string the text with values substituted.
 307       */
 308      public function replace_expressions_in_text($text, $length = null, $format = null) {
 309          if ($text === null || $text === '') {
 310              return $text;
 311          }
 312  
 313          $vs = $this; // Can't see to use $this in a PHP closure.
 314          $text = preg_replace_callback(
 315              qtype_calculated::FORMULAS_IN_TEXT_REGEX,
 316              function ($matches) use ($vs, $format, $length) {
 317                  return $vs->format_float($vs->calculate($matches[1]), $length, $format);
 318              },
 319              $text
 320          );
 321          return $this->substitute_values_pretty($text);
 322      }
 323  }