Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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