Proyectos de Subversion Moodle

Rev

| 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
 * Calculated question definition class.
19
 *
20
 * @package    qtype
21
 * @subpackage calculated
22
 * @copyright  2011 The Open University
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/questionbase.php');
30
require_once($CFG->dirroot . '/question/type/numerical/question.php');
31
require_once($CFG->dirroot . '/question/type/calculated/questiontype.php');
32
 
33
/**
34
 * Represents a calculated question.
35
 *
36
 * @copyright  2011 The Open University
37
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38
 */
39
class qtype_calculated_question extends qtype_numerical_question
40
        implements qtype_calculated_question_with_expressions {
41
 
42
    /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
43
    public $datasetloader;
44
 
45
    /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
46
    public $vs;
47
 
48
    /**
49
     * @var bool wheter the dataset item to use should be chose based on attempt
50
     * start time, rather than randomly.
51
     */
52
    public $synchronised;
53
 
54
    public function start_attempt(question_attempt_step $step, $variant) {
55
        qtype_calculated_question_helper::start_attempt($this, $step, $variant);
56
        parent::start_attempt($step, $variant);
57
    }
58
 
59
    public function apply_attempt_state(question_attempt_step $step) {
60
        qtype_calculated_question_helper::apply_attempt_state($this, $step);
61
        parent::apply_attempt_state($step);
62
    }
63
 
64
    public function calculate_all_expressions() {
65
        $this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext);
66
        $this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback);
67
 
68
        foreach ($this->answers as $ans) {
69
            if ($ans->answer && $ans->answer !== '*') {
70
                $ans->answer = $this->vs->calculate($ans->answer,
71
                        $ans->correctanswerlength, $ans->correctanswerformat);
72
            }
73
            $ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback,
74
                        $ans->correctanswerlength, $ans->correctanswerformat);
75
        }
76
        // Replace expressions in hints referring MDL-36733.
77
        // Calculation through calculation() function in replace_expressions_in_text() function.
78
        // Validation through qtype_calculated_find_formula_errors() function in calculate() function.
79
        foreach ($this->hints as $hint) {
80
            $hint->hint = $this->vs->replace_expressions_in_text($hint->hint);
81
        }
82
    }
83
 
84
    public function get_num_variants() {
85
        return $this->datasetloader->get_number_of_items();
86
    }
87
 
88
    public function get_variants_selection_seed() {
89
        if (!empty($this->synchronised) &&
90
                $this->datasetloader->datasets_are_synchronised($this->category)) {
91
            return 'category' . $this->category;
92
        } else {
93
            return parent::get_variants_selection_seed();
94
        }
95
    }
96
 
97
    public function get_correct_response() {
98
        $answer = $this->get_correct_answer();
99
        if (!$answer) {
100
            return array();
101
        }
102
 
103
        $response = array('answer' => $this->vs->format_float($answer->answer,
104
            $answer->correctanswerlength, $answer->correctanswerformat));
105
 
106
        if ($this->has_separate_unit_field()) {
107
            $response['unit'] = $this->ap->get_default_unit();
108
        } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) {
109
            $response['answer'] = $this->ap->add_unit($response['answer']);
110
        }
111
 
112
        return $response;
113
    }
114
 
115
}
116
 
117
 
118
/**
119
 * This interface defines the method that a quetsion type must implement if it
120
 * is to work with {@link qtype_calculated_question_helper}.
121
 *
122
 * As well as this method, the class that implements this interface must have
123
 * fields
124
 * public $datasetloader; // of type qtype_calculated_dataset_loader
125
 * public $vs; // of type qtype_calculated_variable_substituter
126
 *
127
 * @copyright  2011 The Open University
128
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
129
 */
130
interface qtype_calculated_question_with_expressions {
131
    /**
132
     * Replace all the expression in the question definition with the values
133
     * computed from the selected dataset by calling $this->vs->calculate() and
134
     * $this->vs->replace_expressions_in_text() on the parts of the question
135
     * that require it.
136
     */
137
    public function calculate_all_expressions();
138
}
139
 
140
 
141
/**
142
 * Helper class for questions that use datasets. Works with the interface
143
 * {@link qtype_calculated_question_with_expressions} and the class
144
 * {@link qtype_calculated_dataset_loader} to set up the value of each variable
145
 * in start_attempt, and restore that in apply_attempt_state.
146
 *
147
 * @copyright  2011 The Open University
148
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
149
 */
150
abstract class qtype_calculated_question_helper {
151
    public static function start_attempt(
152
            qtype_calculated_question_with_expressions $question,
153
            question_attempt_step $step, $variant) {
154
 
155
        $question->vs = new qtype_calculated_variable_substituter(
156
                $question->datasetloader->get_values($variant),
157
                get_string('decsep', 'langconfig'));
158
        $question->calculate_all_expressions();
159
 
160
        foreach ($question->vs->get_values() as $name => $value) {
161
            $step->set_qt_var('_var_' . $name, $value);
162
        }
163
    }
164
 
165
    public static function apply_attempt_state(
166
            qtype_calculated_question_with_expressions $question, question_attempt_step $step) {
167
        $values = array();
168
        foreach ($step->get_qt_data() as $name => $value) {
169
            if (substr($name, 0, 5) === '_var_') {
170
                $values[substr($name, 5)] = $value;
171
            }
172
        }
173
 
174
        $question->vs = new qtype_calculated_variable_substituter(
175
                $values, get_string('decsep', 'langconfig'));
176
        $question->calculate_all_expressions();
177
    }
178
}
179
 
180
 
181
/**
182
 * This class is responsible for loading the dataset that a question needs from
183
 * the database.
184
 *
185
 * @copyright  2011 The Open University
186
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
187
 */
188
class qtype_calculated_dataset_loader {
189
    /** @var int the id of the question we are helping. */
190
    protected $questionid;
191
 
192
    /** @var int the id of the question we are helping. */
193
    protected $itemsavailable = null;
194
 
195
    /**
196
     * Constructor
197
     * @param int $questionid the question to load datasets for.
198
     */
199
    public function __construct($questionid) {
200
        $this->questionid = $questionid;
201
    }
202
 
203
    /**
204
     * Get the number of items (different values) in each dataset used by this
205
     * question. This is the minimum number of items in any dataset used by this
206
     * question.
207
     * @return int the number of items available.
208
     */
209
    public function get_number_of_items() {
210
        global $DB;
211
 
212
        if (is_null($this->itemsavailable)) {
213
            $this->itemsavailable = $DB->get_field_sql('
214
                    SELECT MIN(qdd.itemcount)
215
                      FROM {question_dataset_definitions} qdd
216
                      JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
217
                     WHERE qd.question = ?
218
                    ', array($this->questionid), MUST_EXIST);
219
        }
220
 
221
        return $this->itemsavailable;
222
    }
223
 
224
    /**
225
     * Actually query the database for the values.
226
     * @param int $itemnumber which set of values to load.
227
     * @return array name => value;
228
     */
229
    protected function load_values($itemnumber) {
230
        global $DB;
231
 
232
        return $DB->get_records_sql_menu('
233
                SELECT qdd.name, qdi.value
234
                  FROM {question_dataset_items} qdi
235
                  JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
236
                  JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
237
                 WHERE qd.question = ?
238
                   AND qdi.itemnumber = ?
239
                ', array($this->questionid, $itemnumber));
240
    }
241
 
242
    /**
243
     * Load a particular set of values for each dataset used by this question.
244
     * @param int $itemnumber which set of values to load.
245
     *      0 < $itemnumber <= {@link get_number_of_items()}.
246
     * @return array name => value.
247
     */
248
    public function get_values($itemnumber) {
249
        if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) {
250
            $a = new stdClass();
251
            $a->id = $this->questionid;
252
            $a->item = $itemnumber;
253
            throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a);
254
        }
255
 
256
        return $this->load_values($itemnumber);
257
    }
258
 
259
    public function datasets_are_synchronised($category) {
260
        global $DB;
261
        // We need to ensure that there are synchronised datasets, and that they
262
        // all use the right category.
263
        $categories = $DB->get_record_sql('
264
                SELECT MAX(qdd.category) AS max,
265
                       MIN(qdd.category) AS min
266
                  FROM {question_dataset_definitions} qdd
267
                  JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
268
                 WHERE qd.question = ?
269
                   AND qdd.category <> 0
270
            ', array($this->questionid));
271
 
272
        return $categories && $categories->max == $category && $categories->min == $category;
273
    }
274
}
275
 
276
 
277
/**
278
 * This class holds the current values of all the variables used by a calculated
279
 * question.
280
 *
281
 * It can compute formulae using those values, and can substitute equations
282
 * embedded in text.
283
 *
284
 * @copyright  2011 The Open University
285
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
286
 */
287
class qtype_calculated_variable_substituter {
288
 
289
    /** @var array variable name => value */
290
    protected $values;
291
 
292
    /** @var string character to use for the decimal point in displayed numbers. */
293
    protected $decimalpoint;
294
 
295
    /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
296
    protected $search;
297
 
298
    /**
299
     * @var array variable values, with negative numbers wrapped in (...).
300
     * Used by {@link substitute_values()}.
301
     */
302
    protected $safevalue;
303
 
304
    /**
305
     * @var array variable values, with negative numbers wrapped in (...).
306
     * Used by {@link substitute_values()}.
307
     */
308
    protected $prettyvalue;
309
 
310
    /**
311
     * Constructor
312
     * @param array $values variable name => value.
313
     */
314
    public function __construct(array $values, $decimalpoint) {
315
        $this->values = $values;
316
        $this->decimalpoint = $decimalpoint;
317
 
318
        // Prepare an array for {@link substitute_values()}.
319
        $this->search = array();
320
        foreach ($values as $name => $value) {
321
            if (!is_numeric($value)) {
322
                $a = new stdClass();
323
                $a->name = '{' . $name . '}';
324
                $a->value = $value;
325
                throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
326
            }
327
 
328
            $this->search[] = '{' . $name . '}';
329
            $this->safevalue[] = '(' . $value . ')';
330
            $this->prettyvalue[] = $this->format_float($value);
331
        }
332
    }
333
 
334
    /**
335
     * Display a float properly formatted with a certain number of decimal places.
336
     * @param number $x the number to format
337
     * @param int $length restrict to this many decimal places or significant
338
     *      figures. If null, the number is not rounded.
339
     * @param int format 1 => decimalformat, 2 => significantfigures.
340
     * @return string formtted number.
341
     */
342
    public function format_float($x, $length = null, $format = null) {
343
        if (is_nan($x)) {
344
            $x = 'NAN';
345
        } else if (is_infinite($x)) {
346
            $x = ($x < 0) ? '-INF' : 'INF';
347
        } else if (!is_null($length) && !is_null($format)) {
348
            if ($format == '1' ) { // Answer is to have $length decimals.
349
                // Decimal places.
350
                $x = sprintf('%.' . $length . 'F', $x);
351
 
352
            } else if ($x) { // Significant figures does only apply if the result is non-zero.
353
                $answer = $x;
354
                // Convert to positive answer.
355
                if ($answer < 0) {
356
                    $answer = -$answer;
357
                    $sign = '-';
358
                } else {
359
                    $sign = '';
360
                }
361
 
362
                // Determine the format 0.[1-9][0-9]* for the answer...
363
                $p10 = 0;
364
                while ($answer < 1) {
365
                    --$p10;
366
                    $answer *= 10;
367
                }
368
                while ($answer >= 1) {
369
                    ++$p10;
370
                    $answer /= 10;
371
                }
372
                // ... and have the answer rounded of to the correct length.
373
                $answer = round($answer, $length);
374
 
375
                // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
376
                if ($answer >= 1) {
377
                    ++$p10;
378
                    $answer /= 10;
379
                }
380
 
381
                // Have the answer written on a suitable format.
382
                // Either scientific or plain numeric.
383
                if (-2 > $p10 || 4 < $p10) {
384
                    // Use scientific format.
385
                    $exponent = 'e'.--$p10;
386
                    $answer *= 10;
387
                    if (1 == $length) {
388
                        $x = $sign.$answer.$exponent;
389
                    } else {
390
                        // Attach additional zeros at the end of $answer.
391
                        $answer .= (1 == strlen($answer) ? '.' : '')
392
                            . '00000000000000000000000000000000000000000x';
393
                        $x = $sign
394
                            .substr($answer, 0, $length +1).$exponent;
395
                    }
396
                } else {
397
                    // Stick to plain numeric format.
398
                    $answer *= "1e{$p10}";
399
                    if (0.1 <= $answer / "1e{$length}") {
400
                        $x = $sign.$answer;
401
                    } else {
402
                        // Could be an idea to add some zeros here.
403
                        $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
404
                            . '00000000000000000000000000000000000000000x';
405
                        $oklen = $length + ($p10 < 1 ? 2-$p10 : 1);
406
                        $x = $sign.substr($answer, 0, $oklen);
407
                    }
408
                }
409
 
410
            } else {
411
                $x = 0.0;
412
            }
413
        }
414
        return str_replace('.', $this->decimalpoint, $x);
415
    }
416
 
417
    /**
418
     * Return an array of the variables and their values.
419
     * @return array name => value.
420
     */
421
    public function get_values() {
422
        return $this->values;
423
    }
424
 
425
    /**
426
     * Evaluate an expression using the variable values.
427
     * @param string $expression the expression. A PHP expression with placeholders
428
     *      like {a} for where the variables need to go.
429
     * @return float the computed result.
430
     */
431
    public function calculate($expression) {
432
        // Make sure no malicious code is present in the expression. Refer MDL-46148 for details.
433
        if ($error = qtype_calculated_find_formula_errors($expression)) {
434
            throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error);
435
        }
436
        $expression = $this->substitute_values_for_eval($expression);
437
        if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) {
438
            // Some placeholders were not substituted.
439
            throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '',
440
                '{' . reset($datasets) . '}');
441
        }
442
        return $this->calculate_raw($expression);
443
    }
444
 
445
    /**
446
     * Evaluate an expression after the variable values have been substituted.
447
     * @param string $expression the expression. A PHP expression with placeholders
448
     *      like {a} for where the variables need to go.
449
     * @return float the computed result.
450
     */
451
    protected function calculate_raw($expression) {
452
        try {
453
            // In older PHP versions this this is a way to validate code passed to eval.
454
            // The trick came from http://php.net/manual/en/function.eval.php.
455
            if (@eval('return true; $result = ' . $expression . ';')) {
456
                return eval('return ' . $expression . ';');
457
            }
458
        } catch (Throwable $e) {
459
            // PHP7 and later now throws ParseException and friends from eval(),
460
            // which is much better.
461
        }
462
        // In either case of an invalid $expression, we end here.
463
        throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
464
    }
465
 
466
    /**
467
     * Substitute variable placehodlers like {a} with their value wrapped in ().
468
     * @param string $expression the expression. A PHP expression with placeholders
469
     *      like {a} for where the variables need to go.
470
     * @return string the expression with each placeholder replaced by the
471
     *      corresponding value.
472
     */
473
    protected function substitute_values_for_eval($expression) {
474
        return str_replace($this->search, $this->safevalue, $expression);
475
    }
476
 
477
    /**
478
     * Substitute variable placehodlers like {a} with their value without wrapping
479
     * the value in anything.
480
     * @param string $text some content with placeholders
481
     *      like {a} for where the variables need to go.
482
     * @return string the expression with each placeholder replaced by the
483
     *      corresponding value.
484
     */
485
    protected function substitute_values_pretty($text) {
486
        return str_replace($this->search, $this->prettyvalue, $text);
487
    }
488
 
489
    /**
490
     * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
491
     * in some text with the corresponding values.
492
     * @param string $text the text to process.
493
     * @return string the text with values substituted.
494
     */
495
    public function replace_expressions_in_text($text, $length = null, $format = null) {
496
        $vs = $this; // Can't use $this in a PHP closure.
497
        $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
498
                function ($matches) use ($vs, $format, $length) {
499
                    return $vs->format_float($vs->calculate($matches[1]), $length, $format);
500
                }, $text);
501
        return $this->substitute_values_pretty($text);
502
    }
503
}