Merge branch 'MDL-68749-310-2' of git://github.com/mickhawkins/moodle into MOODLE_310...
[moodle.git] / question / type / calculated / questiontype.php
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/>.
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  */
27 defined('MOODLE_INTERNAL') || die();
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');
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:]][^>} <`{"\']*';
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 . ')\}~';
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 . '\}[^{}]*)*)\}~';
56     const MAX_DATASET_ITEMS = 100;
58     public $wizardpagesnumber = 3;
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         }
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         }
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         }
95         $question->hints = $DB->get_records('question_hints',
96                 array('questionid' => $question->id), 'id ASC');
98         if (isset($question->export_process)&&$question->export_process) {
99             $question->options->datasets = $this->get_datasets_for_export($question);
100         }
101         return true;
102     }
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     }
146     public function save_question_options($question) {
147         global $CFG, $DB;
149         // Make it impossible to save bad formulas anywhere.
150         $this->validate_question_data($question);
152         // The code is used for calculated, calculatedsimple and calculatedmulti qtypes.
153         $context = $question->context;
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;
174         foreach (array('correctfeedback', 'partiallycorrectfeedback',
175                 'incorrectfeedback') as $feedbackname) {
176             $options->$feedbackname = '';
177             $feedbackformat = $feedbackname . 'format';
178             $options->$feedbackformat = 0;
179         }
181         if ($update) {
182             $DB->update_record('question_calculated_options', $options);
183         } else {
184             $DB->insert_record('question_calculated_options', $options);
185         }
187         // Get old versions of the objects.
188         $oldanswers = $DB->get_records('question_answers',
189                 array('question' => $question->id), 'id ASC');
191         $oldoptions = $DB->get_records('question_calculated',
192                 array('question' => $question->id), 'answer ASC');
194         // Save the units.
195         $virtualqtype = $this->get_virtual_qtype();
197         $result = $virtualqtype->save_units($question);
198         if (isset($result->error)) {
199             return $result;
200         } else {
201             $units = $result->units;
202         }
204         foreach ($question->answer as $key => $answerdata) {
205             if (trim($answerdata) == '') {
206                 continue;
207             }
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             }
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'];
225             $DB->update_record("question_answers", $answer);
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]);
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         }
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         }
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         }
262         $result = $virtualqtype->save_unit_options($question);
263         if (isset($result->error)) {
264             return $result;
265         }
267         $this->save_hints($question);
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     }
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     }
335     protected function initialise_question_instance(question_definition $question, $questiondata) {
336         parent::initialise_question_instance($question, $questiondata);
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         }
346         $question->synchronised = $questiondata->options->synchronize;
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);
355         $question->datasetloader = new qtype_calculated_dataset_loader($questiondata->id);
356     }
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;
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         }
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     }
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;
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         }
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         }
419         return $mform;
420     }
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;
438             case 'datasetdefinitions':
439                 echo $OUTPUT->heading_with_help(
440                         get_string('choosedatasetproperties', 'qtype_calculated'),
441                         'questiondatasets', 'qtype_calculated');
442                 break;
444             case 'datasetitems':
445                 echo $OUTPUT->heading_with_help(get_string('editdatasets', 'qtype_calculated'),
446                         'questiondatasets', 'qtype_calculated');
447                 break;
448         }
450         $mform->display();
451     }
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     }
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     }
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'].
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         }
496         foreach ($question->answer as $key => $answer) {
497             $this->validate_answer($answer);
498             $this->validate_text($question->feedback[$key]['text']);
499         }
500     }
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     }
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') {
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') {
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);
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     }
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;
615         if ($this->wizardpagesnumber() == 1 || $question->qtype == 'calculatedsimple') {
616             $question = parent::save_question($question, $form);
617             return $question;
618         }
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.
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                 }
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     }
694     public function delete_question($questionid, $contextid) {
695         global $DB;
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));
714         parent::delete_question($questionid, $contextid);
715     }
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     }
726     public function supports_dataset_item_generation() {
727         // Calculated support generation of randomly distributed number data.
728         return true;
729     }
731     public function custom_generator_tools_part($mform, $idx, $j) {
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);
741         $precisionoptions = range(0, 10);
742         $mform->addElement('select', "calclength[{$idx}]",
743                 get_string('calclength', 'qtype_calculated'), $precisionoptions);
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     }
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     }
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'));
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     }
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.
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         }
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     }
840     public function save_question_calculated($question, $fromform) {
841         global $DB;
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     }
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     }
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         }
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);
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         }
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));
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             }
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         }
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);
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);
1044         } else {
1045             print_error('disterror', 'question', '', $regs[1]);
1046         }
1047         return '';
1048     }
1050     public function comment_header($question) {
1051         $strheader = '';
1052         $delimiter = '';
1054         $answers = $question->options->answers;
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     }
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         }
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     }
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     }
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.
1153         list($options, $selected) = $this->dataset_options_from_database(
1154                 $form, $name, '', 'qtype_calculated');
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     }
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     }
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.
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     }
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     }
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     }
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         }
1260         foreach ($newdatasets as $dataset) {
1261             if (!$dataset) {
1262                 continue; // The no dataset case...
1263             }
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     }
1280     public function save_dataset_definitions($form) {
1281         global $DB;
1282         // Save synchronize.
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             }
1311             $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef);
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.
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))) {
1324                     while ($olderdatasetdef = array_shift($olderdatasetdefs)) {
1325                         $DB->delete_records('question_dataset_definitions',
1326                                 array('id' => $datasetdef->id));
1327                         $datasetdef = $olderdatasetdef;
1328                     }
1329                 }
1330             }
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         }
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));
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);
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))) {
1427                     while ($olderdatasetdef = array_shift($olderdatasetdefs)) {
1428                         $DB->delete_records('question_dataset_definitions',
1429                                 array('id' => $datasetdef->id));
1430                         $datasetdef = $olderdatasetdef;
1431                     }
1432                 }
1433             }
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         }
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));
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     }
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     }
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     }
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     }
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     }
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     }
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)) {
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     }
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     }
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      */
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)) {
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                     }
1777                     $text .= "</td></tr>";
1778                 }
1779             }
1780             $text .= "</table>";
1781         } else {
1782             $text .= get_string('nosharedwildcard', 'qtype_calculated');
1783         }
1784         return $text;
1785     }
1787     public function get_virtual_qtype() {
1788         return question_bank::get_qtype('numerical');
1789     }
1791     public function get_possible_responses($questiondata) {
1792         $responses = array();
1794         $virtualqtype = $this->get_virtual_qtype();
1795         $unit = $virtualqtype->get_default_numerical_unit($questiondata);
1797         $tolerancetypes = $this->tolerance_types();
1799         $starfound = false;
1800         foreach ($questiondata->options->answers as $aid => $answer) {
1801             $responseclass = $answer->answer;
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];
1811                 $responseclass = get_string('answerwithtolerance', 'qtype_calculated', $a);
1812             }
1814             $responses[$aid] = new question_possible_response($responseclass,
1815                     $answer->fraction);
1816         }
1818         if (!$starfound) {
1819             $responses[0] = new question_possible_response(
1820             get_string('didnotmatchanyanswer', 'question'), 0);
1821         }
1823         $responses[null] = question_possible_response::no_response();
1825         return array($questiondata->id => $responses);
1826     }
1828     public function move_files($questionid, $oldcontextid, $newcontextid) {
1829         $fs = get_file_storage();
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     }
1836     protected function delete_files($questionid, $contextid) {
1837         $fs = get_file_storage();
1839         parent::delete_files($questionid, $contextid);
1840         $this->delete_files_in_answers($questionid, $contextid);
1841         $this->delete_files_in_hints($questionid, $contextid);
1842     }
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     }
1861     if ('1' == $answerformat) { // Answer is to have $answerlength decimals.
1862         // Decimal places.
1863         $calculated->answer = sprintf('%.' . $answerlength . 'F', $answer);
1865     } else if ($answer) { // Significant figures does only apply if the result is non-zero.
1867         // Convert to positive answer...
1868         if ($answer < 0) {
1869             $answer = -$answer;
1870             $sign = '-';
1871         } else {
1872             $sign = '';
1873         }
1875         // Determine the format 0.[1-9][0-9]* for the answer...
1876         $p10 = 0;
1877         while ($answer < 1) {
1878             --$p10;
1879             $answer *= 10;
1880         }
1881         while ($answer >= 1) {
1882             ++$p10;
1883             $answer /= 10;
1884         }
1885         // ... and have the answer rounded of to the correct length.
1886         $answer = round($answer, $answerlength);
1888         // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
1889         if ($answer >= 1) {
1890             ++$p10;
1891             $answer /= 10;
1892         }
1894         // Have the answer written on a suitable format:
1895         // either scientific or plain numeric.
1896         if (-2 > $p10 || 4 < $p10) {
1897             // Use scientific format.
1898             $exponent = 'e'.--$p10;
1899             $answer *= 10;
1900             if (1 == $answerlength) {
1901                 $calculated->answer = $sign.$answer.$exponent;
1902             } else {
1903                 // Attach additional zeros at the end of $answer.
1904                 $answer .= (1 == strlen($answer) ? '.' : '')
1905                     . '00000000000000000000000000000000000000000x';
1906                 $calculated->answer = $sign
1907                     .substr($answer, 0, $answerlength +1).$exponent;
1908             }
1909         } else {
1910             // Stick to plain numeric format.
1911             $answer *= "1e{$p10}";
1912             if (0.1 <= $answer / "1e{$answerlength}") {
1913                 $calculated->answer = $sign.$answer;
1914             } else {
1915                 // Could be an idea to add some zeros here.
1916                 $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
1917                     . '00000000000000000000000000000000000000000x';
1918                 $oklen = $answerlength + ($p10 < 1 ? 2-$p10 : 1);
1919                 $calculated->answer = $sign.substr($answer, 0, $oklen);
1920             }
1921         }
1923     } else {
1924         $calculated->answer = 0.0;
1925     }
1926     if ($unit != '') {
1927             $calculated->answer = $calculated->answer . ' ' . $unit;
1928     }
1930     // Return the result.
1931     return $calculated;
1935 /**
1936  * Validate a forumula.
1937  * @param string $formula the formula to validate.
1938  * @return string|boolean false if there are no problems. Otherwise a string error message.
1939  */
1940 function qtype_calculated_find_formula_errors($formula) {
1941     foreach (['//', '/*', '#', '<?', '?>'] as $commentstart) {
1942         if (strpos($formula, $commentstart) !== false) {
1943             return get_string('illegalformulasyntax', 'qtype_calculated', $commentstart);
1944         }
1945     }
1947     // Validates the formula submitted from the question edit page.
1948     // Returns false if everything is alright
1949     // otherwise it constructs an error message.
1950     // Strip away dataset names. Use 1.0 to catch illegal concatenation like {a}{b}.
1951     $formula = preg_replace(qtype_calculated::PLACEHODLER_REGEX, '1.0', $formula);
1953     // Strip away empty space and lowercase it.
1954     $formula = strtolower(str_replace(' ', '', $formula));
1956     $safeoperatorchar = '-+/*%>:^\~<?=&|!'; /* */
1957     $operatorornumber = "[{$safeoperatorchar}.0-9eE]";
1959     while (preg_match("~(^|[{$safeoperatorchar},(])([a-z0-9_]*)" .
1960             "\\(({$operatorornumber}+(,{$operatorornumber}+((,{$operatorornumber}+)+)?)?)?\\)~",
1961             $formula, $regs)) {
1962         switch ($regs[2]) {
1963             // Simple parenthesis.
1964             case '':
1965                 if ((isset($regs[4]) && $regs[4]) || strlen($regs[3]) == 0) {
1966                     return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);
1967                 }
1968                 break;
1970                 // Zero argument functions.
1971             case 'pi':
1972                 if (array_key_exists(3, $regs)) {
1973                     return get_string('functiontakesnoargs', 'qtype_calculated', $regs[2]);
1974                 }
1975                 break;
1977                 // Single argument functions (the most common case).
1978             case 'abs': case 'acos': case 'acosh': case 'asin': case 'asinh':
1979             case 'atan': case 'atanh': case 'bindec': case 'ceil': case 'cos':
1980             case 'cosh': case 'decbin': case 'decoct': case 'deg2rad':
1981             case 'exp': case 'expm1': case 'floor': case 'is_finite':
1982             case 'is_infinite': case 'is_nan': case 'log10': case 'log1p':
1983             case 'octdec': case 'rad2deg': case 'sin': case 'sinh': case 'sqrt':
1984             case 'tan': case 'tanh':
1985                 if (!empty($regs[4]) || empty($regs[3])) {
1986                     return get_string('functiontakesonearg', 'qtype_calculated', $regs[2]);
1987                 }
1988                 break;
1990                 // Functions that take one or two arguments.
1991             case 'log': case 'round':
1992                 if (!empty($regs[5]) || empty($regs[3])) {
1993                     return get_string('functiontakesoneortwoargs', 'qtype_calculated', $regs[2]);
1994                 }
1995                 break;
1997                 // Functions that must have two arguments.
1998             case 'atan2': case 'fmod': case 'pow':
1999                 if (!empty($regs[5]) || empty($regs[4])) {
2000                     return get_string('functiontakestwoargs', 'qtype_calculated', $regs[2]);
2001                 }
2002                 break;
2004                 // Functions that take two or more arguments.
2005             case 'min': case 'max':
2006                 if (empty($regs[4])) {
2007                     return get_string('functiontakesatleasttwo', 'qtype_calculated', $regs[2]);
2008                 }
2009                 break;
2011             default:
2012                 return get_string('unsupportedformulafunction', 'qtype_calculated', $regs[2]);
2013         }
2015         // Exchange the function call with '1.0' and then check for
2016         // another function call...
2017         if ($regs[1]) {
2018             // The function call is proceeded by an operator.
2019             $formula = str_replace($regs[0], $regs[1] . '1.0', $formula);
2020         } else {
2021             // The function call starts the formula.
2022             $formula = preg_replace('~^' . preg_quote($regs[2], '~') . '\([^)]*\)~', '1.0', $formula);
2023         }
2024     }
2026     if (preg_match("~[^{$safeoperatorchar}.0-9eE]+~", $formula, $regs)) {
2027         return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]);
2028     } else {
2029         // Formula just might be valid.
2030         return false;
2031     }
2034 /**
2035  * Validate all the forumulas in a bit of text.
2036  * @param string $text the text in which to validate the formulas.
2037  * @return string|boolean false if there are no problems. Otherwise a string error message.
2038  */
2039 function qtype_calculated_find_formula_errors_in_text($text) {
2040     $formulas = question_bank::get_qtype('calculated')->find_formulas($text);
2042     $errors = array();
2043     foreach ($formulas as $match) {
2044         $error = qtype_calculated_find_formula_errors($match);
2045         if ($error) {
2046             $errors[] = $error;
2047         }
2048     }
2050     if ($errors) {
2051         return implode(' ', $errors);
2052     }
2054     return false;