Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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