Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

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