Rev 11 | AutorÃa | Comparar con el anterior | Ultima modificación | Ver Log |
<?php// This file is part of Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>./*** Question type class for the calculated question type.** @package qtype* @subpackage calculated* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/defined('MOODLE_INTERNAL') || die();require_once($CFG->dirroot . '/question/type/questiontypebase.php');require_once($CFG->dirroot . '/question/type/questionbase.php');require_once($CFG->dirroot . '/question/type/numerical/question.php');/*** The calculated question type.** @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/class qtype_calculated extends question_type {/*** @var string a placeholder is a letter, followed by zero or more alphanum chars (as well as space, - and _ for readability).*/const PLACEHOLDER_REGEX_PART = '[[:alpha:]][[:alpha:][:digit:]\-_\s]*';/*** @var string REGEXP for a placeholder, wrapped in its {...} delimiters, with capturing brackets around the name.*/const PLACEHODLER_REGEX = '~\{(' . self::PLACEHOLDER_REGEX_PART . ')\}~';/*** @var string Regular expression that finds the formulas in content, with capturing brackets to get the forumlas.*/const FORMULAS_IN_TEXT_REGEX = '~\{=([^{}]*(?:\{' . self::PLACEHOLDER_REGEX_PART . '\}[^{}]*)*)\}~';const MAX_DATASET_ITEMS = 100;public $wizardpagesnumber = 3;public function get_question_options($question) {// First get the datasets and default options.// The code is used for calculated, calculatedsimple and calculatedmulti qtypes.global $CFG, $DB, $OUTPUT;parent::get_question_options($question);if (!$question->options = $DB->get_record('question_calculated_options',['question' => $question->id])) {$question->options = new stdClass();$question->options->synchronize = 0;$question->options->single = 0;$question->options->answernumbering = 'abc';$question->options->shuffleanswers = 0;$question->options->correctfeedback = '';$question->options->partiallycorrectfeedback = '';$question->options->incorrectfeedback = '';$question->options->correctfeedbackformat = 0;$question->options->partiallycorrectfeedbackformat = 0;$question->options->incorrectfeedbackformat = 0;}if (!$question->options->answers = $DB->get_records_sql("SELECT a.*, c.tolerance, c.tolerancetype, c.correctanswerlength, c.correctanswerformatFROM {question_answers} a,{question_calculated} cWHERE a.question = ?AND a.id = c.answerORDER BY a.id ASC", [$question->id])) {return false;}if ($this->get_virtual_qtype()->name() == 'numerical') {$this->get_virtual_qtype()->get_numerical_units($question);$this->get_virtual_qtype()->get_numerical_options($question);}$question->hints = $DB->get_records('question_hints',['questionid' => $question->id], 'id ASC');if (isset($question->export_process)&&$question->export_process) {$question->options->datasets = $this->get_datasets_for_export($question);}return true;}public function get_datasets_for_export($question) {global $DB, $CFG;$datasetdefs = [];if (!empty($question->id)) {$sql = "SELECT i.*FROM {question_datasets} d, {question_dataset_definitions} iWHERE d.question = ? AND d.datasetdefinition = i.id";if ($records = $DB->get_records_sql($sql, [$question->id])) {foreach ($records as $r) {$def = $r;if ($def->category == '0') {$def->status = 'private';} else {$def->status = 'shared';}$def->type = 'calculated';list($distribution, $min, $max, $dec) = explode(':', $def->options, 4);$def->distribution = $distribution;$def->minimum = $min;$def->maximum = $max;$def->decimals = $dec;if ($def->itemcount > 0) {// Get the datasetitems.$def->items = [];if ($items = $this->get_database_dataset_items($def->id)) {$n = 0;foreach ($items as $ii) {$n++;$def->items[$n] = new stdClass();$def->items[$n]->itemnumber = $ii->itemnumber;$def->items[$n]->value = $ii->value;}$def->number_of_items = $n;}}$datasetdefs["1-{$r->category}-{$r->name}"] = $def;}}}return $datasetdefs;}public function save_question_options($question) {global $CFG, $DB;// Make it impossible to save bad formulas anywhere.$this->validate_question_data($question);// The code is used for calculated, calculatedsimple and calculatedmulti qtypes.$context = $question->context;// Calculated options.$update = true;$options = $DB->get_record('question_calculated_options',['question' => $question->id]);if (!$options) {$update = false;$options = new stdClass();$options->question = $question->id;}// As used only by calculated.if (isset($question->synchronize)) {$options->synchronize = $question->synchronize;} else {$options->synchronize = 0;}$options->single = 0;$options->answernumbering = $question->answernumbering;$options->shuffleanswers = $question->shuffleanswers;foreach (['correctfeedback', 'partiallycorrectfeedback','incorrectfeedback'] as $feedbackname) {$options->$feedbackname = '';$feedbackformat = $feedbackname . 'format';$options->$feedbackformat = 0;}if ($update) {$DB->update_record('question_calculated_options', $options);} else {$DB->insert_record('question_calculated_options', $options);}// Get old versions of the objects.$oldanswers = $DB->get_records('question_answers',['question' => $question->id], 'id ASC');$oldoptions = $DB->get_records('question_calculated',['question' => $question->id], 'answer ASC');// Save the units.$virtualqtype = $this->get_virtual_qtype();$result = $virtualqtype->save_units($question);if (isset($result->error)) {return $result;} else {$units = $result->units;}foreach ($question->answer as $key => $answerdata) {if (trim($answerdata) == '') {continue;}// Update an existing answer if possible.$answer = array_shift($oldanswers);if (!$answer) {$answer = new stdClass();$answer->question = $question->id;$answer->answer = '';$answer->feedback = '';$answer->id = $DB->insert_record('question_answers', $answer);}$answer->answer = trim($answerdata);$answer->fraction = $question->fraction[$key];$answer->feedback = $this->import_or_save_files($question->feedback[$key],$context, 'question', 'answerfeedback', $answer->id);$answer->feedbackformat = $question->feedback[$key]['format'];$DB->update_record("question_answers", $answer);// Set up the options object.if (!$options = array_shift($oldoptions)) {$options = new stdClass();}$options->question = $question->id;$options->answer = $answer->id;$options->tolerance = trim($question->tolerance[$key]);$options->tolerancetype = trim($question->tolerancetype[$key]);$options->correctanswerlength = trim($question->correctanswerlength[$key]);$options->correctanswerformat = trim($question->correctanswerformat[$key]);// Save options.if (isset($options->id)) {// Reusing existing record.$DB->update_record('question_calculated', $options);} else {// New options.$DB->insert_record('question_calculated', $options);}}// Delete old answer records.if (!empty($oldanswers)) {foreach ($oldanswers as $oa) {$DB->delete_records('question_answers', ['id' => $oa->id]);}}// Delete old answer records.if (!empty($oldoptions)) {foreach ($oldoptions as $oo) {$DB->delete_records('question_calculated', ['id' => $oo->id]);}}$result = $virtualqtype->save_unit_options($question);if (isset($result->error)) {return $result;}$this->save_hints($question);if (isset($question->import_process)&&$question->import_process) {$this->import_datasets($question);}// Report any problems.if (!empty($result->notice)) {return $result;}return true;}public function import_datasets($question) {global $DB;$n = count($question->dataset);foreach ($question->dataset as $dataset) {// Name, type, option.$datasetdef = new stdClass();$datasetdef->name = $dataset->name;$datasetdef->type = 1;$datasetdef->options = $dataset->distribution . ':' . $dataset->min . ':' .$dataset->max . ':' . $dataset->length;$datasetdef->itemcount = $dataset->itemcount;if ($dataset->status == 'private') {$datasetdef->category = 0;$todo = 'create';} else if ($dataset->status == 'shared') {if ($sharedatasetdefs = $DB->get_records_select('question_dataset_definitions',"type = '1'AND " . $DB->sql_equal('name', '?') . "AND category = ?ORDER BY id DESC ", [$dataset->name, $question->category])) { // So there is at least one.$sharedatasetdef = array_shift($sharedatasetdefs);if ($sharedatasetdef->options == $datasetdef->options) {// Identical so use it.$todo = 'useit';$datasetdef = $sharedatasetdef;} else { // Different so create a private one.$datasetdef->category = 0;$todo = 'create';}} else { // No so create one.$datasetdef->category = $question->category;$todo = 'create';}}if ($todo == 'create') {$datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);}// Create relation to the dataset.$questiondataset = new stdClass();$questiondataset->question = $question->id;$questiondataset->datasetdefinition = $datasetdef->id;$DB->insert_record('question_datasets', $questiondataset);if ($todo == 'create') {// Add the items.foreach ($dataset->datasetitem as $dataitem) {$datasetitem = new stdClass();$datasetitem->definition = $datasetdef->id;$datasetitem->itemnumber = $dataitem->itemnumber;$datasetitem->value = $dataitem->value;$DB->insert_record('question_dataset_items', $datasetitem);}}}}/*** Initializes calculated answers for a given question.** @param question_definition $question The question definition object.* @param stdClass $questiondata The question data object.*/protected function initialise_calculated_answers(question_definition $question, stdClass $questiondata) {$question->answers = [];if (empty($questiondata->options->answers)) {return;}foreach ($questiondata->options->answers as $a) {$question->answers[$a->id] = new \qtype_calculated\qtype_calculated_answer($a->id, $a->answer,$a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);}}protected function initialise_question_instance(question_definition $question, $questiondata) {parent::initialise_question_instance($question, $questiondata);$this->initialise_calculated_answers($question, $questiondata);foreach ($questiondata->options->answers as $a) {$question->answers[$a->id]->tolerancetype = $a->tolerancetype;$question->answers[$a->id]->correctanswerlength = $a->correctanswerlength;$question->answers[$a->id]->correctanswerformat = $a->correctanswerformat;}$question->synchronised = $questiondata->options->synchronize;$question->unitdisplay = $questiondata->options->showunits;$question->unitgradingtype = $questiondata->options->unitgradingtype;$question->unitpenalty = $questiondata->options->unitpenalty;$question->ap = question_bank::get_qtype('numerical')->make_answer_processor($questiondata->options->units, $questiondata->options->unitsleft);$question->datasetloader = new qtype_calculated_dataset_loader($questiondata->id);}public function finished_edit_wizard($form) {return isset($form->savechanges);}public function wizardpagesnumber() {return 3;}// This gets called by editquestion.php after the standard question is saved.public function print_next_wizard_page($question, $form, $course) {global $CFG, $SESSION, $COURSE;// Catch invalid navigation & reloads.if (empty($question->id) && empty($SESSION->calculated)) {redirect('edit.php?courseid='.$COURSE->id, 'The page you are loading has expired.', 3);}// See where we're coming from.switch($form->wizardpage) {case 'question':require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions.php");break;case 'datasetdefinitions':case 'datasetitems':require("{$CFG->dirroot}/question/type/calculated/datasetitems.php");break;default:throw new \moodle_exception('invalidwizardpage', 'question');break;}}// This gets called by question2.php after the standard question is saved.public function &next_wizard_form($submiturl, $question, $wizardnow) {global $CFG, $SESSION, $COURSE;// Catch invalid navigation & reloads.if (empty($question->id) && empty($SESSION->calculated)) {redirect('edit.php?courseid=' . $COURSE->id,'The page you are loading has expired. Cannot get next wizard form.', 3);}if (empty($question->id)) {$question = $SESSION->calculated->questionform;}// See where we're coming from.switch($wizardnow) {case 'datasetdefinitions':require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions_form.php");$mform = new question_dataset_dependent_definitions_form("{$submiturl}?wizardnow=datasetdefinitions", $question);break;case 'datasetitems':require("{$CFG->dirroot}/question/type/calculated/datasetitems_form.php");$regenerate = optional_param('forceregeneration', false, PARAM_BOOL);$mform = new question_dataset_dependent_items_form("{$submiturl}?wizardnow=datasetitems", $question, $regenerate);break;default:throw new \moodle_exception('invalidwizardpage', 'question');break;}return $mform;}/*** This method should be overriden if you want to include a special heading or some other* html on a question editing page besides the question editing form.** @param question_edit_form $mform a child of question_edit_form* @param object $question* @param string $wizardnow is '' for first page.*/public function display_question_editing_page($mform, $question, $wizardnow) {global $OUTPUT;switch ($wizardnow) {case '':// On the first page, the default display is fine.parent::display_question_editing_page($mform, $question, $wizardnow);return;case 'datasetdefinitions':echo $OUTPUT->heading_with_help(get_string('choosedatasetproperties', 'qtype_calculated'),'questiondatasets', 'qtype_calculated');break;case 'datasetitems':echo $OUTPUT->heading_with_help(get_string('editdatasets', 'qtype_calculated'),'questiondatasets', 'qtype_calculated');break;}$mform->display();}/*** Verify that the equations in part of the question are OK.* We throw an exception here because this should have already been validated* by the form. This is just a last line of defence to prevent a question* being stored in the database if it has bad formulas. This saves us from,* for example, malicious imports.* @param string $text containing equations.*/protected function validate_text($text) {$error = qtype_calculated_find_formula_errors_in_text($text);if ($error) {throw new coding_exception($error);}}/*** Verify that an answer is OK.* We throw an exception here because this should have already been validated* by the form. This is just a last line of defence to prevent a question* being stored in the database if it has bad formulas. This saves us from,* for example, malicious imports.* @param string $text containing equations.*/protected function validate_answer($answer) {$error = qtype_calculated_find_formula_errors($answer);if ($error) {throw new coding_exception($error);}}/*** Validate data before save.* @param stdClass $question data from the form / import file.*/protected function validate_question_data($question) {$this->validate_text($question->questiontext); // Yes, really no ['text'].if (isset($question->generalfeedback['text'])) {$this->validate_text($question->generalfeedback['text']);} else if (isset($question->generalfeedback)) {$this->validate_text($question->generalfeedback); // Because question import is weird.}foreach ($question->answer as $key => $answer) {$this->validate_answer($answer);$this->validate_text($question->feedback[$key]['text']);}}/*** Remove prefix #{..}# if exists.* @param $name a question name,* @return string the cleaned up question name.*/public function clean_technical_prefix_from_question_name($name) {return preg_replace('~#\{([^[:space:]]*)#~', '', $name);}/*** This method prepare the $datasets in a format similar to dadatesetdefinitions_form.php* so that they can be saved* using the function save_dataset_definitions($form)* when creating a new calculated question or* when editing an already existing calculated question* or by function save_as_new_dataset_definitions($form, $initialid)* when saving as new an already existing calculated question.** @param object $form* @param int $questionfromid default = '0'*/public function preparedatasets($form, $questionfromid = '0') {// The dataset names present in the edit_question_form and edit_calculated_form// are retrieved.$possibledatasets = $this->find_dataset_names($form->questiontext);$mandatorydatasets = [];foreach ($form->answer as $key => $answer) {$mandatorydatasets += $this->find_dataset_names($answer);}// If there are identical datasetdefs already saved in the original question// either when editing a question or saving as new,// they are retrieved using $questionfromid.if ($questionfromid != '0') {$form->id = $questionfromid;}$datasets = [];$key = 0;// Always prepare the mandatorydatasets present in the answers.// The $options are not used here.foreach ($mandatorydatasets as $datasetname) {if (!isset($datasets[$datasetname])) {list($options, $selected) =$this->dataset_options($form, $datasetname);$datasets[$datasetname] = '';$form->dataset[$key] = $selected;$key++;}}// Do not prepare possibledatasets when creating a question.// They will defined and stored with datasetdefinitions_form.php.// The $options are not used here.if ($questionfromid != '0') {foreach ($possibledatasets as $datasetname) {if (!isset($datasets[$datasetname])) {list($options, $selected) =$this->dataset_options($form, $datasetname, false);$datasets[$datasetname] = '';$form->dataset[$key] = $selected;$key++;}}}return $datasets;}public function addnamecategory(&$question) {global $DB;$categorydatasetdefs = $DB->get_records_sql("SELECT a.*FROM {question_datasets} b, {question_dataset_definitions} aWHERE a.id = b.datasetdefinitionAND a.type = '1'AND a.category != 0AND b.question = ?ORDER BY a.name ", [$question->id]);$questionname = $this->clean_technical_prefix_from_question_name($question->name);if (!empty($categorydatasetdefs)) {// There is at least one with the same name.$questionname = '#' . $questionname;foreach ($categorydatasetdefs as $def) {if (strlen($def->name) + strlen($questionname) < 250) {$questionname = '{' . $def->name . '}' . $questionname;}}$questionname = '#' . $questionname;}$DB->set_field('question', 'name', $questionname, ['id' => $question->id]);}/*** this version save the available data at the different steps of the question editing process* without using global $SESSION as storage between steps* at the first step $wizardnow = 'question'* when creating a new question* when modifying a question* when copying as a new question* the general parameters and answers are saved using parent::save_question* then the datasets are prepared and saved* at the second step $wizardnow = 'datasetdefinitions'* the datadefs final type are defined as private, category or not a datadef* at the third step $wizardnow = 'datasetitems'* the datadefs parameters and the data items are created or defined** @param object question* @param object $form* @param int $course* @param PARAM_ALPHA $wizardnow should be added as we are coming from question2.php*/public function save_question($question, $form) {global $DB;if ($this->wizardpagesnumber() == 1 || $question->qtype == 'calculatedsimple') {$question = parent::save_question($question, $form);return $question;}$wizardnow = optional_param('wizardnow', '', PARAM_ALPHA);$id = optional_param('id', 0, PARAM_INT); // Question id.// In case 'question':// For a new question $form->id is empty// when saving as new question.// The $question->id = 0, $form is $data from question2.php// and $data->makecopy is defined as $data->id is the initial question id.// Edit case. If it is a new question we don't necessarily need to// return a valid question object.// See where we're coming from.switch($wizardnow) {case '' :case 'question': // Coming from the first page, creating the second.if (empty($form->id)) { // Or a new question $form->id is empty.$question = parent::save_question($question, $form);// Prepare the datasets using default $questionfromid.$this->preparedatasets($form);$form->id = $question->id;$this->save_dataset_definitions($form);if (isset($form->synchronize) && $form->synchronize == 2) {$this->addnamecategory($question);}} else {$questionfromid = $form->id;$question = parent::save_question($question, $form);// Prepare the datasets.$this->preparedatasets($form, $questionfromid);$form->id = $question->id;$this->save_as_new_dataset_definitions($form, $questionfromid);if (isset($form->synchronize) && $form->synchronize == 2) {$this->addnamecategory($question);}}break;case 'datasetdefinitions':// Calculated options.// It cannot go here without having done the first page,// so the question_calculated_options should exist.// We only need to update the synchronize field.if (isset($form->synchronize)) {$optionssynchronize = $form->synchronize;} else {$optionssynchronize = 0;}$DB->set_field('question_calculated_options', 'synchronize', $optionssynchronize,['question' => $question->id]);if (isset($form->synchronize) && $form->synchronize == 2) {$this->addnamecategory($question);}$this->save_dataset_definitions($form);break;case 'datasetitems':$this->save_dataset_items($question, $form);$this->save_question_calculated($question, $form);break;default:throw new \moodle_exception('invalidwizardpage', 'question');break;}return $question;}public function delete_question($questionid, $contextid) {global $DB;$DB->delete_records('question_calculated', ['question' => $questionid]);$DB->delete_records('question_calculated_options', ['question' => $questionid]);$DB->delete_records('question_numerical_units', ['question' => $questionid]);if ($datasets = $DB->get_records('question_datasets', ['question' => $questionid])) {foreach ($datasets as $dataset) {if (!$DB->get_records_select('question_datasets',"question != ? AND datasetdefinition = ? ",[$questionid, $dataset->datasetdefinition])) {$DB->delete_records('question_dataset_definitions',['id' => $dataset->datasetdefinition]);$DB->delete_records('question_dataset_items',['definition' => $dataset->datasetdefinition]);}}}$DB->delete_records('question_datasets', ['question' => $questionid]);parent::delete_question($questionid, $contextid);}public function get_random_guess_score($questiondata) {foreach ($questiondata->options->answers as $aid => $answer) {if ('*' == trim($answer->answer)) {return max($answer->fraction - $questiondata->options->unitpenalty, 0);}}return 0;}public function supports_dataset_item_generation() {// Calculated support generation of randomly distributed number data.return true;}public function custom_generator_tools_part($mform, $idx, $j) {$minmaxgrp = [];$minmaxgrp[] = $mform->createElement('float', "calcmin[{$idx}]",get_string('calcmin', 'qtype_calculated'));$minmaxgrp[] = $mform->createElement('float', "calcmax[{$idx}]",get_string('calcmax', 'qtype_calculated'));$mform->addGroup($minmaxgrp, 'minmaxgrp',get_string('minmax', 'qtype_calculated'), ' - ', false);$precisionoptions = range(0, 10);$mform->addElement('select', "calclength[{$idx}]",get_string('calclength', 'qtype_calculated'), $precisionoptions);$distriboptions = ['uniform' => get_string('uniform', 'qtype_calculated'),'loguniform' => get_string('loguniform', 'qtype_calculated')];$mform->addElement('select', "calcdistribution[{$idx}]",get_string('calcdistribution', 'qtype_calculated'), $distriboptions);}public function custom_generator_set_data($datasetdefs, $formdata) {$idx = 1;foreach ($datasetdefs as $datasetdef) {if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~',$datasetdef->options, $regs)) {$formdata["calcdistribution[{$idx}]"] = $regs[1];$formdata["calcmin[{$idx}]"] = $regs[2];$formdata["calcmax[{$idx}]"] = $regs[3];$formdata["calclength[{$idx}]"] = $regs[4];}$idx++;}return $formdata;}public function custom_generator_tools($datasetdef) {global $OUTPUT;if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~',$datasetdef->options, $regs)) {$defid = "{$datasetdef->type}-{$datasetdef->category}-{$datasetdef->name}";for ($i = 0; $i < 10; ++$i) {$lengthoptions[$i] = get_string(($regs[1] == 'uniform'? 'decimals': 'significantfigures'), 'qtype_calculated', $i);}$menu1 = html_writer::label(get_string('lengthoption', 'qtype_calculated'),'menucalclength', false, ['class' => 'accesshide']);$menu1 .= html_writer::select($lengthoptions, 'calclength[]', $regs[4], null, ['class' => 'form-select']);$options = ['uniform' => get_string('uniformbit', 'qtype_calculated'),'loguniform' => get_string('loguniformbit', 'qtype_calculated')];$menu2 = html_writer::label(get_string('distributionoption', 'qtype_calculated'),'menucalcdistribution', false, ['class' => 'accesshide']);$menu2 .= html_writer::select($options, 'calcdistribution[]', $regs[1], null, ['class' => 'form-select']);return '<input type="submit" class="btn btn-secondary" onclick="'. "getElementById('addform').regenerateddefid.value='{$defid}'; return true;".'" value="'. get_string('generatevalue', 'qtype_calculated') . '"/><br/>'. '<input type="text" class="form-control" size="3" name="calcmin[]" '. " value=\"{$regs[2]}\"/> & <input name=\"calcmax[]\" ". ' type="text" class="form-control" size="3" value="' . $regs[3] .'"/> '. $menu1 . '<br/>'. $menu2;} else {return '';}}public function update_dataset_options($datasetdefs, $form) {global $OUTPUT;// Do we have information about new options ?if (empty($form->definition) || empty($form->calcmin)||empty($form->calcmax) || empty($form->calclength)|| empty($form->calcdistribution)) {// I guess not.} else {// Looks like we just could have some new information here.$uniquedefs = array_values(array_unique($form->definition));foreach ($uniquedefs as $key => $defid) {if (isset($datasetdefs[$defid])&& is_numeric($form->calcmin[$key + 1])&& is_numeric($form->calcmax[$key + 1])&& is_numeric($form->calclength[$key + 1])) {switch ($form->calcdistribution[$key + 1]) {case 'uniform': case 'loguniform':$datasetdefs[$defid]->options =$form->calcdistribution[$key + 1] . ':'. $form->calcmin[$key + 1] . ':'. $form->calcmax[$key + 1] . ':'. $form->calclength[$key + 1];break;default:echo $OUTPUT->notification("Unexpected distribution ".$form->calcdistribution[$key + 1]);}}}}// Look for empty options, on which we set default values.foreach ($datasetdefs as $defid => $def) {if (empty($def->options)) {$datasetdefs[$defid]->options = 'uniform:1.0:10.0:1';}}return $datasetdefs;}public function save_question_calculated($question, $fromform) {global $DB;foreach ($question->options->answers as $key => $answer) {if ($options = $DB->get_record('question_calculated', ['answer' => $key])) {$options->tolerance = trim($fromform->tolerance[$key]);$options->tolerancetype = trim($fromform->tolerancetype[$key]);$options->correctanswerlength = trim($fromform->correctanswerlength[$key]);$options->correctanswerformat = trim($fromform->correctanswerformat[$key]);$DB->update_record('question_calculated', $options);}}}/*** This function get the dataset items using id as unique parameter and return an* array with itemnumber as index sorted ascendant* If the multiple records with the same itemnumber exist, only the newest one* i.e with the greatest id is used, the others are ignored but not deleted.* MDL-19210*/public function get_database_dataset_items($definition) {global $CFG, $DB;$databasedataitems = $DB->get_records_sql( // Hint: Use the number as a key." SELECT id , itemnumber, definition, valueFROM {question_dataset_items}WHERE definition = $definition order by id DESC ", [$definition]);$dataitems = [];foreach ($databasedataitems as $id => $dataitem) {if (!isset($dataitems[$dataitem->itemnumber])) {$dataitems[$dataitem->itemnumber] = $dataitem;}}ksort($dataitems);return $dataitems;}public function save_dataset_items($question, $fromform) {global $CFG, $DB;$synchronize = false;if (isset($fromform->nextpageparam['forceregeneration'])) {$regenerate = $fromform->nextpageparam['forceregeneration'];} else {$regenerate = 0;}if (empty($question->options)) {$this->get_question_options($question);}if (!empty($question->options->synchronize)) {$synchronize = true;}// Get the old datasets for this question.$datasetdefs = $this->get_dataset_definitions($question->id, []);// Handle generator options...$olddatasetdefs = fullclone($datasetdefs);$datasetdefs = $this->update_dataset_options($datasetdefs, $fromform);$maxnumber = -1;foreach ($datasetdefs as $defid => $datasetdef) {if (isset($datasetdef->id)&& $datasetdef->options != $olddatasetdefs[$defid]->options) {// Save the new value for options.$DB->update_record('question_dataset_definitions', $datasetdef);}// Get maxnumber.if ($maxnumber == -1 || $datasetdef->itemcount < $maxnumber) {$maxnumber = $datasetdef->itemcount;}}// Handle adding and removing of dataset items.$i = 1;if ($maxnumber > self::MAX_DATASET_ITEMS) {$maxnumber = self::MAX_DATASET_ITEMS;}ksort($fromform->definition);foreach ($fromform->definition as $key => $defid) {// If the delete button has not been pressed then skip the datasetitems// in the 'add item' part of the form.if ($i > count($datasetdefs) * $maxnumber) {break;}$addeditem = new stdClass();$addeditem->definition = $datasetdefs[$defid]->id;$addeditem->value = $fromform->number[$i];$addeditem->itemnumber = ceil($i / count($datasetdefs));if ($fromform->itemid[$i]) {// Reuse any previously used record.$addeditem->id = $fromform->itemid[$i];$DB->update_record('question_dataset_items', $addeditem);} else {$DB->insert_record('question_dataset_items', $addeditem);}$i++;}if (isset($addeditem->itemnumber) && $maxnumber < $addeditem->itemnumber&& $addeditem->itemnumber < self::MAX_DATASET_ITEMS) {$maxnumber = $addeditem->itemnumber;foreach ($datasetdefs as $key => $newdef) {if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) {$newdef->itemcount = $maxnumber;// Save the new value for options.$DB->update_record('question_dataset_definitions', $newdef);}}}// Adding supplementary items.$numbertoadd = 0;if (isset($fromform->addbutton) && $fromform->selectadd > 0 &&$maxnumber < self::MAX_DATASET_ITEMS) {$numbertoadd = $fromform->selectadd;if (self::MAX_DATASET_ITEMS - $maxnumber < $numbertoadd) {$numbertoadd = self::MAX_DATASET_ITEMS - $maxnumber;}// Add the other items.// Generate a new dataset item (or reuse an old one).foreach ($datasetdefs as $defid => $datasetdef) {// In case that for category datasets some new items has been added,// get actual values.// Fix regenerate for this datadefs.$defregenerate = 0;if ($synchronize &&!empty ($fromform->nextpageparam["datasetregenerate[{$datasetdef->name}"])) {$defregenerate = 1;} else if (!$synchronize &&(($regenerate == 1 && $datasetdef->category == 0) ||$regenerate == 2)) {$defregenerate = 1;}if (isset($datasetdef->id)) {$datasetdefs[$defid]->items =$this->get_database_dataset_items($datasetdef->id);}for ($numberadded = $maxnumber + 1; $numberadded <= $maxnumber + $numbertoadd; $numberadded++) {if (isset($datasetdefs[$defid]->items[$numberadded])) {// In case of regenerate it modifies the already existing record.if ($defregenerate) {$datasetitem = new stdClass();$datasetitem->id = $datasetdefs[$defid]->items[$numberadded]->id;$datasetitem->definition = $datasetdef->id;$datasetitem->itemnumber = $numberadded;$datasetitem->value =$this->generate_dataset_item($datasetdef->options);$DB->update_record('question_dataset_items', $datasetitem);}// If not regenerate do nothing as there is already a record.} else {$datasetitem = new stdClass();$datasetitem->definition = $datasetdef->id;$datasetitem->itemnumber = $numberadded;if ($this->supports_dataset_item_generation()) {$datasetitem->value =$this->generate_dataset_item($datasetdef->options);} else {$datasetitem->value = '';}$DB->insert_record('question_dataset_items', $datasetitem);}}// For number added.}// Datasetsdefs end.$maxnumber += $numbertoadd;foreach ($datasetdefs as $key => $newdef) {if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) {$newdef->itemcount = $maxnumber;// Save the new value for options.$DB->update_record('question_dataset_definitions', $newdef);}}}if (isset($fromform->deletebutton)) {if (isset($fromform->selectdelete)) {$newmaxnumber = $maxnumber - $fromform->selectdelete;} else {$newmaxnumber = $maxnumber - 1;}if ($newmaxnumber < 0) {$newmaxnumber = 0;}foreach ($datasetdefs as $datasetdef) {if ($datasetdef->itemcount == $maxnumber) {$datasetdef->itemcount = $newmaxnumber;$DB->update_record('question_dataset_definitions', $datasetdef);}}}}public function generate_dataset_item($options) {if (!preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~',$options, $regs)) {// Unknown options...return false;}if ($regs[1] == 'uniform') {$nbr = $regs[2] + ($regs[3] - $regs[2]) * mt_rand() / mt_getrandmax();return sprintf("%.".$regs[4].'f', $nbr);} else if ($regs[1] == 'loguniform') {$log0 = log(abs($regs[2])); // It would have worked the other way to.$nbr = exp($log0 + (log(abs($regs[3])) - $log0) * mt_rand() / mt_getrandmax());return sprintf("%.".$regs[4].'f', $nbr);} else {throw new \moodle_exception('disterror', 'question', '', $regs[1]);}return '';}public function comment_header($question) {$strheader = '';$delimiter = '';$answers = $question->options->answers;foreach ($answers as $key => $answer) {$ans = shorten_text($answer->answer, 17, true);$strheader .= $delimiter.$ans;$delimiter = '<br/><br/><br/>';}return $strheader;}public function comment_on_datasetitems($qtypeobj, $questionid, $questiontext,$answers, $data, $number) {global $DB;$comment = new stdClass();$comment->stranswers = [];$comment->outsidelimit = false;$comment->answers = [];// Find a default unit.$unit = '';if (!empty($questionid)) {$units = $DB->get_records('question_numerical_units',['question' => $questionid, 'multiplier' => 1.0],'id ASC', '*', 0, 1);if ($units) {$unit = reset($units);$unit = $unit->unit;}}$answers = fullclone($answers);$delimiter = ': ';$virtualqtype = $qtypeobj->get_virtual_qtype();foreach ($answers as $key => $answer) {$error = qtype_calculated_find_formula_errors($answer->answer);if ($error) {$comment->stranswers[$key] = $error;continue;}$formula = $this->substitute_variables($answer->answer, $data);$formattedanswer = qtype_calculated_calculate_answer($answer->answer, $data, $answer->tolerance,$answer->tolerancetype, $answer->correctanswerlength,$answer->correctanswerformat, $unit);if ($formula === '*') {$answer->min = ' ';$formattedanswer->answer = $answer->answer;} else {eval('$ansvalue = '.$formula.';');$ans = new qtype_numerical_answer(0, $ansvalue, 0, '', 0, $answer->tolerance);$ans->tolerancetype = $answer->tolerancetype;list($answer->min, $answer->max) = $ans->get_tolerance_interval($answer);}if ($answer->min === '') {// This should mean that something is wrong.$comment->stranswers[$key] = " {$formattedanswer->answer}".'<br/><br/>';} else if ($formula === '*') {$comment->stranswers[$key] = $formula . ' = ' .get_string('anyvalue', 'qtype_calculated') . '<br/><br/><br/>';} else {$formula = shorten_text($formula, 57, true);$comment->stranswers[$key] = $formula . ' = ' . $formattedanswer->answer . '<br/>';$correcttrue = new stdClass();$correcttrue->correct = $formattedanswer->answer;$correcttrue->true = '';if ((float) $formattedanswer->answer < $answer->min ||(float) $formattedanswer->answer > $answer->max) {$comment->outsidelimit = true;$comment->answers[$key] = $key;$comment->stranswers[$key] .=get_string('trueansweroutsidelimits', 'qtype_calculated', $correcttrue);} else {$comment->stranswers[$key] .=get_string('trueanswerinsidelimits', 'qtype_calculated', $correcttrue);}$comment->stranswers[$key] .= '<br/>';$comment->stranswers[$key] .= get_string('min', 'qtype_calculated') .$delimiter . $answer->min . ' --- ';$comment->stranswers[$key] .= get_string('max', 'qtype_calculated') .$delimiter . $answer->max;}}return fullclone($comment);}public function tolerance_types() {return ['1' => get_string('relative', 'qtype_numerical'),'2' => get_string('nominal', 'qtype_numerical'),'3' => get_string('geometric', 'qtype_numerical'),];}public function dataset_options($form, $name, $mandatory = true,$renameabledatasets = false) {// Takes datasets from the parent implementation but// filters options that are currently not accepted by calculated.// It also determines a default selection.// Param $renameabledatasets not implemented anywhere.list($options, $selected) = $this->dataset_options_from_database($form, $name, '', 'qtype_calculated');foreach ($options as $key => $whatever) {if (!preg_match('~^1-~', $key) && $key != '0') {unset($options[$key]);}}if (!$selected) {if ($mandatory) {$selected = "1-0-{$name}"; // Default.} else {$selected = '0'; // Default.}}return [$options, $selected];}public function construct_dataset_menus($form, $mandatorydatasets,$optionaldatasets) {global $OUTPUT;$datasetmenus = [];foreach ($mandatorydatasets as $datasetname) {if (!isset($datasetmenus[$datasetname])) {list($options, $selected) =$this->dataset_options($form, $datasetname);unset($options['0']); // Mandatory...$datasetmenus[$datasetname] = html_writer::select($options, 'dataset[]', $selected, null);}}foreach ($optionaldatasets as $datasetname) {if (!isset($datasetmenus[$datasetname])) {list($options, $selected) =$this->dataset_options($form, $datasetname);$datasetmenus[$datasetname] = html_writer::select($options, 'dataset[]', $selected, null);}}return $datasetmenus;}public function substitute_variables($str, $dataset) {global $OUTPUT;// Testing for wrong numerical values.// All calculations used this function so testing here should be OK.foreach ($dataset as $name => $value) {$val = $value;if (! is_numeric($val)) {$a = new stdClass();$a->name = '{'.$name.'}';$a->value = $value;echo $OUTPUT->notification(get_string('notvalidnumber', 'qtype_calculated', $a));$val = 1.0;}if ($val <= 0) { // MDL-36025 Use parentheses for "-0" .$str = str_replace('{'.$name.'}', '('.$val.')', $str);} else {$str = str_replace('{'.$name.'}', $val, $str);}}return $str;}public function evaluate_equations($str, $dataset) {$formula = $this->substitute_variables($str, $dataset);if ($error = qtype_calculated_find_formula_errors($formula)) {return $error;}return $str;}public function substitute_variables_and_eval($str, $dataset) {$formula = $this->substitute_variables($str, $dataset);if ($error = qtype_calculated_find_formula_errors($formula)) {return $error;}// Calculate the correct answer.if (empty($formula)) {$str = '';} else if ($formula === '*') {$str = '*';} else {$str = null;eval('$str = '.$formula.';');}return $str;}public function get_dataset_definitions($questionid, $newdatasets) {global $DB;// Get the existing datasets for this question.$datasetdefs = [];if (!empty($questionid)) {global $CFG;$sql = "SELECT i.*FROM {question_datasets} d, {question_dataset_definitions} iWHERE d.question = ? AND d.datasetdefinition = i.idORDER BY i.id";if ($records = $DB->get_records_sql($sql, [$questionid])) {foreach ($records as $r) {$datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r;}}}foreach ($newdatasets as $dataset) {if (!$dataset) {continue; // The no dataset case...}if (!isset($datasetdefs[$dataset])) {// Make new datasetdef.list($type, $category, $name) = explode('-', $dataset, 3);$datasetdef = new stdClass();$datasetdef->type = $type;$datasetdef->name = $name;$datasetdef->category = $category;$datasetdef->itemcount = 0;$datasetdef->options = 'uniform:1.0:10.0:1';$datasetdefs[$dataset] = clone($datasetdef);}}return $datasetdefs;}public function save_dataset_definitions($form) {global $DB;// Save synchronize.if (empty($form->dataset)) {$form->dataset = [];}// Save datasets.$datasetdefinitions = $this->get_dataset_definitions($form->id, $form->dataset);$tmpdatasets = array_flip($form->dataset);$defids = array_keys($datasetdefinitions);foreach ($defids as $defid) {$datasetdef = &$datasetdefinitions[$defid];if (isset($datasetdef->id)) {if (!isset($tmpdatasets[$defid])) {// This dataset is not used any more, delete it.$DB->delete_records('question_datasets',['question' => $form->id, 'datasetdefinition' => $datasetdef->id]);if ($datasetdef->category == 0) {// Question local dataset.$DB->delete_records('question_dataset_definitions',['id' => $datasetdef->id]);$DB->delete_records('question_dataset_items',['definition' => $datasetdef->id]);}}// This has already been saved or just got deleted.unset($datasetdefinitions[$defid]);continue;}$datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);if (0 != $datasetdef->category) {// We need to look for already existing datasets in the category.// First creating the datasetdefinition above// then we can manage to automatically take care of some possible realtime concurrence.if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions','type = ? AND name = ? AND category = ? AND id < ?ORDER BY id DESC',[$datasetdef->type, $datasetdef->name,$datasetdef->category, $datasetdef->id])) {while ($olderdatasetdef = array_shift($olderdatasetdefs)) {$DB->delete_records('question_dataset_definitions',['id' => $datasetdef->id]);$datasetdef = $olderdatasetdef;}}}// Create relation to this dataset.$questiondataset = new stdClass();$questiondataset->question = $form->id;$questiondataset->datasetdefinition = $datasetdef->id;$DB->insert_record('question_datasets', $questiondataset);unset($datasetdefinitions[$defid]);}// Remove local obsolete datasets as well as relations// to datasets in other categories.if (!empty($datasetdefinitions)) {foreach ($datasetdefinitions as $def) {$DB->delete_records('question_datasets',['question' => $form->id, 'datasetdefinition' => $def->id]);if ($def->category == 0) { // Question local dataset.$DB->delete_records('question_dataset_definitions',['id' => $def->id]);$DB->delete_records('question_dataset_items',['definition' => $def->id]);}}}}/** This function create a copy of the datasets (definition and dataitems)* from the preceding question if they remain in the new question* otherwise its create the datasets that have been added as in the* save_dataset_definitions()*/public function save_as_new_dataset_definitions($form, $initialid) {global $CFG, $DB;// Get the datasets from the intial question.$datasetdefinitions = $this->get_dataset_definitions($initialid, $form->dataset);// Param $tmpdatasets contains those of the new question.$tmpdatasets = array_flip($form->dataset);$defids = array_keys($datasetdefinitions);// New datasets.foreach ($defids as $defid) {$datasetdef = &$datasetdefinitions[$defid];if (isset($datasetdef->id)) {// This dataset exist in the initial question.if (!isset($tmpdatasets[$defid])) {// Do not exist in the new question so ignore.unset($datasetdefinitions[$defid]);continue;}// Create a copy but not for category one.if (0 == $datasetdef->category) {$olddatasetid = $datasetdef->id;$olditemcount = $datasetdef->itemcount;$datasetdef->itemcount = 0;$datasetdef->id = $DB->insert_record('question_dataset_definitions',$datasetdef);// Copy the dataitems.$olditems = $this->get_database_dataset_items($olddatasetid);if (count($olditems) > 0) {$itemcount = 0;foreach ($olditems as $item) {$item->definition = $datasetdef->id;$DB->insert_record('question_dataset_items', $item);$itemcount++;}// Update item count to olditemcount if// at least this number of items has been recover from the database.if ($olditemcount <= $itemcount) {$datasetdef->itemcount = $olditemcount;} else {$datasetdef->itemcount = $itemcount;}$DB->update_record('question_dataset_definitions', $datasetdef);} // End of copy the dataitems.}// End of copy the datasetdef.// Create relation to the new question with this// copy as new datasetdef from the initial question.$questiondataset = new stdClass();$questiondataset->question = $form->id;$questiondataset->datasetdefinition = $datasetdef->id;$DB->insert_record('question_datasets', $questiondataset);unset($datasetdefinitions[$defid]);continue;}// End of datasetdefs from the initial question.// Really new one code similar to save_dataset_definitions().$datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);if (0 != $datasetdef->category) {// We need to look for already existing// datasets in the category.// By first creating the datasetdefinition above we// can manage to automatically take care of// some possible realtime concurrence.if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions',"type = ? AND " . $DB->sql_equal('name', '?') . " AND category = ? AND id < ?ORDER BY id DESC",[$datasetdef->type, $datasetdef->name,$datasetdef->category, $datasetdef->id])) {while ($olderdatasetdef = array_shift($olderdatasetdefs)) {$DB->delete_records('question_dataset_definitions',['id' => $datasetdef->id]);$datasetdef = $olderdatasetdef;}}}// Create relation to this dataset.$questiondataset = new stdClass();$questiondataset->question = $form->id;$questiondataset->datasetdefinition = $datasetdef->id;$DB->insert_record('question_datasets', $questiondataset);unset($datasetdefinitions[$defid]);}// Remove local obsolete datasets as well as relations// to datasets in other categories.if (!empty($datasetdefinitions)) {foreach ($datasetdefinitions as $def) {$DB->delete_records('question_datasets',['question' => $form->id, 'datasetdefinition' => $def->id]);if ($def->category == 0) { // Question local dataset.$DB->delete_records('question_dataset_definitions',['id' => $def->id]);$DB->delete_records('question_dataset_items',['definition' => $def->id]);}}}}// Dataset functionality.public function pick_question_dataset($question, $datasetitem) {// Select a dataset in the following format:// an array indexed by the variable names (d.name) pointing to the value// to be substituted.global $CFG, $DB;if (!$dataitems = $DB->get_records_sql("SELECT i.id, d.name, i.valueFROM {question_dataset_definitions} d,{question_dataset_items} i,{question_datasets} qWHERE q.question = ?AND q.datasetdefinition = d.idAND d.id = i.definitionAND i.itemnumber = ?ORDER BY i.id DESC ", [$question->id, $datasetitem])) {$a = new stdClass();$a->id = $question->id;$a->item = $datasetitem;throw new \moodle_exception('cannotgetdsfordependent', 'question', '', $a);}$dataset = [];foreach ($dataitems as $id => $dataitem) {if (!isset($dataset[$dataitem->name])) {$dataset[$dataitem->name] = $dataitem->value;}}return $dataset;}public function dataset_options_from_database($form, $name, $prefix = '',$langfile = 'qtype_calculated') {global $CFG, $DB;$type = 1; // Only type = 1 (i.e. old 'LITERAL') has ever been used.// First options - it is not a dataset...$options['0'] = get_string($prefix.'nodataset', $langfile);// New question no local.if (!isset($form->id) || $form->id == 0) {$key = "{$type}-0-{$name}";$options[$key] = get_string($prefix."newlocal{$type}", $langfile);$currentdatasetdef = new stdClass();$currentdatasetdef->type = '0';} else {// Construct question local options.$sql = "SELECT a.*FROM {question_dataset_definitions} a, {question_datasets} bWHERE a.id = b.datasetdefinition AND a.type = '1' AND b.question = ? AND " . $DB->sql_equal('a.name', '?');$currentdatasetdef = $DB->get_record_sql($sql, [$form->id, $name]);if (!$currentdatasetdef) {$currentdatasetdef = new stdClass();$currentdatasetdef->type = '0';}$key = "{$type}-0-{$name}";if ($currentdatasetdef->type == $type&& $currentdatasetdef->category == 0) {$options[$key] = get_string($prefix."keptlocal{$type}", $langfile);} else {$options[$key] = get_string($prefix."newlocal{$type}", $langfile);}}// Construct question category options.$categorydatasetdefs = $DB->get_records_sql("SELECT b.question, a.*FROM {question_datasets} b,{question_dataset_definitions} aWHERE a.id = b.datasetdefinitionAND a.type = '1'AND a.category = ?AND " . $DB->sql_equal('a.name', '?'), [$form->category, $name]);$type = 1;$key = "{$type}-{$form->category}-{$name}";if (!empty($categorydatasetdefs)) {// There is at least one with the same name.if (isset($form->id) && isset($categorydatasetdefs[$form->id])) {// It is already used by this question.$options[$key] = get_string($prefix."keptcategory{$type}", $langfile);} else {$options[$key] = get_string($prefix."existingcategory{$type}", $langfile);}} else {$options[$key] = get_string($prefix."newcategory{$type}", $langfile);}// All done!return [$options, $currentdatasetdef->type? "{$currentdatasetdef->type}-{$currentdatasetdef->category}-{$name}": ''];}/*** Find the names of all datasets mentioned in a piece of question content like the question text.* @param $text the text to analyse.* @return array with dataset name for both key and value.*/public function find_dataset_names($text) {preg_match_all(self::PLACEHODLER_REGEX, $text, $matches);return array_combine($matches[1], $matches[1]);}/*** Find all the formulas in a bit of text.** For example, called with "What is {a} plus {b}? (Hint, it is not {={a}*{b}}.)" this* returns ['{a}*{b}'].** @param $text text to analyse.* @return array where they keys an values are the formulas.*/public function find_formulas($text) {preg_match_all(self::FORMULAS_IN_TEXT_REGEX, $text, $matches);return array_combine($matches[1], $matches[1]);}/*** This function retrieve the item count of the available category shareable* wild cards that is added as a comment displayed when a wild card with* the same name is displayed in datasetdefinitions_form.php*/public function get_dataset_definitions_category($form) {global $CFG, $DB;$datasetdefs = [];$lnamemax = 30;if (!empty($form->category)) {$sql = "SELECT i.*, d.*FROM {question_datasets} d, {question_dataset_definitions} iWHERE i.id = d.datasetdefinition AND i.category = ?";if ($records = $DB->get_records_sql($sql, [$form->category])) {foreach ($records as $r) {if (!isset ($datasetdefs["{$r->name}"])) {$datasetdefs["{$r->name}"] = $r->itemcount;}}}}return $datasetdefs;}/*** This function build a table showing the available category shareable* wild cards, their name, their definition (Min, Max, Decimal) , the item count* and the name of the question where they are used.* This table is intended to be add before the question text to help the user use* these wild cards*/public function print_dataset_definitions_category($form) {global $CFG, $DB;$datasetdefs = [];$lnamemax = 22;$namestr = get_string('name');$rangeofvaluestr = get_string('minmax', 'qtype_calculated');$questionusingstr = get_string('usedinquestion', 'qtype_calculated');$itemscountstr = get_string('itemscount', 'qtype_calculated');$text = '';if (!empty($form->category)) {list($category) = explode(',', $form->category);$sql = "SELECT i.*, d.*FROM {question_datasets} d,{question_dataset_definitions} iWHERE i.id = d.datasetdefinitionAND i.category = ?";if ($records = $DB->get_records_sql($sql, [$category])) {foreach ($records as $r) {$sql1 = "SELECT q.*FROM {question} qWHERE q.id = ?";if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"])) {$datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r;}if ($questionb = $DB->get_records_sql($sql1, [$r->question])) {if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question])) {$datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question] = new stdClass();}$datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question]->name =$questionb[$r->question]->name;}}}}if (!empty ($datasetdefs)) {$text = "<table width=\"100%\" border=\"1\"><tr><th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">{$namestr}</th><th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">{$rangeofvaluestr}</th><th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">{$itemscountstr}</th><th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">{$questionusingstr}</th></tr>";foreach ($datasetdefs as $datasetdef) {list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4);$text .= "<tr><td valign=\"top\" align=\"center\">{$datasetdef->name}</td><td align=\"center\" valign=\"top\">{$min} <strong>-</strong> $max</td><td align=\"right\" valign=\"top\">{$datasetdef->itemcount}  </td><td align=\"left\">";foreach ($datasetdef->questions as $qu) {// Limit the name length displayed.$questionname = $this->get_short_question_name($qu->name, $lnamemax);$text .= "    {$questionname} <br/>";}$text .= "</td></tr>";}$text .= "</table>";} else {$text .= get_string('nosharedwildcard', 'qtype_calculated');}return $text;}/*** This function shortens a question name if it exceeds the character limit.** @param string $stringtoshorten the string to be shortened.* @param int $characterlimit the character limit.* @return string*/public function get_short_question_name($stringtoshorten, $characterlimit) {if (!empty($stringtoshorten)) {$returnstring = format_string($stringtoshorten);if (strlen($returnstring) > $characterlimit) {$returnstring = shorten_text($returnstring, $characterlimit, true);}return $returnstring;} else {return '';}}/*** This function build a table showing the available category shareable* wild cards, their name, their definition (Min, Max, Decimal) , the item count* and the name of the question where they are used.* This table is intended to be add before the question text to help the user use* these wild cards*/public function print_dataset_definitions_category_shared($question, $datasetdefsq) {global $CFG, $DB;$datasetdefs = [];$lnamemax = 22;$namestr = get_string('name', 'quiz');$rangeofvaluestr = get_string('minmax', 'qtype_calculated');$questionusingstr = get_string('usedinquestion', 'qtype_calculated');$itemscountstr = get_string('itemscount', 'qtype_calculated');$text = '';if (!empty($question->category)) {list($category) = explode(',', $question->category);$sql = "SELECT i.*, d.*FROM {question_datasets} d, {question_dataset_definitions} iWHERE i.id = d.datasetdefinition AND i.category = ?";if ($records = $DB->get_records_sql($sql, [$category])) {foreach ($records as $r) {$key = "{$r->type}-{$r->category}-{$r->name}";$sql1 = "SELECT q.*FROM {question} qWHERE q.id = ?";if (!isset($datasetdefs[$key])) {$datasetdefs[$key] = $r;}if ($questionb = $DB->get_records_sql($sql1, [$r->question])) {$datasetdefs[$key]->questions[$r->question] = new stdClass();$datasetdefs[$key]->questions[$r->question]->name =$questionb[$r->question]->name;$datasetdefs[$key]->questions[$r->question]->id =$questionb[$r->question]->id;}}}}if (!empty ($datasetdefs)) {$text = "<table width=\"100%\" border=\"1\"><tr><th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">{$namestr}</th>";$text .= "<th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">{$itemscountstr}</th>";$text .= "<th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">  {$questionusingstr}   </th>";$text .= "<th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">Quiz</th>";$text .= "<th style=\"white-space:nowrap;\" class=\"header\"scope=\"col\">Attempts</th></tr>";foreach ($datasetdefs as $datasetdef) {list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4);$count = count($datasetdef->questions);$text .= "<tr><td style=\"white-space:nowrap;\" valign=\"top\"align=\"center\" rowspan=\"{$count}\"> {$datasetdef->name} </td><td align=\"right\" valign=\"top\"rowspan=\"{$count}\">{$datasetdef->itemcount}</td>";$line = 0;foreach ($datasetdef->questions as $qu) {// Limit the name length displayed.$questionname = $this->get_short_question_name($qu->name, $lnamemax);if ($line) {$text .= "<tr>";}$line++;$text .= "<td align=\"left\" style=\"white-space:nowrap;\">{$questionname}</td>";// TODO MDL-43779 should not have quiz-specific code here.$sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_bank_usage_sql() . ') questioncount';$nbofquiz = $DB->count_records_sql($sql, [$qu->id, 'mod_quiz', 'slot']);$sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_attempt_usage_sql() . ') attemptcount';$nbofattempts = $DB->count_records_sql($sql, [$qu->id]);if ($nbofquiz > 0) {$text .= "<td align=\"center\">{$nbofquiz}</td>";$text .= "<td align=\"center\">{$nbofattempts}";} else {$text .= "<td align=\"center\">0</td>";$text .= "<td align=\"left\"><br/>";}$text .= "</td></tr>";}}$text .= "</table>";} else {$text .= get_string('nosharedwildcard', 'qtype_calculated');}return $text;}public function get_virtual_qtype() {return question_bank::get_qtype('numerical');}public function get_possible_responses($questiondata) {$responses = [];$virtualqtype = $this->get_virtual_qtype();$unit = $virtualqtype->get_default_numerical_unit($questiondata);$tolerancetypes = $this->tolerance_types();$starfound = false;foreach ($questiondata->options->answers as $aid => $answer) {$responseclass = $answer->answer;if ($responseclass === '*') {$starfound = true;} else {$a = new stdClass();$a->answer = $virtualqtype->add_unit($questiondata, $responseclass, $unit);$a->tolerance = $answer->tolerance;$a->tolerancetype = $tolerancetypes[$answer->tolerancetype];$responseclass = get_string('answerwithtolerance', 'qtype_calculated', $a);}$responses[$aid] = new question_possible_response($responseclass,$answer->fraction);}if (!$starfound) {$responses[0] = new question_possible_response(get_string('didnotmatchanyanswer', 'question'), 0);}$responses[null] = question_possible_response::no_response();return [$questiondata->id => $responses];}public function move_files($questionid, $oldcontextid, $newcontextid) {$fs = get_file_storage();parent::move_files($questionid, $oldcontextid, $newcontextid);$this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);$this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);}protected function delete_files($questionid, $contextid) {$fs = get_file_storage();parent::delete_files($questionid, $contextid);$this->delete_files_in_answers($questionid, $contextid);$this->delete_files_in_hints($questionid, $contextid);}}function qtype_calculated_calculate_answer($formula, $individualdata,$tolerance, $tolerancetype, $answerlength, $answerformat = '1', $unit = '') {// The return value has these properties: .// ->answer the correct answer// ->min the lower bound for an acceptable response// ->max the upper bound for an accetpable response.$calculated = new stdClass();// Exchange formula variables with the correct values...$answer = question_bank::get_qtype('calculated')->substitute_variables_and_eval($formula, $individualdata);if (!is_numeric($answer)) {// Something went wrong, so just return NaN.$calculated->answer = NAN;return $calculated;} else if (is_nan($answer) || is_infinite($answer)) {$calculated->answer = $answer;return $calculated;}if ('1' == $answerformat) { // Answer is to have $answerlength decimals.// Decimal places.$calculated->answer = sprintf('%.' . $answerlength . 'F', $answer);} else if ($answer) { // Significant figures does only apply if the result is non-zero.// Convert to positive answer...if ($answer < 0) {$answer = -$answer;$sign = '-';} else {$sign = '';}// Determine the format 0.[1-9][0-9]* for the answer...$p10 = 0;while ($answer < 1) {--$p10;$answer *= 10;}while ($answer >= 1) {++$p10;$answer /= 10;}// ... and have the answer rounded of to the correct length.$answer = round($answer, $answerlength);// If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.if ($answer >= 1) {++$p10;$answer /= 10;}// Have the answer written on a suitable format:// either scientific or plain numeric.if (-2 > $p10 || 4 < $p10) {// Use scientific format.$exponent = 'e'.--$p10;$answer *= 10;if (1 == $answerlength) {$calculated->answer = $sign.$answer.$exponent;} else {// Attach additional zeros at the end of $answer.$answer .= (1 == strlen($answer) ? '.' : ''). '00000000000000000000000000000000000000000x';$calculated->answer = $sign.substr($answer, 0, $answerlength + 1).$exponent;}} else {// Stick to plain numeric format.$answer *= "1e{$p10}";if (0.1 <= $answer / "1e{$answerlength}") {$calculated->answer = $sign.$answer;} else {// Could be an idea to add some zeros here.$answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : ''). '00000000000000000000000000000000000000000x';$oklen = $answerlength + ($p10 < 1 ? 2 - $p10 : 1);$calculated->answer = $sign.substr($answer, 0, $oklen);}}} else {$calculated->answer = 0.0;}if ($unit != '') {$calculated->answer = $calculated->answer . ' ' . $unit;}// Return the result.return $calculated;}/*** Validate a forumula.* @param string $formula the formula to validate.* @return string|boolean false if there are no problems. Otherwise a string error message.*/function qtype_calculated_find_formula_errors($formula) {foreach (['//', '/*', '#', '<?', '?>'] as $commentstart) {if (strpos($formula, $commentstart) !== false) {return get_string('illegalformulasyntax', 'qtype_calculated', $commentstart);}}// Validates the formula submitted from the question edit page.// Returns false if everything is alright// otherwise it constructs an error message.// Strip away dataset names. Use 1.0 to remove valid names, so illegal names can be identified later.$formula = preg_replace(qtype_calculated::PLACEHODLER_REGEX, '1.0', $formula);// Strip away empty space and lowercase it.$formula = strtolower(str_replace(' ', '', $formula));// Only mathematical operators are supported. Bitwise operators are not safe.// Note: In this context, ^ is a bitwise operator (exponents are represented by **).$safeoperatorchar = '-+/*%>:\~<?=!';$operatorornumber = "[{$safeoperatorchar}.0-9eE]";// Validate mathematical functions in formula.while (preg_match("~(^|[{$safeoperatorchar},(])([a-z0-9_]*)" ."\\(({$operatorornumber}+(,{$operatorornumber}+((,{$operatorornumber}+)+)?)?)?\\)~",$formula, $regs)) {switch ($regs[2]) {// Simple parenthesis.case '':if ((isset($regs[4]) && $regs[4]) || strlen($regs[3]) == 0) {return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);}break;// Zero argument functions.case 'pi':if (array_key_exists(3, $regs)) {return get_string('functiontakesnoargs', 'qtype_calculated', $regs[2]);}break;// Single argument functions (the most common case).case 'abs': case 'acos': case 'acosh': case 'asin': case 'asinh':case 'atan': case 'atanh': case 'bindec': case 'ceil': case 'cos':case 'cosh': case 'decbin': case 'decoct': case 'deg2rad':case 'exp': case 'expm1': case 'floor': case 'is_finite':case 'is_infinite': case 'is_nan': case 'log10': case 'log1p':case 'octdec': case 'rad2deg': case 'sin': case 'sinh': case 'sqrt':case 'tan': case 'tanh':if (!empty($regs[4]) || empty($regs[3])) {return get_string('functiontakesonearg', 'qtype_calculated', $regs[2]);}break;// Functions that take one or two arguments.case 'log': case 'round':if (!empty($regs[5]) || empty($regs[3])) {return get_string('functiontakesoneortwoargs', 'qtype_calculated', $regs[2]);}break;// Functions that must have two arguments.case 'atan2': case 'fmod': case 'pow':if (!empty($regs[5]) || empty($regs[4])) {return get_string('functiontakestwoargs', 'qtype_calculated', $regs[2]);}break;// Functions that take two or more arguments.case 'min': case 'max':if (empty($regs[4])) {return get_string('functiontakesatleasttwo', 'qtype_calculated', $regs[2]);}break;default:return get_string('unsupportedformulafunction', 'qtype_calculated', $regs[2]);}// Exchange the function call with '1.0' and then check for// another function call...if ($regs[1]) {// The function call is proceeded by an operator.$formula = str_replace($regs[0], $regs[1] . '1.0', $formula);} else {// The function call starts the formula.$formula = preg_replace('~^' . preg_quote($regs[2], '~') . '\([^)]*\)~', '1.0', $formula);}}if (preg_match("~[^{$safeoperatorchar}.0-9eE]+~", $formula, $regs)) {return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);} else {// Formula just might be valid.return false;}}/*** Validate all the forumulas in a bit of text.* @param string $text the text in which to validate the formulas.* @return string|boolean false if there are no problems. Otherwise a string error message.*/function qtype_calculated_find_formula_errors_in_text($text) {$formulas = question_bank::get_qtype('calculated')->find_formulas($text);$errors = [];foreach ($formulas as $match) {$error = qtype_calculated_find_formula_errors($match);if ($error) {$errors[] = $error;}}if ($errors) {return implode(' ', $errors);}return false;}