Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]

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