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] [Versions 401 and 402] [Versions 401 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   * Question type class for the calculated question type.
  19   *
  20   * @package    qtype
  21   * @subpackage calculated
  22   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  require_once($CFG->dirroot . '/question/type/questiontypebase.php');
  30  require_once($CFG->dirroot . '/question/type/questionbase.php');
  31  require_once($CFG->dirroot . '/question/type/numerical/question.php');
  32  
  33  
  34  /**
  35   * The calculated question type.
  36   *
  37   * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
  38   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class qtype_calculated extends question_type {
  41      /**
  42       * @const string a placeholder is a letter, followed by almost any characters. (This should probably be restricted more.)
  43       */
  44      const PLACEHOLDER_REGEX_PART = '[[:alpha:]][^>} <`{"\']*';
  45  
  46      /**
  47       * @const string REGEXP for a placeholder, wrapped in its {...} delimiters, with capturing brackets around the name.
  48       */
  49      const PLACEHODLER_REGEX = '~\{(' . self::PLACEHOLDER_REGEX_PART . ')\}~';
  50  
  51      /**
  52       * @const string Regular expression that finds the formulas in content, with capturing brackets to get the forumlas.
  53       */
  54      const FORMULAS_IN_TEXT_REGEX = '~\{=([^{}]*(?:\{' . self::PLACEHOLDER_REGEX_PART . '\}[^{}]*)*)\}~';
  55  
  56      const MAX_DATASET_ITEMS = 100;
  57  
  58      public $wizardpagesnumber = 3;
  59  
  60      public function get_question_options($question) {
  61          // First get the datasets and default options.
  62          // The code is used for calculated, calculatedsimple and calculatedmulti qtypes.
  63          global $CFG, $DB, $OUTPUT;
  64          parent::get_question_options($question);
  65          if (!$question->options = $DB->get_record('question_calculated_options',
  66                  array('question' => $question->id))) {
  67              $question->options = new stdClass();
  68              $question->options->synchronize = 0;
  69              $question->options->single = 0;
  70              $question->options->answernumbering = 'abc';
  71              $question->options->shuffleanswers = 0;
  72              $question->options->correctfeedback = '';
  73              $question->options->partiallycorrectfeedback = '';
  74              $question->options->incorrectfeedback = '';
  75              $question->options->correctfeedbackformat = 0;
  76              $question->options->partiallycorrectfeedbackformat = 0;
  77              $question->options->incorrectfeedbackformat = 0;
  78          }
  79  
  80          if (!$question->options->answers = $DB->get_records_sql("
  81              SELECT a.*, c.tolerance, c.tolerancetype, c.correctanswerlength, c.correctanswerformat
  82              FROM {question_answers} a,
  83                   {question_calculated} c
  84              WHERE a.question = ?
  85              AND   a.id = c.answer
  86              ORDER BY a.id ASC", array($question->id))) {
  87                  return false;
  88          }
  89  
  90          if ($this->get_virtual_qtype()->name() == 'numerical') {
  91              $this->get_virtual_qtype()->get_numerical_units($question);
  92              $this->get_virtual_qtype()->get_numerical_options($question);
  93          }
  94  
  95          $question->hints = $DB->get_records('question_hints',
  96                  array('questionid' => $question->id), 'id ASC');
  97  
  98          if (isset($question->export_process)&&$question->export_process) {
  99              $question->options->datasets = $this->get_datasets_for_export($question);
 100          }
 101          return true;
 102      }
 103  
 104      public function get_datasets_for_export($question) {
 105          global $DB, $CFG;
 106          $datasetdefs = array();
 107          if (!empty($question->id)) {
 108              $sql = "SELECT i.*
 109                        FROM {question_datasets} d, {question_dataset_definitions} i
 110                       WHERE d.question = ? AND d.datasetdefinition = i.id";
 111              if ($records = $DB->get_records_sql($sql, array($question->id))) {
 112                  foreach ($records as $r) {
 113                      $def = $r;
 114                      if ($def->category == '0') {
 115                          $def->status = 'private';
 116                      } else {
 117                          $def->status = 'shared';
 118                      }
 119                      $def->type = 'calculated';
 120                      list($distribution, $min, $max, $dec) = explode(':', $def->options, 4);
 121                      $def->distribution = $distribution;
 122                      $def->minimum = $min;
 123                      $def->maximum = $max;
 124                      $def->decimals = $dec;
 125                      if ($def->itemcount > 0) {
 126                          // Get the datasetitems.
 127                          $def->items = array();
 128                          if ($items = $this->get_database_dataset_items($def->id)) {
 129                              $n = 0;
 130                              foreach ($items as $ii) {
 131                                  $n++;
 132                                  $def->items[$n] = new stdClass();
 133                                  $def->items[$n]->itemnumber = $ii->itemnumber;
 134                                  $def->items[$n]->value = $ii->value;
 135                              }
 136                              $def->number_of_items = $n;
 137                          }
 138                      }
 139                      $datasetdefs["1-{$r->category}-{$r->name}"] = $def;
 140                  }
 141              }
 142          }
 143          return $datasetdefs;
 144      }
 145  
 146      public function save_question_options($question) {
 147          global $CFG, $DB;
 148  
 149          // Make it impossible to save bad formulas anywhere.
 150          $this->validate_question_data($question);
 151  
 152          // The code is used for calculated, calculatedsimple and calculatedmulti qtypes.
 153          $context = $question->context;
 154  
 155          // Calculated options.
 156          $update = true;
 157          $options = $DB->get_record('question_calculated_options',
 158                  array('question' => $question->id));
 159          if (!$options) {
 160              $update = false;
 161              $options = new stdClass();
 162              $options->question = $question->id;
 163          }
 164          // As used only by calculated.
 165          if (isset($question->synchronize)) {
 166              $options->synchronize = $question->synchronize;
 167          } else {
 168              $options->synchronize = 0;
 169          }
 170          $options->single = 0;
 171          $options->answernumbering =  $question->answernumbering;
 172          $options->shuffleanswers = $question->shuffleanswers;
 173  
 174          foreach (array('correctfeedback', 'partiallycorrectfeedback',
 175                  'incorrectfeedback') as $feedbackname) {
 176              $options->$feedbackname = '';
 177              $feedbackformat = $feedbackname . 'format';
 178              $options->$feedbackformat = 0;
 179          }
 180  
 181          if ($update) {
 182              $DB->update_record('question_calculated_options', $options);
 183          } else {
 184              $DB->insert_record('question_calculated_options', $options);
 185          }
 186  
 187          // Get old versions of the objects.
 188          $oldanswers = $DB->get_records('question_answers',
 189                  array('question' => $question->id), 'id ASC');
 190  
 191          $oldoptions = $DB->get_records('question_calculated',
 192                  array('question' => $question->id), 'answer ASC');
 193  
 194          // Save the units.
 195          $virtualqtype = $this->get_virtual_qtype();
 196  
 197          $result = $virtualqtype->save_units($question);
 198          if (isset($result->error)) {
 199              return $result;
 200          } else {
 201              $units = $result->units;
 202          }
 203  
 204          foreach ($question->answer as $key => $answerdata) {
 205              if (trim($answerdata) == '') {
 206                  continue;
 207              }
 208  
 209              // Update an existing answer if possible.
 210              $answer = array_shift($oldanswers);
 211              if (!$answer) {
 212                  $answer = new stdClass();
 213                  $answer->question = $question->id;
 214                  $answer->answer   = '';
 215                  $answer->feedback = '';
 216                  $answer->id       = $DB->insert_record('question_answers', $answer);
 217              }
 218  
 219              $answer->answer   = trim($answerdata);
 220              $answer->fraction = $question->fraction[$key];
 221              $answer->feedback = $this->import_or_save_files($question->feedback[$key],
 222                      $context, 'question', 'answerfeedback', $answer->id);
 223              $answer->feedbackformat = $question->feedback[$key]['format'];
 224  
 225              $DB->update_record("question_answers", $answer);
 226  
 227              // Set up the options object.
 228              if (!$options = array_shift($oldoptions)) {
 229                  $options = new stdClass();
 230              }
 231              $options->question            = $question->id;
 232              $options->answer              = $answer->id;
 233              $options->tolerance           = trim($question->tolerance[$key]);
 234              $options->tolerancetype       = trim($question->tolerancetype[$key]);
 235              $options->correctanswerlength = trim($question->correctanswerlength[$key]);
 236              $options->correctanswerformat = trim($question->correctanswerformat[$key]);
 237  
 238              // Save options.
 239              if (isset($options->id)) {
 240                  // Reusing existing record.
 241                  $DB->update_record('question_calculated', $options);
 242              } else {
 243                  // New options.
 244                  $DB->insert_record('question_calculated', $options);
 245              }
 246          }
 247  
 248          // Delete old answer records.
 249          if (!empty($oldanswers)) {
 250              foreach ($oldanswers as $oa) {
 251                  $DB->delete_records('question_answers', array('id' => $oa->id));
 252              }
 253          }
 254  
 255          // Delete old answer records.
 256          if (!empty($oldoptions)) {
 257              foreach ($oldoptions as $oo) {
 258                  $DB->delete_records('question_calculated', array('id' => $oo->id));
 259              }
 260          }
 261  
 262          $result = $virtualqtype->save_unit_options($question);
 263          if (isset($result->error)) {
 264              return $result;
 265          }
 266  
 267          $this->save_hints($question);
 268  
 269          if (isset($question->import_process)&&$question->import_process) {
 270              $this->import_datasets($question);
 271          }
 272          // Report any problems.
 273          if (!empty($result->notice)) {
 274              return $result;
 275          }
 276          return true;
 277      }
 278  
 279      public function import_datasets($question) {
 280          global $DB;
 281          $n = count($question->dataset);
 282          foreach ($question->dataset as $dataset) {
 283              // Name, type, option.
 284              $datasetdef = new stdClass();
 285              $datasetdef->name = $dataset->name;
 286              $datasetdef->type = 1;
 287              $datasetdef->options =  $dataset->distribution . ':' . $dataset->min . ':' .
 288                      $dataset->max . ':' . $dataset->length;
 289              $datasetdef->itemcount = $dataset->itemcount;
 290              if ($dataset->status == 'private') {
 291                  $datasetdef->category = 0;
 292                  $todo = 'create';
 293              } else if ($dataset->status == 'shared') {
 294                  if ($sharedatasetdefs = $DB->get_records_select(
 295                      'question_dataset_definitions',
 296                      "type = '1'
 297                      AND " . $DB->sql_equal('name', '?') . "
 298                      AND category = ?
 299                      ORDER BY id DESC ", array($dataset->name, $question->category)
 300                  )) { // So there is at least one.
 301                      $sharedatasetdef = array_shift($sharedatasetdefs);
 302                      if ($sharedatasetdef->options ==  $datasetdef->options) {// Identical so use it.
 303                          $todo = 'useit';
 304                          $datasetdef = $sharedatasetdef;
 305                      } else { // Different so create a private one.
 306                          $datasetdef->category = 0;
 307                          $todo = 'create';
 308                      }
 309                  } else { // No so create one.
 310                      $datasetdef->category = $question->category;
 311                      $todo = 'create';
 312                  }
 313              }
 314              if ($todo == 'create') {
 315                  $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);
 316              }
 317              // Create relation to the dataset.
 318              $questiondataset = new stdClass();
 319              $questiondataset->question = $question->id;
 320              $questiondataset->datasetdefinition = $datasetdef->id;
 321              $DB->insert_record('question_datasets', $questiondataset);
 322              if ($todo == 'create') {
 323                  // Add the items.
 324                  foreach ($dataset->datasetitem as $dataitem) {
 325                      $datasetitem = new stdClass();
 326                      $datasetitem->definition = $datasetdef->id;
 327                      $datasetitem->itemnumber = $dataitem->itemnumber;
 328                      $datasetitem->value = $dataitem->value;
 329                      $DB->insert_record('question_dataset_items', $datasetitem);
 330                  }
 331              }
 332          }
 333      }
 334  
 335      protected function initialise_question_instance(question_definition $question, $questiondata) {
 336          parent::initialise_question_instance($question, $questiondata);
 337  
 338          question_bank::get_qtype('numerical')->initialise_numerical_answers(
 339                  $question, $questiondata);
 340          foreach ($questiondata->options->answers as $a) {
 341              $question->answers[$a->id]->tolerancetype = $a->tolerancetype;
 342              $question->answers[$a->id]->correctanswerlength = $a->correctanswerlength;
 343              $question->answers[$a->id]->correctanswerformat = $a->correctanswerformat;
 344          }
 345  
 346          $question->synchronised = $questiondata->options->synchronize;
 347  
 348          $question->unitdisplay = $questiondata->options->showunits;
 349          $question->unitgradingtype = $questiondata->options->unitgradingtype;
 350          $question->unitpenalty = $questiondata->options->unitpenalty;
 351          $question->ap = question_bank::get_qtype(
 352                  'numerical')->make_answer_processor(
 353                  $questiondata->options->units, $questiondata->options->unitsleft);
 354  
 355          $question->datasetloader = new qtype_calculated_dataset_loader($questiondata->id);
 356      }
 357  
 358      public function finished_edit_wizard($form) {
 359          return isset($form->savechanges);
 360      }
 361      public function wizardpagesnumber() {
 362          return 3;
 363      }
 364      // This gets called by editquestion.php after the standard question is saved.
 365      public function print_next_wizard_page($question, $form, $course) {
 366          global $CFG, $SESSION, $COURSE;
 367  
 368          // Catch invalid navigation & reloads.
 369          if (empty($question->id) && empty($SESSION->calculated)) {
 370              redirect('edit.php?courseid='.$COURSE->id, 'The page you are loading has expired.', 3);
 371          }
 372  
 373          // See where we're coming from.
 374          switch($form->wizardpage) {
 375              case 'question':
 376                  require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions.php");
 377                  break;
 378              case 'datasetdefinitions':
 379              case 'datasetitems':
 380                  require("{$CFG->dirroot}/question/type/calculated/datasetitems.php");
 381                  break;
 382              default:
 383                  throw new \moodle_exception('invalidwizardpage', 'question');
 384                  break;
 385          }
 386      }
 387  
 388      // This gets called by question2.php after the standard question is saved.
 389      public function &next_wizard_form($submiturl, $question, $wizardnow) {
 390          global $CFG, $SESSION, $COURSE;
 391  
 392          // Catch invalid navigation & reloads.
 393          if (empty($question->id) && empty($SESSION->calculated)) {
 394              redirect('edit.php?courseid=' . $COURSE->id,
 395                      'The page you are loading has expired. Cannot get next wizard form.', 3);
 396          }
 397          if (empty($question->id)) {
 398              $question = $SESSION->calculated->questionform;
 399          }
 400  
 401          // See where we're coming from.
 402          switch($wizardnow) {
 403              case 'datasetdefinitions':
 404                  require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions_form.php");
 405                  $mform = new question_dataset_dependent_definitions_form(
 406                          "{$submiturl}?wizardnow=datasetdefinitions", $question);
 407                  break;
 408              case 'datasetitems':
 409                  require("{$CFG->dirroot}/question/type/calculated/datasetitems_form.php");
 410                  $regenerate = optional_param('forceregeneration', false, PARAM_BOOL);
 411                  $mform = new question_dataset_dependent_items_form(
 412                          "{$submiturl}?wizardnow=datasetitems", $question, $regenerate);
 413                  break;
 414              default:
 415                  throw new \moodle_exception('invalidwizardpage', 'question');
 416                  break;
 417          }
 418  
 419          return $mform;
 420      }
 421  
 422      /**
 423       * This method should be overriden if you want to include a special heading or some other
 424       * html on a question editing page besides the question editing form.
 425       *
 426       * @param question_edit_form $mform a child of question_edit_form
 427       * @param object $question
 428       * @param string $wizardnow is '' for first page.
 429       */
 430      public function display_question_editing_page($mform, $question, $wizardnow) {
 431          global $OUTPUT;
 432          switch ($wizardnow) {
 433              case '':
 434                  // On the first page, the default display is fine.
 435                  parent::display_question_editing_page($mform, $question, $wizardnow);
 436                  return;
 437  
 438              case 'datasetdefinitions':
 439                  echo $OUTPUT->heading_with_help(
 440                          get_string('choosedatasetproperties', 'qtype_calculated'),
 441                          'questiondatasets', 'qtype_calculated');
 442                  break;
 443  
 444              case 'datasetitems':
 445                  echo $OUTPUT->heading_with_help(get_string('editdatasets', 'qtype_calculated'),
 446                          'questiondatasets', 'qtype_calculated');
 447                  break;
 448          }
 449  
 450          $mform->display();
 451      }
 452  
 453      /**
 454       * Verify that the equations in part of the question are OK.
 455       * We throw an exception here because this should have already been validated
 456       * by the form. This is just a last line of defence to prevent a question
 457       * being stored in the database if it has bad formulas. This saves us from,
 458       * for example, malicious imports.
 459       * @param string $text containing equations.
 460       */
 461      protected function validate_text($text) {
 462          $error = qtype_calculated_find_formula_errors_in_text($text);
 463          if ($error) {
 464              throw new coding_exception($error);
 465          }
 466      }
 467  
 468      /**
 469       * Verify that an answer is OK.
 470       * We throw an exception here because this should have already been validated
 471       * by the form. This is just a last line of defence to prevent a question
 472       * being stored in the database if it has bad formulas. This saves us from,
 473       * for example, malicious imports.
 474       * @param string $text containing equations.
 475       */
 476      protected function validate_answer($answer) {
 477          $error = qtype_calculated_find_formula_errors($answer);
 478          if ($error) {
 479              throw new coding_exception($error);
 480          }
 481      }
 482  
 483      /**
 484       * Validate data before save.
 485       * @param stdClass $question data from the form / import file.
 486       */
 487      protected function validate_question_data($question) {
 488          $this->validate_text($question->questiontext); // Yes, really no ['text'].
 489  
 490          if (isset($question->generalfeedback['text'])) {
 491              $this->validate_text($question->generalfeedback['text']);
 492          } else if (isset($question->generalfeedback)) {
 493              $this->validate_text($question->generalfeedback); // Because question import is weird.
 494          }
 495  
 496          foreach ($question->answer as $key => $answer) {
 497              $this->validate_answer($answer);
 498              $this->validate_text($question->feedback[$key]['text']);
 499          }
 500      }
 501  
 502      /**
 503       * Remove prefix #{..}# if exists.
 504       * @param $name a question name,
 505       * @return string the cleaned up question name.
 506       */
 507      public function clean_technical_prefix_from_question_name($name) {
 508          return preg_replace('~#\{([^[:space:]]*)#~', '', $name);
 509      }
 510  
 511      /**
 512       * This method prepare the $datasets in a format similar to dadatesetdefinitions_form.php
 513       * so that they can be saved
 514       * using the function save_dataset_definitions($form)
 515       * when creating a new calculated question or
 516       * when editing an already existing calculated question
 517       * or by  function save_as_new_dataset_definitions($form, $initialid)
 518       * when saving as new an already existing calculated question.
 519       *
 520       * @param object $form
 521       * @param int $questionfromid default = '0'
 522       */
 523      public function preparedatasets($form, $questionfromid = '0') {
 524  
 525          // The dataset names present in the edit_question_form and edit_calculated_form
 526          // are retrieved.
 527          $possibledatasets = $this->find_dataset_names($form->questiontext);
 528          $mandatorydatasets = array();
 529          foreach ($form->answer as $key => $answer) {
 530              $mandatorydatasets += $this->find_dataset_names($answer);
 531          }
 532          // If there are identical datasetdefs already saved in the original question
 533          // either when editing a question or saving as new,
 534          // they are retrieved using $questionfromid.
 535          if ($questionfromid != '0') {
 536              $form->id = $questionfromid;
 537          }
 538          $datasets = array();
 539          $key = 0;
 540          // Always prepare the mandatorydatasets present in the answers.
 541          // The $options are not used here.
 542          foreach ($mandatorydatasets as $datasetname) {
 543              if (!isset($datasets[$datasetname])) {
 544                  list($options, $selected) =
 545                      $this->dataset_options($form, $datasetname);
 546                  $datasets[$datasetname] = '';
 547                  $form->dataset[$key] = $selected;
 548                  $key++;
 549              }
 550          }
 551          // Do not prepare possibledatasets when creating a question.
 552          // They will defined and stored with datasetdefinitions_form.php.
 553          // The $options are not used here.
 554          if ($questionfromid != '0') {
 555  
 556              foreach ($possibledatasets as $datasetname) {
 557                  if (!isset($datasets[$datasetname])) {
 558                      list($options, $selected) =
 559                          $this->dataset_options($form, $datasetname, false);
 560                      $datasets[$datasetname] = '';
 561                      $form->dataset[$key] = $selected;
 562                      $key++;
 563                  }
 564              }
 565          }
 566          return $datasets;
 567      }
 568      public function addnamecategory(&$question) {
 569          global $DB;
 570          $categorydatasetdefs = $DB->get_records_sql(
 571              "SELECT  a.*
 572                 FROM {question_datasets} b, {question_dataset_definitions} a
 573                WHERE a.id = b.datasetdefinition
 574                  AND a.type = '1'
 575                  AND a.category != 0
 576                  AND b.question = ?
 577             ORDER BY a.name ", array($question->id));
 578          $questionname = $this->clean_technical_prefix_from_question_name($question->name);
 579  
 580          if (!empty($categorydatasetdefs)) {
 581              // There is at least one with the same name.
 582              $questionname = '#' . $questionname;
 583              foreach ($categorydatasetdefs as $def) {
 584                  if (strlen($def->name) + strlen($questionname) < 250) {
 585                      $questionname = '{' . $def->name . '}' . $questionname;
 586                  }
 587              }
 588              $questionname = '#' . $questionname;
 589          }
 590          $DB->set_field('question', 'name', $questionname, array('id' => $question->id));
 591      }
 592  
 593      /**
 594       * this version save the available data at the different steps of the question editing process
 595       * without using global $SESSION as storage between steps
 596       * at the first step $wizardnow = 'question'
 597       *  when creating a new question
 598       *  when modifying a question
 599       *  when copying as a new question
 600       *  the general parameters and answers are saved using parent::save_question
 601       *  then the datasets are prepared and saved
 602       * at the second step $wizardnow = 'datasetdefinitions'
 603       *  the datadefs final type are defined as private, category or not a datadef
 604       * at the third step $wizardnow = 'datasetitems'
 605       *  the datadefs parameters and the data items are created or defined
 606       *
 607       * @param object question
 608       * @param object $form
 609       * @param int $course
 610       * @param PARAM_ALPHA $wizardnow should be added as we are coming from question2.php
 611       */
 612      public function save_question($question, $form) {
 613          global $DB;
 614  
 615          if ($this->wizardpagesnumber() == 1 || $question->qtype == 'calculatedsimple') {
 616              $question = parent::save_question($question, $form);
 617              return $question;
 618          }
 619  
 620          $wizardnow =  optional_param('wizardnow', '', PARAM_ALPHA);
 621          $id = optional_param('id', 0, PARAM_INT); // Question id.
 622          // In case 'question':
 623          // For a new question $form->id is empty
 624          // when saving as new question.
 625          // The $question->id = 0, $form is $data from question2.php
 626          // and $data->makecopy is defined as $data->id is the initial question id.
 627          // Edit case. If it is a new question we don't necessarily need to
 628          // return a valid question object.
 629  
 630          // See where we're coming from.
 631          switch($wizardnow) {
 632              case '' :
 633              case 'question': // Coming from the first page, creating the second.
 634                  if (empty($form->id)) { // or a new question $form->id is empty.
 635                      $question = parent::save_question($question, $form);
 636                      // Prepare the datasets using default $questionfromid.
 637                      $this->preparedatasets($form);
 638                      $form->id = $question->id;
 639                      $this->save_dataset_definitions($form);
 640                      if (isset($form->synchronize) && $form->synchronize == 2) {
 641                          $this->addnamecategory($question);
 642                      }
 643                  } else {
 644                      $questionfromid =  $form->id;
 645                      $question = parent::save_question($question, $form);
 646                      // Prepare the datasets.
 647                      $this->preparedatasets($form, $questionfromid);
 648                      $form->id = $question->id;
 649                      $this->save_as_new_dataset_definitions($form, $questionfromid);
 650                      if (isset($form->synchronize) && $form->synchronize == 2) {
 651                          $this->addnamecategory($question);
 652                      }
 653                  }
 654                  break;
 655              case 'datasetdefinitions':
 656                  // Calculated options.
 657                  // It cannot go here without having done the first page,
 658                  // so the question_calculated_options should exist.
 659                  // We only need to update the synchronize field.
 660                  if (isset($form->synchronize)) {
 661                      $optionssynchronize = $form->synchronize;
 662                  } else {
 663                      $optionssynchronize = 0;
 664                  }
 665                  $DB->set_field('question_calculated_options', 'synchronize', $optionssynchronize,
 666                          array('question' => $question->id));
 667                  if (isset($form->synchronize) && $form->synchronize == 2) {
 668                      $this->addnamecategory($question);
 669                  }
 670  
 671                  $this->save_dataset_definitions($form);
 672                  break;
 673              case 'datasetitems':
 674                  $this->save_dataset_items($question, $form);
 675                  $this->save_question_calculated($question, $form);
 676                  break;
 677              default:
 678                  throw new \moodle_exception('invalidwizardpage', 'question');
 679                  break;
 680          }
 681          return $question;
 682      }
 683  
 684      public function delete_question($questionid, $contextid) {
 685          global $DB;
 686  
 687          $DB->delete_records('question_calculated', array('question' => $questionid));
 688          $DB->delete_records('question_calculated_options', array('question' => $questionid));
 689          $DB->delete_records('question_numerical_units', array('question' => $questionid));
 690          if ($datasets = $DB->get_records('question_datasets', array('question' => $questionid))) {
 691              foreach ($datasets as $dataset) {
 692                  if (!$DB->get_records_select('question_datasets',
 693                          "question != ? AND datasetdefinition = ? ",
 694                          array($questionid, $dataset->datasetdefinition))) {
 695                      $DB->delete_records('question_dataset_definitions',
 696                              array('id' => $dataset->datasetdefinition));
 697                      $DB->delete_records('question_dataset_items',
 698                              array('definition' => $dataset->datasetdefinition));
 699                  }
 700              }
 701          }
 702          $DB->delete_records('question_datasets', array('question' => $questionid));
 703  
 704          parent::delete_question($questionid, $contextid);
 705      }
 706  
 707      public function get_random_guess_score($questiondata) {
 708          foreach ($questiondata->options->answers as $aid => $answer) {
 709              if ('*' == trim($answer->answer)) {
 710                  return max($answer->fraction - $questiondata->options->unitpenalty, 0);
 711              }
 712          }
 713          return 0;
 714      }
 715  
 716      public function supports_dataset_item_generation() {
 717          // Calculated support generation of randomly distributed number data.
 718          return true;
 719      }
 720  
 721      public function custom_generator_tools_part($mform, $idx, $j) {
 722  
 723          $minmaxgrp = array();
 724          $minmaxgrp[] = $mform->createElement('float', "calcmin[{$idx}]",
 725                  get_string('calcmin', 'qtype_calculated'));
 726          $minmaxgrp[] = $mform->createElement('float', "calcmax[{$idx}]",
 727                  get_string('calcmax', 'qtype_calculated'));
 728          $mform->addGroup($minmaxgrp, 'minmaxgrp',
 729                  get_string('minmax', 'qtype_calculated'), ' - ', false);
 730  
 731          $precisionoptions = range(0, 10);
 732          $mform->addElement('select', "calclength[{$idx}]",
 733                  get_string('calclength', 'qtype_calculated'), $precisionoptions);
 734  
 735          $distriboptions = array('uniform' => get_string('uniform', 'qtype_calculated'),
 736                  'loguniform' => get_string('loguniform', 'qtype_calculated'));
 737          $mform->addElement('select', "calcdistribution[{$idx}]",
 738                  get_string('calcdistribution', 'qtype_calculated'), $distriboptions);
 739      }
 740  
 741      public function custom_generator_set_data($datasetdefs, $formdata) {
 742          $idx = 1;
 743          foreach ($datasetdefs as $datasetdef) {
 744              if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~',
 745                      $datasetdef->options, $regs)) {
 746                  $formdata["calcdistribution[{$idx}]"] = $regs[1];
 747                  $formdata["calcmin[{$idx}]"] = $regs[2];
 748                  $formdata["calcmax[{$idx}]"] = $regs[3];
 749                  $formdata["calclength[{$idx}]"] = $regs[4];
 750              }
 751              $idx++;
 752          }
 753          return $formdata;
 754      }
 755  
 756      public function custom_generator_tools($datasetdef) {
 757          global $OUTPUT;
 758          if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~',
 759                  $datasetdef->options, $regs)) {
 760              $defid = "{$datasetdef->type}-{$datasetdef->category}-{$datasetdef->name}";
 761              for ($i = 0; $i<10; ++$i) {
 762                  $lengthoptions[$i] = get_string(($regs[1] == 'uniform'
 763                      ? 'decimals'
 764                      : 'significantfigures'), 'qtype_calculated', $i);
 765              }
 766              $menu1 = html_writer::label(get_string('lengthoption', 'qtype_calculated'),
 767                  'menucalclength', false, array('class' => 'accesshide'));
 768              $menu1 .= html_writer::select($lengthoptions, 'calclength[]', $regs[4], null, array('class' => 'custom-select'));
 769  
 770              $options = array('uniform' => get_string('uniformbit', 'qtype_calculated'),
 771                  'loguniform' => get_string('loguniformbit', 'qtype_calculated'));
 772              $menu2 = html_writer::label(get_string('distributionoption', 'qtype_calculated'),
 773                  'menucalcdistribution', false, array('class' => 'accesshide'));
 774              $menu2 .= html_writer::select($options, 'calcdistribution[]', $regs[1], null, array('class' => 'custom-select'));
 775              return '<input type="submit" class="btn btn-secondary" onclick="'
 776                  . "getElementById('addform').regenerateddefid.value='{$defid}'; return true;"
 777                  .'" value="'. get_string('generatevalue', 'qtype_calculated') . '"/><br/>'
 778                  . '<input type="text" class="form-control" size="3" name="calcmin[]" '
 779                  . " value=\"{$regs[2]}\"/> &amp; <input name=\"calcmax[]\" "
 780                  . ' type="text" class="form-control" size="3" value="' . $regs[3] .'"/> '
 781                  . $menu1 . '<br/>'
 782                  . $menu2;
 783          } else {
 784              return '';
 785          }
 786      }
 787  
 788  
 789      public function update_dataset_options($datasetdefs, $form) {
 790          global $OUTPUT;
 791          // Do we have information about new options ?
 792          if (empty($form->definition) || empty($form->calcmin)
 793                  ||empty($form->calcmax) || empty($form->calclength)
 794                  || empty($form->calcdistribution)) {
 795              // I guess not.
 796  
 797          } else {
 798              // Looks like we just could have some new information here.
 799              $uniquedefs = array_values(array_unique($form->definition));
 800              foreach ($uniquedefs as $key => $defid) {
 801                  if (isset($datasetdefs[$defid])
 802                          && is_numeric($form->calcmin[$key+1])
 803                          && is_numeric($form->calcmax[$key+1])
 804                          && is_numeric($form->calclength[$key+1])) {
 805                      switch     ($form->calcdistribution[$key+1]) {
 806                          case 'uniform': case 'loguniform':
 807                              $datasetdefs[$defid]->options =
 808                                  $form->calcdistribution[$key+1] . ':'
 809                                  . $form->calcmin[$key+1] . ':'
 810                                  . $form->calcmax[$key+1] . ':'
 811                                  . $form->calclength[$key+1];
 812                              break;
 813                          default:
 814                              echo $OUTPUT->notification(
 815                                      "Unexpected distribution ".$form->calcdistribution[$key+1]);
 816                      }
 817                  }
 818              }
 819          }
 820  
 821          // Look for empty options, on which we set default values.
 822          foreach ($datasetdefs as $defid => $def) {
 823              if (empty($def->options)) {
 824                  $datasetdefs[$defid]->options = 'uniform:1.0:10.0:1';
 825              }
 826          }
 827          return $datasetdefs;
 828      }
 829  
 830      public function save_question_calculated($question, $fromform) {
 831          global $DB;
 832  
 833          foreach ($question->options->answers as $key => $answer) {
 834              if ($options = $DB->get_record('question_calculated', array('answer' => $key))) {
 835                  $options->tolerance = trim($fromform->tolerance[$key]);
 836                  $options->tolerancetype  = trim($fromform->tolerancetype[$key]);
 837                  $options->correctanswerlength  = trim($fromform->correctanswerlength[$key]);
 838                  $options->correctanswerformat  = trim($fromform->correctanswerformat[$key]);
 839                  $DB->update_record('question_calculated', $options);
 840              }
 841          }
 842      }
 843  
 844      /**
 845       * This function get the dataset items using id as unique parameter and return an
 846       * array with itemnumber as index sorted ascendant
 847       * If the multiple records with the same itemnumber exist, only the newest one
 848       * i.e with the greatest id is used, the others are ignored but not deleted.
 849       * MDL-19210
 850       */
 851      public function get_database_dataset_items($definition) {
 852          global $CFG, $DB;
 853          $databasedataitems = $DB->get_records_sql(// Use number as key!!
 854              " SELECT id , itemnumber, definition,  value
 855              FROM {question_dataset_items}
 856              WHERE definition = $definition order by id DESC ", array($definition));
 857          $dataitems = Array();
 858          foreach ($databasedataitems as $id => $dataitem) {
 859              if (!isset($dataitems[$dataitem->itemnumber])) {
 860                  $dataitems[$dataitem->itemnumber] = $dataitem;
 861              }
 862          }
 863          ksort($dataitems);
 864          return $dataitems;
 865      }
 866  
 867      public function save_dataset_items($question, $fromform) {
 868          global $CFG, $DB;
 869          $synchronize = false;
 870          if (isset($fromform->nextpageparam['forceregeneration'])) {
 871              $regenerate = $fromform->nextpageparam['forceregeneration'];
 872          } else {
 873              $regenerate = 0;
 874          }
 875          if (empty($question->options)) {
 876              $this->get_question_options($question);
 877          }
 878          if (!empty($question->options->synchronize)) {
 879              $synchronize = true;
 880          }
 881  
 882          // Get the old datasets for this question.
 883          $datasetdefs = $this->get_dataset_definitions($question->id, array());
 884          // Handle generator options...
 885          $olddatasetdefs = fullclone($datasetdefs);
 886          $datasetdefs = $this->update_dataset_options($datasetdefs, $fromform);
 887          $maxnumber = -1;
 888          foreach ($datasetdefs as $defid => $datasetdef) {
 889              if (isset($datasetdef->id)
 890                      && $datasetdef->options != $olddatasetdefs[$defid]->options) {
 891                  // Save the new value for options.
 892                  $DB->update_record('question_dataset_definitions', $datasetdef);
 893  
 894              }
 895              // Get maxnumber.
 896              if ($maxnumber == -1 || $datasetdef->itemcount < $maxnumber) {
 897                  $maxnumber = $datasetdef->itemcount;
 898              }
 899          }
 900          // Handle adding and removing of dataset items.
 901          $i = 1;
 902          if ($maxnumber > self::MAX_DATASET_ITEMS) {
 903              $maxnumber = self::MAX_DATASET_ITEMS;
 904          }
 905  
 906          ksort($fromform->definition);
 907          foreach ($fromform->definition as $key => $defid) {
 908              // If the delete button has not been pressed then skip the datasetitems
 909              // in the 'add item' part of the form.
 910              if ($i > count($datasetdefs)*$maxnumber) {
 911                  break;
 912              }
 913              $addeditem = new stdClass();
 914              $addeditem->definition = $datasetdefs[$defid]->id;
 915              $addeditem->value = $fromform->number[$i];
 916              $addeditem->itemnumber = ceil($i / count($datasetdefs));
 917  
 918              if ($fromform->itemid[$i]) {
 919                  // Reuse any previously used record.
 920                  $addeditem->id = $fromform->itemid[$i];
 921                  $DB->update_record('question_dataset_items', $addeditem);
 922              } else {
 923                  $DB->insert_record('question_dataset_items', $addeditem);
 924              }
 925  
 926              $i++;
 927          }
 928          if (isset($addeditem->itemnumber) && $maxnumber < $addeditem->itemnumber
 929                  && $addeditem->itemnumber < self::MAX_DATASET_ITEMS) {
 930              $maxnumber = $addeditem->itemnumber;
 931              foreach ($datasetdefs as $key => $newdef) {
 932                  if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) {
 933                      $newdef->itemcount = $maxnumber;
 934                      // Save the new value for options.
 935                      $DB->update_record('question_dataset_definitions', $newdef);
 936                  }
 937              }
 938          }
 939          // Adding supplementary items.
 940          $numbertoadd = 0;
 941          if (isset($fromform->addbutton) && $fromform->selectadd > 0 &&
 942                  $maxnumber < self::MAX_DATASET_ITEMS) {
 943              $numbertoadd = $fromform->selectadd;
 944              if (self::MAX_DATASET_ITEMS - $maxnumber < $numbertoadd) {
 945                  $numbertoadd = self::MAX_DATASET_ITEMS - $maxnumber;
 946              }
 947              // Add the other items.
 948              // Generate a new dataset item (or reuse an old one).
 949              foreach ($datasetdefs as $defid => $datasetdef) {
 950                  // In case that for category datasets some new items has been added,
 951                  // get actual values.
 952                  // Fix regenerate for this datadefs.
 953                  $defregenerate = 0;
 954                  if ($synchronize &&
 955                          !empty ($fromform->nextpageparam["datasetregenerate[{$datasetdef->name}"])) {
 956                      $defregenerate = 1;
 957                  } else if (!$synchronize &&
 958                          (($regenerate == 1 && $datasetdef->category == 0) ||$regenerate == 2)) {
 959                      $defregenerate = 1;
 960                  }
 961                  if (isset($datasetdef->id)) {
 962                      $datasetdefs[$defid]->items =
 963                              $this->get_database_dataset_items($datasetdef->id);
 964                  }
 965                  for ($numberadded = $maxnumber+1; $numberadded <= $maxnumber + $numbertoadd; $numberadded++) {
 966                      if (isset($datasetdefs[$defid]->items[$numberadded])) {
 967                          // In case of regenerate it modifies the already existing record.
 968                          if ($defregenerate) {
 969                              $datasetitem = new stdClass();
 970                              $datasetitem->id = $datasetdefs[$defid]->items[$numberadded]->id;
 971                              $datasetitem->definition = $datasetdef->id;
 972                              $datasetitem->itemnumber = $numberadded;
 973                              $datasetitem->value =
 974                                      $this->generate_dataset_item($datasetdef->options);
 975                              $DB->update_record('question_dataset_items', $datasetitem);
 976                          }
 977                          // If not regenerate do nothing as there is already a record.
 978                      } else {
 979                          $datasetitem = new stdClass();
 980                          $datasetitem->definition = $datasetdef->id;
 981                          $datasetitem->itemnumber = $numberadded;
 982                          if ($this->supports_dataset_item_generation()) {
 983                              $datasetitem->value =
 984                                      $this->generate_dataset_item($datasetdef->options);
 985                          } else {
 986                              $datasetitem->value = '';
 987                          }
 988                          $DB->insert_record('question_dataset_items', $datasetitem);
 989                      }
 990                  }// For number added.
 991              }// Datasetsdefs end.
 992              $maxnumber += $numbertoadd;
 993              foreach ($datasetdefs as $key => $newdef) {
 994                  if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) {
 995                      $newdef->itemcount = $maxnumber;
 996                      // Save the new value for options.
 997                      $DB->update_record('question_dataset_definitions', $newdef);
 998                  }
 999              }
1000          }
1001  
1002          if (isset($fromform->deletebutton)) {
1003              if (isset($fromform->selectdelete)) {
1004                  $newmaxnumber = $maxnumber-$fromform->selectdelete;
1005              } else {
1006                  $newmaxnumber = $maxnumber-1;
1007              }
1008              if ($newmaxnumber < 0) {
1009                  $newmaxnumber = 0;
1010              }
1011              foreach ($datasetdefs as $datasetdef) {
1012                  if ($datasetdef->itemcount == $maxnumber) {
1013                      $datasetdef->itemcount= $newmaxnumber;
1014                      $DB->update_record('question_dataset_definitions', $datasetdef);
1015                  }
1016              }
1017          }
1018      }
1019      public function generate_dataset_item($options) {
1020          if (!preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~',
1021                  $options, $regs)) {
1022              // Unknown options...
1023              return false;
1024          }
1025          if ($regs[1] == 'uniform') {
1026              $nbr = $regs[2] + ($regs[3]-$regs[2])*mt_rand()/mt_getrandmax();
1027              return sprintf("%.".$regs[4].'f', $nbr);
1028  
1029          } else if ($regs[1] == 'loguniform') {
1030              $log0 = log(abs($regs[2])); // It would have worked the other way to.
1031              $nbr = exp($log0 + (log(abs($regs[3])) - $log0)*mt_rand()/mt_getrandmax());
1032              return sprintf("%.".$regs[4].'f', $nbr);
1033  
1034          } else {
1035              throw new \moodle_exception('disterror', 'question', '', $regs[1]);
1036          }
1037          return '';
1038      }
1039  
1040      public function comment_header($question) {
1041          $strheader = '';
1042          $delimiter = '';
1043  
1044          $answers = $question->options->answers;
1045  
1046          foreach ($answers as $key => $answer) {
1047              $ans = shorten_text($answer->answer, 17, true);
1048              $strheader .= $delimiter.$ans;
1049              $delimiter = '<br/><br/><br/>';
1050          }
1051          return $strheader;
1052      }
1053  
1054      public function comment_on_datasetitems($qtypeobj, $questionid, $questiontext,
1055              $answers, $data, $number) {
1056          global $DB;
1057          $comment = new stdClass();
1058          $comment->stranswers = array();
1059          $comment->outsidelimit = false;
1060          $comment->answers = array();
1061          // Find a default unit.
1062          $unit = '';
1063          if (!empty($questionid)) {
1064              $units = $DB->get_records('question_numerical_units',
1065                  array('question' => $questionid, 'multiplier' => 1.0),
1066                  'id ASC', '*', 0, 1);
1067              if ($units) {
1068                  $unit = reset($units);
1069                  $unit = $unit->unit;
1070              }
1071          }
1072  
1073          $answers = fullclone($answers);
1074          $delimiter = ': ';
1075          $virtualqtype =  $qtypeobj->get_virtual_qtype();
1076          foreach ($answers as $key => $answer) {
1077              $error = qtype_calculated_find_formula_errors($answer->answer);
1078              if ($error) {
1079                  $comment->stranswers[$key] = $error;
1080                  continue;
1081              }
1082              $formula = $this->substitute_variables($answer->answer, $data);
1083              $formattedanswer = qtype_calculated_calculate_answer(
1084                  $answer->answer, $data, $answer->tolerance,
1085                  $answer->tolerancetype, $answer->correctanswerlength,
1086                  $answer->correctanswerformat, $unit);
1087              if ($formula === '*') {
1088                  $answer->min = ' ';
1089                  $formattedanswer->answer = $answer->answer;
1090              } else {
1091                  eval('$ansvalue = '.$formula.';');
1092                  $ans = new qtype_numerical_answer(0, $ansvalue, 0, '', 0, $answer->tolerance);
1093                  $ans->tolerancetype = $answer->tolerancetype;
1094                  list($answer->min, $answer->max) = $ans->get_tolerance_interval($answer);
1095              }
1096              if ($answer->min === '') {
1097                  // This should mean that something is wrong.
1098                  $comment->stranswers[$key] = " {$formattedanswer->answer}".'<br/><br/>';
1099              } else if ($formula === '*') {
1100                  $comment->stranswers[$key] = $formula . ' = ' .
1101                          get_string('anyvalue', 'qtype_calculated') . '<br/><br/><br/>';
1102              } else {
1103                  $formula = shorten_text($formula, 57, true);
1104                  $comment->stranswers[$key] = $formula . ' = ' . $formattedanswer->answer . '<br/>';
1105                  $correcttrue = new stdClass();
1106                  $correcttrue->correct = $formattedanswer->answer;
1107                  $correcttrue->true = '';
1108                  if ($formattedanswer->answer < $answer->min ||
1109                          $formattedanswer->answer > $answer->max) {
1110                      $comment->outsidelimit = true;
1111                      $comment->answers[$key] = $key;
1112                      $comment->stranswers[$key] .=
1113                              get_string('trueansweroutsidelimits', 'qtype_calculated', $correcttrue);
1114                  } else {
1115                      $comment->stranswers[$key] .=
1116                              get_string('trueanswerinsidelimits', 'qtype_calculated', $correcttrue);
1117                  }
1118                  $comment->stranswers[$key] .= '<br/>';
1119                  $comment->stranswers[$key] .= get_string('min', 'qtype_calculated') .
1120                          $delimiter . $answer->min . ' --- ';
1121                  $comment->stranswers[$key] .= get_string('max', 'qtype_calculated') .
1122                          $delimiter . $answer->max;
1123              }
1124          }
1125          return fullclone($comment);
1126      }
1127  
1128      public function tolerance_types() {
1129          return array(
1130              '1' => get_string('relative', 'qtype_numerical'),
1131              '2' => get_string('nominal', 'qtype_numerical'),
1132              '3' => get_string('geometric', 'qtype_numerical')
1133          );
1134      }
1135  
1136      public function dataset_options($form, $name, $mandatory = true,
1137              $renameabledatasets = false) {
1138          // Takes datasets from the parent implementation but
1139          // filters options that are currently not accepted by calculated.
1140          // It also determines a default selection.
1141          // Param $renameabledatasets not implemented anywhere.
1142  
1143          list($options, $selected) = $this->dataset_options_from_database(
1144                  $form, $name, '', 'qtype_calculated');
1145  
1146          foreach ($options as $key => $whatever) {
1147              if (!preg_match('~^1-~', $key) && $key != '0') {
1148                  unset($options[$key]);
1149              }
1150          }
1151          if (!$selected) {
1152              if ($mandatory) {
1153                  $selected =  "1-0-{$name}"; // Default.
1154              } else {
1155                  $selected = '0'; // Default.
1156              }
1157          }
1158          return array($options, $selected);
1159      }
1160  
1161      public function construct_dataset_menus($form, $mandatorydatasets,
1162              $optionaldatasets) {
1163          global $OUTPUT;
1164          $datasetmenus = array();
1165          foreach ($mandatorydatasets as $datasetname) {
1166              if (!isset($datasetmenus[$datasetname])) {
1167                  list($options, $selected) =
1168                      $this->dataset_options($form, $datasetname);
1169                  unset($options['0']); // Mandatory...
1170                  $datasetmenus[$datasetname] = html_writer::select(
1171                          $options, 'dataset[]', $selected, null);
1172              }
1173          }
1174          foreach ($optionaldatasets as $datasetname) {
1175              if (!isset($datasetmenus[$datasetname])) {
1176                  list($options, $selected) =
1177                      $this->dataset_options($form, $datasetname);
1178                  $datasetmenus[$datasetname] = html_writer::select(
1179                          $options, 'dataset[]', $selected, null);
1180              }
1181          }
1182          return $datasetmenus;
1183      }
1184  
1185      public function substitute_variables($str, $dataset) {
1186          global $OUTPUT;
1187          // Testing for wrong numerical values.
1188          // All calculations used this function so testing here should be OK.
1189  
1190          foreach ($dataset as $name => $value) {
1191              $val = $value;
1192              if (! is_numeric($val)) {
1193                  $a = new stdClass();
1194                  $a->name = '{'.$name.'}';
1195                  $a->value = $value;
1196                  echo $OUTPUT->notification(get_string('notvalidnumber', 'qtype_calculated', $a));
1197                  $val = 1.0;
1198              }
1199              if ($val <= 0) { // MDL-36025 Use parentheses for "-0" .
1200                  $str = str_replace('{'.$name.'}', '('.$val.')', $str);
1201              } else {
1202                  $str = str_replace('{'.$name.'}', $val, $str);
1203              }
1204          }
1205          return $str;
1206      }
1207  
1208      public function evaluate_equations($str, $dataset) {
1209          $formula = $this->substitute_variables($str, $dataset);
1210          if ($error = qtype_calculated_find_formula_errors($formula)) {
1211              return $error;
1212          }
1213          return $str;
1214      }
1215  
1216      public function substitute_variables_and_eval($str, $dataset) {
1217          $formula = $this->substitute_variables($str, $dataset);
1218          if ($error = qtype_calculated_find_formula_errors($formula)) {
1219              return $error;
1220          }
1221          // Calculate the correct answer.
1222          if (empty($formula)) {
1223              $str = '';
1224          } else if ($formula === '*') {
1225              $str = '*';
1226          } else {
1227              $str = null;
1228              eval('$str = '.$formula.';');
1229          }
1230          return $str;
1231      }
1232  
1233      public function get_dataset_definitions($questionid, $newdatasets) {
1234          global $DB;
1235          // Get the existing datasets for this question.
1236          $datasetdefs = array();
1237          if (!empty($questionid)) {
1238              global $CFG;
1239              $sql = "SELECT i.*
1240                        FROM {question_datasets} d, {question_dataset_definitions} i
1241                       WHERE d.question = ? AND d.datasetdefinition = i.id
1242                    ORDER BY i.id";
1243              if ($records = $DB->get_records_sql($sql, array($questionid))) {
1244                  foreach ($records as $r) {
1245                      $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r;
1246                  }
1247              }
1248          }
1249  
1250          foreach ($newdatasets as $dataset) {
1251              if (!$dataset) {
1252                  continue; // The no dataset case...
1253              }
1254  
1255              if (!isset($datasetdefs[$dataset])) {
1256                  // Make new datasetdef.
1257                  list($type, $category, $name) = explode('-', $dataset, 3);
1258                  $datasetdef = new stdClass();
1259                  $datasetdef->type = $type;
1260                  $datasetdef->name = $name;
1261                  $datasetdef->category  = $category;
1262                  $datasetdef->itemcount = 0;
1263                  $datasetdef->options   = 'uniform:1.0:10.0:1';
1264                  $datasetdefs[$dataset] = clone($datasetdef);
1265              }
1266          }
1267          return $datasetdefs;
1268      }
1269  
1270      public function save_dataset_definitions($form) {
1271          global $DB;
1272          // Save synchronize.
1273  
1274          if (empty($form->dataset)) {
1275              $form->dataset = array();
1276          }
1277          // Save datasets.
1278          $datasetdefinitions = $this->get_dataset_definitions($form->id, $form->dataset);
1279          $tmpdatasets = array_flip($form->dataset);
1280          $defids = array_keys($datasetdefinitions);
1281          foreach ($defids as $defid) {
1282              $datasetdef = &$datasetdefinitions[$defid];
1283              if (isset($datasetdef->id)) {
1284                  if (!isset($tmpdatasets[$defid])) {
1285                      // This dataset is not used any more, delete it.
1286                      $DB->delete_records('question_datasets',
1287                              array('question' => $form->id, 'datasetdefinition' => $datasetdef->id));
1288                      if ($datasetdef->category == 0) {
1289                          // Question local dataset.
1290                          $DB->delete_records('question_dataset_definitions',
1291                                  array('id' => $datasetdef->id));
1292                          $DB->delete_records('question_dataset_items',
1293                                  array('definition' => $datasetdef->id));
1294                      }
1295                  }
1296                  // This has already been saved or just got deleted.
1297                  unset($datasetdefinitions[$defid]);
1298                  continue;
1299              }
1300  
1301              $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);
1302  
1303              if (0 != $datasetdef->category) {
1304                  // We need to look for already existing datasets in the category.
1305                  // First creating the datasetdefinition above
1306                  // then we can manage to automatically take care of some possible realtime concurrence.
1307  
1308                  if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions',
1309                          'type = ? AND name = ? AND category = ? AND id < ?
1310                          ORDER BY id DESC',
1311                          array($datasetdef->type, $datasetdef->name,
1312                                  $datasetdef->category, $datasetdef->id))) {
1313  
1314                      while ($olderdatasetdef = array_shift($olderdatasetdefs)) {
1315                          $DB->delete_records('question_dataset_definitions',
1316                                  array('id' => $datasetdef->id));
1317                          $datasetdef = $olderdatasetdef;
1318                      }
1319                  }
1320              }
1321  
1322              // Create relation to this dataset.
1323              $questiondataset = new stdClass();
1324              $questiondataset->question = $form->id;
1325              $questiondataset->datasetdefinition = $datasetdef->id;
1326              $DB->insert_record('question_datasets', $questiondataset);
1327              unset($datasetdefinitions[$defid]);
1328          }
1329  
1330          // Remove local obsolete datasets as well as relations
1331          // to datasets in other categories.
1332          if (!empty($datasetdefinitions)) {
1333              foreach ($datasetdefinitions as $def) {
1334                  $DB->delete_records('question_datasets',
1335                          array('question' => $form->id, 'datasetdefinition' => $def->id));
1336  
1337                  if ($def->category == 0) { // Question local dataset.
1338                      $DB->delete_records('question_dataset_definitions',
1339                              array('id' => $def->id));
1340                      $DB->delete_records('question_dataset_items',
1341                              array('definition' => $def->id));
1342                  }
1343              }
1344          }
1345      }
1346      /** This function create a copy of the datasets (definition and dataitems)
1347       * from the preceding question if they remain in the new question
1348       * otherwise its create the datasets that have been added as in the
1349       * save_dataset_definitions()
1350       */
1351      public function save_as_new_dataset_definitions($form, $initialid) {
1352          global $CFG, $DB;
1353          // Get the datasets from the intial question.
1354          $datasetdefinitions = $this->get_dataset_definitions($initialid, $form->dataset);
1355          // Param $tmpdatasets contains those of the new question.
1356          $tmpdatasets = array_flip($form->dataset);
1357          $defids = array_keys($datasetdefinitions);// New datasets.
1358          foreach ($defids as $defid) {
1359              $datasetdef = &$datasetdefinitions[$defid];
1360              if (isset($datasetdef->id)) {
1361                  // This dataset exist in the initial question.
1362                  if (!isset($tmpdatasets[$defid])) {
1363                      // Do not exist in the new question so ignore.
1364                      unset($datasetdefinitions[$defid]);
1365                      continue;
1366                  }
1367                  // Create a copy but not for category one.
1368                  if (0 == $datasetdef->category) {
1369                      $olddatasetid = $datasetdef->id;
1370                      $olditemcount = $datasetdef->itemcount;
1371                      $datasetdef->itemcount = 0;
1372                      $datasetdef->id = $DB->insert_record('question_dataset_definitions',
1373                              $datasetdef);
1374                      // Copy the dataitems.
1375                      $olditems = $this->get_database_dataset_items($olddatasetid);
1376                      if (count($olditems) > 0) {
1377                          $itemcount = 0;
1378                          foreach ($olditems as $item) {
1379                              $item->definition = $datasetdef->id;
1380                              $DB->insert_record('question_dataset_items', $item);
1381                              $itemcount++;
1382                          }
1383                          // Update item count to olditemcount if
1384                          // at least this number of items has been recover from the database.
1385                          if ($olditemcount <= $itemcount) {
1386                              $datasetdef->itemcount = $olditemcount;
1387                          } else {
1388                              $datasetdef->itemcount = $itemcount;
1389                          }
1390                          $DB->update_record('question_dataset_definitions', $datasetdef);
1391                      } // End of  copy the dataitems.
1392                  }// End of  copy the datasetdef.
1393                  // Create relation to the new question with this
1394                  // copy as new datasetdef from the initial question.
1395                  $questiondataset = new stdClass();
1396                  $questiondataset->question = $form->id;
1397                  $questiondataset->datasetdefinition = $datasetdef->id;
1398                  $DB->insert_record('question_datasets', $questiondataset);
1399                  unset($datasetdefinitions[$defid]);
1400                  continue;
1401              }// End of datasetdefs from the initial question.
1402              // Really new one code similar to save_dataset_definitions().
1403              $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);
1404  
1405              if (0 != $datasetdef->category) {
1406                  // We need to look for already existing
1407                  // datasets in the category.
1408                  // By first creating the datasetdefinition above we
1409                  // can manage to automatically take care of
1410                  // some possible realtime concurrence.
1411                  if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions',
1412                          "type = ? AND " . $DB->sql_equal('name', '?') . " AND category = ? AND id < ?
1413                          ORDER BY id DESC",
1414                          array($datasetdef->type, $datasetdef->name,
1415                                  $datasetdef->category, $datasetdef->id))) {
1416  
1417                      while ($olderdatasetdef = array_shift($olderdatasetdefs)) {
1418                          $DB->delete_records('question_dataset_definitions',
1419                                  array('id' => $datasetdef->id));
1420                          $datasetdef = $olderdatasetdef;
1421                      }
1422                  }
1423              }
1424  
1425              // Create relation to this dataset.
1426              $questiondataset = new stdClass();
1427              $questiondataset->question = $form->id;
1428              $questiondataset->datasetdefinition = $datasetdef->id;
1429              $DB->insert_record('question_datasets', $questiondataset);
1430              unset($datasetdefinitions[$defid]);
1431          }
1432  
1433          // Remove local obsolete datasets as well as relations
1434          // to datasets in other categories.
1435          if (!empty($datasetdefinitions)) {
1436              foreach ($datasetdefinitions as $def) {
1437                  $DB->delete_records('question_datasets',
1438                          array('question' => $form->id, 'datasetdefinition' => $def->id));
1439  
1440                  if ($def->category == 0) { // Question local dataset.
1441                      $DB->delete_records('question_dataset_definitions',
1442                              array('id' => $def->id));
1443                      $DB->delete_records('question_dataset_items',
1444                              array('definition' => $def->id));
1445                  }
1446              }
1447          }
1448      }
1449  
1450      // Dataset functionality.
1451      public function pick_question_dataset($question, $datasetitem) {
1452          // Select a dataset in the following format:
1453          // an array indexed by the variable names (d.name) pointing to the value
1454          // to be substituted.
1455          global $CFG, $DB;
1456          if (!$dataitems = $DB->get_records_sql(
1457                  "SELECT i.id, d.name, i.value
1458                     FROM {question_dataset_definitions} d,
1459                          {question_dataset_items} i,
1460                          {question_datasets} q
1461                    WHERE q.question = ?
1462                      AND q.datasetdefinition = d.id
1463                      AND d.id = i.definition
1464                      AND i.itemnumber = ?
1465                 ORDER BY i.id DESC ", array($question->id, $datasetitem))) {
1466              $a = new stdClass();
1467              $a->id = $question->id;
1468              $a->item = $datasetitem;
1469              throw new \moodle_exception('cannotgetdsfordependent', 'question', '', $a);
1470          }
1471          $dataset = Array();
1472          foreach ($dataitems as $id => $dataitem) {
1473              if (!isset($dataset[$dataitem->name])) {
1474                  $dataset[$dataitem->name] = $dataitem->value;
1475              }
1476          }
1477          return $dataset;
1478      }
1479  
1480      public function dataset_options_from_database($form, $name, $prefix = '',
1481              $langfile = 'qtype_calculated') {
1482          global $CFG, $DB;
1483          $type = 1; // Only type = 1 (i.e. old 'LITERAL') has ever been used.
1484          // First options - it is not a dataset...
1485          $options['0'] = get_string($prefix.'nodataset', $langfile);
1486          // New question no local.
1487          if (!isset($form->id) || $form->id == 0) {
1488              $key = "{$type}-0-{$name}";
1489              $options[$key] = get_string($prefix."newlocal{$type}", $langfile);
1490              $currentdatasetdef = new stdClass();
1491              $currentdatasetdef->type = '0';
1492          } else {
1493              // Construct question local options.
1494              $sql = "SELECT a.*
1495                  FROM {question_dataset_definitions} a, {question_datasets} b
1496                 WHERE a.id = b.datasetdefinition AND a.type = '1' AND b.question = ? AND " . $DB->sql_equal('a.name', '?');
1497              $currentdatasetdef = $DB->get_record_sql($sql, array($form->id, $name));
1498              if (!$currentdatasetdef) {
1499                  $currentdatasetdef = new stdClass();
1500                  $currentdatasetdef->type = '0';
1501              }
1502              $key = "{$type}-0-{$name}";
1503              if ($currentdatasetdef->type == $type
1504                      and $currentdatasetdef->category == 0) {
1505                  $options[$key] = get_string($prefix."keptlocal{$type}", $langfile);
1506              } else {
1507                  $options[$key] = get_string($prefix."newlocal{$type}", $langfile);
1508              }
1509          }
1510          // Construct question category options.
1511          $categorydatasetdefs = $DB->get_records_sql(
1512              "SELECT b.question, a.*
1513              FROM {question_datasets} b,
1514              {question_dataset_definitions} a
1515              WHERE a.id = b.datasetdefinition
1516              AND a.type = '1'
1517              AND a.category = ?
1518              AND " . $DB->sql_equal('a.name', '?'), array($form->category, $name));
1519          $type = 1;
1520          $key = "{$type}-{$form->category}-{$name}";
1521          if (!empty($categorydatasetdefs)) {
1522              // There is at least one with the same name.
1523              if (isset($form->id) && isset($categorydatasetdefs[$form->id])) {
1524                  // It is already used by this question.
1525                  $options[$key] = get_string($prefix."keptcategory{$type}", $langfile);
1526              } else {
1527                  $options[$key] = get_string($prefix."existingcategory{$type}", $langfile);
1528              }
1529          } else {
1530              $options[$key] = get_string($prefix."newcategory{$type}", $langfile);
1531          }
1532          // All done!
1533          return array($options, $currentdatasetdef->type
1534              ? "{$currentdatasetdef->type}-{$currentdatasetdef->category}-{$name}"
1535              : '');
1536      }
1537  
1538      /**
1539       * Find the names of all datasets mentioned in a piece of question content like the question text.
1540       * @param $text the text to analyse.
1541       * @return array with dataset name for both key and value.
1542       */
1543      public function find_dataset_names($text) {
1544          preg_match_all(self::PLACEHODLER_REGEX, $text, $matches);
1545          return array_combine($matches[1], $matches[1]);
1546      }
1547  
1548      /**
1549       * Find all the formulas in a bit of text.
1550       *
1551       * For example, called with "What is {a} plus {b}? (Hint, it is not {={a}*{b}}.)" this
1552       * returns ['{a}*{b}'].
1553       *
1554       * @param $text text to analyse.
1555       * @return array where they keys an values are the formulas.
1556       */
1557      public function find_formulas($text) {
1558          preg_match_all(self::FORMULAS_IN_TEXT_REGEX, $text, $matches);
1559          return array_combine($matches[1], $matches[1]);
1560      }
1561  
1562      /**
1563       * This function retrieve the item count of the available category shareable
1564       * wild cards that is added as a comment displayed when a wild card with
1565       * the same name is displayed in datasetdefinitions_form.php
1566       */
1567      public function get_dataset_definitions_category($form) {
1568          global $CFG, $DB;
1569          $datasetdefs = array();
1570          $lnamemax = 30;
1571          if (!empty($form->category)) {
1572              $sql = "SELECT i.*, d.*
1573                        FROM {question_datasets} d, {question_dataset_definitions} i
1574                       WHERE i.id = d.datasetdefinition AND i.category = ?";
1575              if ($records = $DB->get_records_sql($sql, array($form->category))) {
1576                  foreach ($records as $r) {
1577                      if (!isset ($datasetdefs["{$r->name}"])) {
1578                          $datasetdefs["{$r->name}"] = $r->itemcount;
1579                      }
1580                  }
1581              }
1582          }
1583          return $datasetdefs;
1584      }
1585  
1586      /**
1587       * This function build a table showing the available category shareable
1588       * wild cards, their name, their definition (Min, Max, Decimal) , the item count
1589       * and the name of the question where they are used.
1590       * This table is intended to be add before the question text to help the user use
1591       * these wild cards
1592       */
1593      public function print_dataset_definitions_category($form) {
1594          global $CFG, $DB;
1595          $datasetdefs = array();
1596          $lnamemax = 22;
1597          $namestr          = get_string('name');
1598          $rangeofvaluestr  = get_string('minmax', 'qtype_calculated');
1599          $questionusingstr = get_string('usedinquestion', 'qtype_calculated');
1600          $itemscountstr    = get_string('itemscount', 'qtype_calculated');
1601          $text = '';
1602          if (!empty($form->category)) {
1603              list($category) = explode(',', $form->category);
1604              $sql = "SELECT i.*, d.*
1605                  FROM {question_datasets} d,
1606          {question_dataset_definitions} i
1607          WHERE i.id = d.datasetdefinition
1608          AND i.category = ?";
1609              if ($records = $DB->get_records_sql($sql, array($category))) {
1610                  foreach ($records as $r) {
1611                      $sql1 = "SELECT q.*
1612                                 FROM {question} q
1613                                WHERE q.id = ?";
1614                      if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"])) {
1615                          $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r;
1616                      }
1617                      if ($questionb = $DB->get_records_sql($sql1, array($r->question))) {
1618                          if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question])) {
1619                              $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question] = new stdClass();
1620                          }
1621                          $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[
1622                                  $r->question]->name = $questionb[$r->question]->name;
1623                      }
1624                  }
1625              }
1626          }
1627          if (!empty ($datasetdefs)) {
1628  
1629              $text = "<table width=\"100%\" border=\"1\"><tr>
1630                      <th style=\"white-space:nowrap;\" class=\"header\"
1631                              scope=\"col\">{$namestr}</th>
1632                      <th style=\"white-space:nowrap;\" class=\"header\"
1633                              scope=\"col\">{$rangeofvaluestr}</th>
1634                      <th style=\"white-space:nowrap;\" class=\"header\"
1635                              scope=\"col\">{$itemscountstr}</th>
1636                      <th style=\"white-space:nowrap;\" class=\"header\"
1637                              scope=\"col\">{$questionusingstr}</th>
1638                      </tr>";
1639              foreach ($datasetdefs as $datasetdef) {
1640                  list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4);
1641                  $text .= "<tr>
1642                          <td valign=\"top\" align=\"center\">{$datasetdef->name}</td>
1643                          <td align=\"center\" valign=\"top\">{$min} <strong>-</strong> $max</td>
1644                          <td align=\"right\" valign=\"top\">{$datasetdef->itemcount}&nbsp;&nbsp;</td>
1645                          <td align=\"left\">";
1646                  foreach ($datasetdef->questions as $qu) {
1647                      // Limit the name length displayed.
1648                      $questionname = $this->get_short_question_name($qu->name, $lnamemax);
1649                      $text .= " &nbsp;&nbsp; {$questionname} <br/>";
1650                  }
1651                  $text .= "</td></tr>";
1652              }
1653              $text .= "</table>";
1654          } else {
1655              $text .= get_string('nosharedwildcard', 'qtype_calculated');
1656          }
1657          return $text;
1658      }
1659  
1660      /**
1661       * This function shortens a question name if it exceeds the character limit.
1662       *
1663       * @param string $stringtoshorten the string to be shortened.
1664       * @param int $characterlimit the character limit.
1665       * @return string
1666       */
1667      public function get_short_question_name($stringtoshorten, $characterlimit)
1668      {
1669          if (!empty($stringtoshorten)) {
1670              $returnstring = format_string($stringtoshorten);
1671              if (strlen($returnstring) > $characterlimit) {
1672                  $returnstring = shorten_text($returnstring, $characterlimit, true);
1673              }
1674              return $returnstring;
1675          } else {
1676              return '';
1677          }
1678      }
1679  
1680      /**
1681       * This function build a table showing the available category shareable
1682       * wild cards, their name, their definition (Min, Max, Decimal) , the item count
1683       * and the name of the question where they are used.
1684       * This table is intended to be add before the question text to help the user use
1685       * these wild cards
1686       */
1687  
1688      public function print_dataset_definitions_category_shared($question, $datasetdefsq) {
1689          global $CFG, $DB;
1690          $datasetdefs = array();
1691          $lnamemax = 22;
1692          $namestr          = get_string('name', 'quiz');
1693          $rangeofvaluestr  = get_string('minmax', 'qtype_calculated');
1694          $questionusingstr = get_string('usedinquestion', 'qtype_calculated');
1695          $itemscountstr    = get_string('itemscount', 'qtype_calculated');
1696          $text = '';
1697          if (!empty($question->category)) {
1698              list($category) = explode(',', $question->category);
1699              $sql = "SELECT i.*, d.*
1700                        FROM {question_datasets} d, {question_dataset_definitions} i
1701                       WHERE i.id = d.datasetdefinition AND i.category = ?";
1702              if ($records = $DB->get_records_sql($sql, array($category))) {
1703                  foreach ($records as $r) {
1704                      $key = "{$r->type}-{$r->category}-{$r->name}";
1705                      $sql1 = "SELECT q.*
1706                                 FROM {question} q
1707                                WHERE q.id = ?";
1708                      if (!isset($datasetdefs[$key])) {
1709                          $datasetdefs[$key] = $r;
1710                      }
1711                      if ($questionb = $DB->get_records_sql($sql1, array($r->question))) {
1712                          $datasetdefs[$key]->questions[$r->question] = new stdClass();
1713                          $datasetdefs[$key]->questions[$r->question]->name =
1714                                  $questionb[$r->question]->name;
1715                          $datasetdefs[$key]->questions[$r->question]->id =
1716                                  $questionb[$r->question]->id;
1717                      }
1718                  }
1719              }
1720          }
1721          if (!empty ($datasetdefs)) {
1722  
1723              $text  = "<table width=\"100%\" border=\"1\"><tr>
1724                      <th style=\"white-space:nowrap;\" class=\"header\"
1725                              scope=\"col\">{$namestr}</th>";
1726              $text .= "<th style=\"white-space:nowrap;\" class=\"header\"
1727                      scope=\"col\">{$itemscountstr}</th>";
1728              $text .= "<th style=\"white-space:nowrap;\" class=\"header\"
1729                      scope=\"col\">&nbsp;&nbsp;{$questionusingstr} &nbsp;&nbsp;</th>";
1730              $text .= "<th style=\"white-space:nowrap;\" class=\"header\"
1731                      scope=\"col\">Quiz</th>";
1732              $text .= "<th style=\"white-space:nowrap;\" class=\"header\"
1733                      scope=\"col\">Attempts</th></tr>";
1734              foreach ($datasetdefs as $datasetdef) {
1735                  list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4);
1736                  $count = count($datasetdef->questions);
1737                  $text .= "<tr>
1738                          <td style=\"white-space:nowrap;\" valign=\"top\"
1739                                  align=\"center\" rowspan=\"{$count}\"> {$datasetdef->name} </td>
1740                          <td align=\"right\" valign=\"top\"
1741                                  rowspan=\"{$count}\">{$datasetdef->itemcount}</td>";
1742                  $line = 0;
1743                  foreach ($datasetdef->questions as $qu) {
1744                      // Limit the name length displayed.
1745                      $questionname = $this->get_short_question_name($qu->name, $lnamemax);
1746                      if ($line) {
1747                          $text .= "<tr>";
1748                      }
1749                      $line++;
1750                      $text .= "<td align=\"left\" style=\"white-space:nowrap;\">{$questionname}</td>";
1751                      // TODO MDL-43779 should not have quiz-specific code here.
1752                      $sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_bank_usage_sql() . ') questioncount';
1753                      $nbofquiz = $DB->count_records_sql($sql, [$qu->id, 'mod_quiz', 'slot']);
1754                      $sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_attempt_usage_sql() . ') attemptcount';
1755                      $nbofattempts = $DB->count_records_sql($sql, [$qu->id]);
1756                      if ($nbofquiz > 0) {
1757                          $text .= "<td align=\"center\">{$nbofquiz}</td>";
1758                          $text .= "<td align=\"center\">{$nbofattempts}";
1759                      } else {
1760                          $text .= "<td align=\"center\">0</td>";
1761                          $text .= "<td align=\"left\"><br/>";
1762                      }
1763  
1764                      $text .= "</td></tr>";
1765                  }
1766              }
1767              $text .= "</table>";
1768          } else {
1769              $text .= get_string('nosharedwildcard', 'qtype_calculated');
1770          }
1771          return $text;
1772      }
1773  
1774      public function get_virtual_qtype() {
1775          return question_bank::get_qtype('numerical');
1776      }
1777  
1778      public function get_possible_responses($questiondata) {
1779          $responses = array();
1780  
1781          $virtualqtype = $this->get_virtual_qtype();
1782          $unit = $virtualqtype->get_default_numerical_unit($questiondata);
1783  
1784          $tolerancetypes = $this->tolerance_types();
1785  
1786          $starfound = false;
1787          foreach ($questiondata->options->answers as $aid => $answer) {
1788              $responseclass = $answer->answer;
1789  
1790              if ($responseclass === '*') {
1791                  $starfound = true;
1792              } else {
1793                  $a = new stdClass();
1794                  $a->answer = $virtualqtype->add_unit($questiondata, $responseclass, $unit);
1795                  $a->tolerance = $answer->tolerance;
1796                  $a->tolerancetype = $tolerancetypes[$answer->tolerancetype];
1797  
1798                  $responseclass = get_string('answerwithtolerance', 'qtype_calculated', $a);
1799              }
1800  
1801              $responses[$aid] = new question_possible_response($responseclass,
1802                      $answer->fraction);
1803          }
1804  
1805          if (!$starfound) {
1806              $responses[0] = new question_possible_response(
1807              get_string('didnotmatchanyanswer', 'question'), 0);
1808          }
1809  
1810          $responses[null] = question_possible_response::no_response();
1811  
1812          return array($questiondata->id => $responses);
1813      }
1814  
1815      public function move_files($questionid, $oldcontextid, $newcontextid) {
1816          $fs = get_file_storage();
1817  
1818          parent::move_files($questionid, $oldcontextid, $newcontextid);
1819          $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
1820          $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
1821      }
1822  
1823      protected function delete_files($questionid, $contextid) {
1824          $fs = get_file_storage();
1825  
1826          parent::delete_files($questionid, $contextid);
1827          $this->delete_files_in_answers($questionid, $contextid);
1828          $this->delete_files_in_hints($questionid, $contextid);
1829      }
1830  }
1831  
1832  
1833  function qtype_calculated_calculate_answer($formula, $individualdata,
1834      $tolerance, $tolerancetype, $answerlength, $answerformat = '1', $unit = '') {
1835      // The return value has these properties: .
1836      // ->answer    the correct answer
1837      // ->min       the lower bound for an acceptable response
1838      // ->max       the upper bound for an accetpable response.
1839      $calculated = new stdClass();
1840      // Exchange formula variables with the correct values...
1841      $answer = question_bank::get_qtype('calculated')->substitute_variables_and_eval(
1842              $formula, $individualdata);
1843      if (!is_numeric($answer)) {
1844          // Something went wrong, so just return NaN.
1845          $calculated->answer = NAN;
1846          return $calculated;
1847      } else if (is_nan($answer) || is_infinite($answer)) {
1848          $calculated->answer = $answer;
1849          return $calculated;
1850      }
1851      if ('1' == $answerformat) { // Answer is to have $answerlength decimals.
1852          // Decimal places.
1853          $calculated->answer = sprintf('%.' . $answerlength . 'F', $answer);
1854  
1855      } else if ($answer) { // Significant figures does only apply if the result is non-zero.
1856  
1857          // Convert to positive answer...
1858          if ($answer < 0) {
1859              $answer = -$answer;
1860              $sign = '-';
1861          } else {
1862              $sign = '';
1863          }
1864  
1865          // Determine the format 0.[1-9][0-9]* for the answer...
1866          $p10 = 0;
1867          while ($answer < 1) {
1868              --$p10;
1869              $answer *= 10;
1870          }
1871          while ($answer >= 1) {
1872              ++$p10;
1873              $answer /= 10;
1874          }
1875          // ... and have the answer rounded of to the correct length.
1876          $answer = round($answer, $answerlength);
1877  
1878          // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
1879          if ($answer >= 1) {
1880              ++$p10;
1881              $answer /= 10;
1882          }
1883  
1884          // Have the answer written on a suitable format:
1885          // either scientific or plain numeric.
1886          if (-2 > $p10 || 4 < $p10) {
1887              // Use scientific format.
1888              $exponent = 'e'.--$p10;
1889              $answer *= 10;
1890              if (1 == $answerlength) {
1891                  $calculated->answer = $sign.$answer.$exponent;
1892              } else {
1893                  // Attach additional zeros at the end of $answer.
1894                  $answer .= (1 == strlen($answer) ? '.' : '')
1895                      . '00000000000000000000000000000000000000000x';
1896                  $calculated->answer = $sign
1897                      .substr($answer, 0, $answerlength +1).$exponent;
1898              }
1899          } else {
1900              // Stick to plain numeric format.
1901              $answer *= "1e{$p10}";
1902              if (0.1 <= $answer / "1e{$answerlength}") {
1903                  $calculated->answer = $sign.$answer;
1904              } else {
1905                  // Could be an idea to add some zeros here.
1906                  $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
1907                      . '00000000000000000000000000000000000000000x';
1908                  $oklen = $answerlength + ($p10 < 1 ? 2-$p10 : 1);
1909                  $calculated->answer = $sign.substr($answer, 0, $oklen);
1910              }
1911          }
1912  
1913      } else {
1914          $calculated->answer = 0.0;
1915      }
1916      if ($unit != '') {
1917              $calculated->answer = $calculated->answer . ' ' . $unit;
1918      }
1919  
1920      // Return the result.
1921      return $calculated;
1922  }
1923  
1924  
1925  /**
1926   * Validate a forumula.
1927   * @param string $formula the formula to validate.
1928   * @return string|boolean false if there are no problems. Otherwise a string error message.
1929   */
1930  function qtype_calculated_find_formula_errors($formula) {
1931      foreach (['//', '/*', '#', '<?', '?>'] as $commentstart) {
1932          if (strpos($formula, $commentstart) !== false) {
1933              return get_string('illegalformulasyntax', 'qtype_calculated', $commentstart);
1934          }
1935      }
1936  
1937      // Validates the formula submitted from the question edit page.
1938      // Returns false if everything is alright
1939      // otherwise it constructs an error message.
1940      // Strip away dataset names. Use 1.0 to catch illegal concatenation like {a}{b}.
1941      $formula = preg_replace(qtype_calculated::PLACEHODLER_REGEX, '1.0', $formula);
1942  
1943      // Strip away empty space and lowercase it.
1944      $formula = strtolower(str_replace(' ', '', $formula));
1945  
1946      $safeoperatorchar = '-+/*%>:^\~<?=&|!'; /* */
1947      $operatorornumber = "[{$safeoperatorchar}.0-9eE]";
1948  
1949      while (preg_match("~(^|[{$safeoperatorchar},(])([a-z0-9_]*)" .
1950              "\\(({$operatorornumber}+(,{$operatorornumber}+((,{$operatorornumber}+)+)?)?)?\\)~",
1951              $formula, $regs)) {
1952          switch ($regs[2]) {
1953              // Simple parenthesis.
1954              case '':
1955                  if ((isset($regs[4]) && $regs[4]) || strlen($regs[3]) == 0) {
1956                      return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);
1957                  }
1958                  break;
1959  
1960                  // Zero argument functions.
1961              case 'pi':
1962                  if (array_key_exists(3, $regs)) {
1963                      return get_string('functiontakesnoargs', 'qtype_calculated', $regs[2]);
1964                  }
1965                  break;
1966  
1967                  // Single argument functions (the most common case).
1968              case 'abs': case 'acos': case 'acosh': case 'asin': case 'asinh':
1969              case 'atan': case 'atanh': case 'bindec': case 'ceil': case 'cos':
1970              case 'cosh': case 'decbin': case 'decoct': case 'deg2rad':
1971              case 'exp': case 'expm1': case 'floor': case 'is_finite':
1972              case 'is_infinite': case 'is_nan': case 'log10': case 'log1p':
1973              case 'octdec': case 'rad2deg': case 'sin': case 'sinh': case 'sqrt':
1974              case 'tan': case 'tanh':
1975                  if (!empty($regs[4]) || empty($regs[3])) {
1976                      return get_string('functiontakesonearg', 'qtype_calculated', $regs[2]);
1977                  }
1978                  break;
1979  
1980                  // Functions that take one or two arguments.
1981              case 'log': case 'round':
1982                  if (!empty($regs[5]) || empty($regs[3])) {
1983                      return get_string('functiontakesoneortwoargs', 'qtype_calculated', $regs[2]);
1984                  }
1985                  break;
1986  
1987                  // Functions that must have two arguments.
1988              case 'atan2': case 'fmod': case 'pow':
1989                  if (!empty($regs[5]) || empty($regs[4])) {
1990                      return get_string('functiontakestwoargs', 'qtype_calculated', $regs[2]);
1991                  }
1992                  break;
1993  
1994                  // Functions that take two or more arguments.
1995              case 'min': case 'max':
1996                  if (empty($regs[4])) {
1997                      return get_string('functiontakesatleasttwo', 'qtype_calculated', $regs[2]);
1998                  }
1999                  break;
2000  
2001              default:
2002                  return get_string('unsupportedformulafunction', 'qtype_calculated', $regs[2]);
2003          }
2004  
2005          // Exchange the function call with '1.0' and then check for
2006          // another function call...
2007          if ($regs[1]) {
2008              // The function call is proceeded by an operator.
2009              $formula = str_replace($regs[0], $regs[1] . '1.0', $formula);
2010          } else {
2011              // The function call starts the formula.
2012              $formula = preg_replace('~^' . preg_quote($regs[2], '~') . '\([^)]*\)~', '1.0', $formula);
2013          }
2014      }
2015  
2016      if (preg_match("~[^{$safeoperatorchar}.0-9eE]+~", $formula, $regs)) {
2017          return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);
2018      } else {
2019          // Formula just might be valid.
2020          return false;
2021      }
2022  }
2023  
2024  /**
2025   * Validate all the forumulas in a bit of text.
2026   * @param string $text the text in which to validate the formulas.
2027   * @return string|boolean false if there are no problems. Otherwise a string error message.
2028   */
2029  function qtype_calculated_find_formula_errors_in_text($text) {
2030      $formulas = question_bank::get_qtype('calculated')->find_formulas($text);
2031  
2032      $errors = array();
2033      foreach ($formulas as $match) {
2034          $error = qtype_calculated_find_formula_errors($match);
2035          if ($error) {
2036              $errors[] = $error;
2037          }
2038      }
2039  
2040      if ($errors) {
2041          return implode(' ', $errors);
2042      }
2043  
2044      return false;
2045  }