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