Revert "MDL-33105 flexible apply_units() function"
[moodle.git] / question / type / numerical / questiontype.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Question type class for the numerical question type.
20  *
21  * @package    qtype
22  * @subpackage numerical
23  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
28 defined('MOODLE_INTERNAL') || die();
30 require_once($CFG->libdir . '/questionlib.php');
31 require_once($CFG->dirroot . '/question/type/numerical/question.php');
34 /**
35  * The numerical question type class.
36  *
37  * This class contains some special features in order to make the
38  * question type embeddable within a multianswer (cloze) question
39  *
40  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
41  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42  */
43 class qtype_numerical extends question_type {
44     const UNITINPUT = 0;
45     const UNITRADIO = 1;
46     const UNITSELECT = 2;
48     const UNITNONE = 3;
49     const UNITGRADED = 1;
50     const UNITOPTIONAL = 0;
52     const UNITGRADEDOUTOFMARK = 1;
53     const UNITGRADEDOUTOFMAX = 2;
55     public function get_question_options($question) {
56         global $CFG, $DB, $OUTPUT;
57         parent::get_question_options($question);
58         // Get the question answers and their respective tolerances
59         // Note: question_numerical is an extension of the answer table rather than
60         //       the question table as is usually the case for qtype
61         //       specific tables.
62         if (!$question->options->answers = $DB->get_records_sql(
63                                 "SELECT a.*, n.tolerance " .
64                                 "FROM {question_answers} a, " .
65                                 "     {question_numerical} n " .
66                                 "WHERE a.question = ? " .
67                                 "    AND   a.id = n.answer " .
68                                 "ORDER BY a.id ASC", array($question->id))) {
69             echo $OUTPUT->notification('Error: Missing question answer for numerical question ' .
70                     $question->id . '!');
71             return false;
72         }
74         $question->hints = $DB->get_records('question_hints',
75                 array('questionid' => $question->id), 'id ASC');
77         $this->get_numerical_units($question);
78         // get_numerical_options() need to know if there are units
79         // to set correctly default values
80         $this->get_numerical_options($question);
82         // If units are defined we strip off the default unit from the answer, if
83         // it is present. (Required for compatibility with the old code and DB).
84         if ($defaultunit = $this->get_default_numerical_unit($question)) {
85             foreach ($question->options->answers as $key => $val) {
86                 $answer = trim($val->answer);
87                 $length = strlen($defaultunit->unit);
88                 if ($length && substr($answer, -$length) == $defaultunit->unit) {
89                     $question->options->answers[$key]->answer =
90                             substr($answer, 0, strlen($answer)-$length);
91                 }
92             }
93         }
95         return true;
96     }
98     public function get_numerical_units(&$question) {
99         global $DB;
101         if ($units = $DB->get_records('question_numerical_units',
102                 array('question' => $question->id), 'id ASC')) {
103             $units = array_values($units);
104         } else {
105             $units = array();
106         }
107         foreach ($units as $key => $unit) {
108             $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT);
109         }
110         $question->options->units = $units;
111         return true;
112     }
114     public function get_default_numerical_unit($question) {
115         if (isset($question->options->units[0])) {
116             foreach ($question->options->units as $unit) {
117                 if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) {
118                     return $unit;
119                 }
120             }
121         }
122         return false;
123     }
125     public function get_numerical_options($question) {
126         global $DB;
127         if (!$options = $DB->get_record('question_numerical_options',
128                 array('question' => $question->id))) {
129             // Old question, set defaults.
130             $question->options->unitgradingtype = 0;
131             $question->options->unitpenalty = 0.1;
132             if ($defaultunit = $this->get_default_numerical_unit($question)) {
133                 $question->options->showunits = self::UNITINPUT;
134             } else {
135                 $question->options->showunits = self::UNITNONE;
136             }
137             $question->options->unitsleft = 0;
139         } else {
140             $question->options->unitgradingtype = $options->unitgradingtype;
141             $question->options->unitpenalty = $options->unitpenalty;
142             $question->options->showunits = $options->showunits;
143             $question->options->unitsleft = $options->unitsleft;
144         }
146         return true;
147     }
149     /**
150      * Save the units and the answers associated with this question.
151      */
152     public function save_question_options($question) {
153         global $DB;
154         $context = $question->context;
156         // Get old versions of the objects
157         $oldanswers = $DB->get_records('question_answers',
158                 array('question' => $question->id), 'id ASC');
159         $oldoptions = $DB->get_records('question_numerical',
160                 array('question' => $question->id), 'answer ASC');
162         // Save the units.
163         $result = $this->save_units($question);
164         if (isset($result->error)) {
165             return $result;
166         } else {
167             $units = $result->units;
168         }
170         // Insert all the new answers
171         foreach ($question->answer as $key => $answerdata) {
172             // Check for, and ingore, completely blank answer from the form.
173             if (trim($answerdata) == '' && $question->fraction[$key] == 0 &&
174                     html_is_blank($question->feedback[$key]['text'])) {
175                 continue;
176             }
178             // Update an existing answer if possible.
179             $answer = array_shift($oldanswers);
180             if (!$answer) {
181                 $answer = new stdClass();
182                 $answer->question = $question->id;
183                 $answer->answer = '';
184                 $answer->feedback = '';
185                 $answer->id = $DB->insert_record('question_answers', $answer);
186             }
188             if (trim($answerdata) === '*') {
189                 $answer->answer = '*';
190             } else {
191                 $answer->answer = $this->apply_unit($answerdata, $units,
192                         !empty($question->unitsleft));
193                 if ($answer->answer === false) {
194                     $result->notice = get_string('invalidnumericanswer', 'quiz');
195                 }
196             }
197             $answer->fraction = $question->fraction[$key];
198             $answer->feedback = $this->import_or_save_files($question->feedback[$key],
199                     $context, 'question', 'answerfeedback', $answer->id);
200             $answer->feedbackformat = $question->feedback[$key]['format'];
201             $DB->update_record('question_answers', $answer);
203             // Set up the options object
204             if (!$options = array_shift($oldoptions)) {
205                 $options = new stdClass();
206             }
207             $options->question = $question->id;
208             $options->answer   = $answer->id;
209             if (trim($question->tolerance[$key]) == '') {
210                 $options->tolerance = '';
211             } else {
212                 $options->tolerance = $this->apply_unit($question->tolerance[$key],
213                         $units, !empty($question->unitsleft));
214                 if ($options->tolerance === false) {
215                     $result->notice = get_string('invalidnumerictolerance', 'quiz');
216                 }
217             }
218             if (isset($options->id)) {
219                 $DB->update_record('question_numerical', $options);
220             } else {
221                 $DB->insert_record('question_numerical', $options);
222             }
223         }
225         // Delete any left over old answer records.
226         $fs = get_file_storage();
227         foreach ($oldanswers as $oldanswer) {
228             $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
229             $DB->delete_records('question_answers', array('id' => $oldanswer->id));
230         }
231         foreach ($oldoptions as $oldoption) {
232             $DB->delete_records('question_numerical', array('id' => $oldoption->id));
233         }
235         $result = $this->save_unit_options($question);
236         if (!empty($result->error) || !empty($result->notice)) {
237             return $result;
238         }
240         $this->save_hints($question);
242         return true;
243     }
245     /**
246      * The numerical options control the display and the grading of the unit
247      * part of the numerical question and related types (calculateds)
248      * Questions previous to 2.0 do not have this table as multianswer questions
249      * in all versions including 2.0. The default values are set to give the same grade
250      * as old question.
251      *
252      */
253     public function save_unit_options($question) {
254         global $DB;
255         $result = new stdClass();
257         $update = true;
258         $options = $DB->get_record('question_numerical_options',
259                 array('question' => $question->id));
260         if (!$options) {
261             $options = new stdClass();
262             $options->question = $question->id;
263             $options->id = $DB->insert_record('question_numerical_options', $options);
264         }
266         if (isset($question->unitpenalty)) {
267             $options->unitpenalty = $question->unitpenalty;
268         } else {
269             // Either an old question or a close question type.
270             $options->unitpenalty = 1;
271         }
273         $options->unitgradingtype = 0;
274         if (isset($question->unitrole)) {
275             // Saving the editing form.
276             $options->showunits = $question->unitrole;
277             if ($question->unitrole == self::UNITGRADED) {
278                 $options->unitgradingtype = $question->unitgradingtypes;
279                 $options->showunits = $question->multichoicedisplay;
280             }
282         } else if (isset($question->showunits)) {
283             // Updated import, e.g. Moodle XML.
284             $options->showunits = $question->showunits;
285             if (isset($question->unitgradingtype)) {
286                 $options->unitgradingtype = $question->unitgradingtype;
287             }
288         } else {
289             // Legacy import.
290             if ($defaultunit = $this->get_default_numerical_unit($question)) {
291                 $options->showunits = self::UNITINPUT;
292             } else {
293                 $options->showunits = self::UNITNONE;
294             }
295         }
297         $options->unitsleft = !empty($question->unitsleft);
299         $DB->update_record('question_numerical_options', $options);
301         // Report any problems.
302         if (!empty($result->notice)) {
303             return $result;
304         }
306         return true;
307     }
309     public function save_units($question) {
310         global $DB;
311         $result = new stdClass();
313         // Delete the units previously saved for this question.
314         $DB->delete_records('question_numerical_units', array('question' => $question->id));
316         // Nothing to do.
317         if (!isset($question->multiplier)) {
318             $result->units = array();
319             return $result;
320         }
322         // Save the new units.
323         $units = array();
324         $unitalreadyinsert = array();
325         foreach ($question->multiplier as $i => $multiplier) {
326             // Discard any unit which doesn't specify the unit or the multiplier
327             if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) &&
328                     !array_key_exists($question->unit[$i], $unitalreadyinsert)) {
329                 $unitalreadyinsert[$question->unit[$i]] = 1;
330                 $units[$i] = new stdClass();
331                 $units[$i]->question = $question->id;
332                 $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i],
333                         array(), false);
334                 $units[$i]->unit = $question->unit[$i];
335                 $DB->insert_record('question_numerical_units', $units[$i]);
336             }
337         }
338         unset($question->multiplier, $question->unit);
340         $result->units = &$units;
341         return $result;
342     }
344     protected function initialise_question_instance(question_definition $question, $questiondata) {
345         parent::initialise_question_instance($question, $questiondata);
346         $this->initialise_numerical_answers($question, $questiondata);
347         $question->unitdisplay = $questiondata->options->showunits;
348         $question->unitgradingtype = $questiondata->options->unitgradingtype;
349         $question->unitpenalty = $questiondata->options->unitpenalty;
350         $question->ap = $this->make_answer_processor($questiondata->options->units,
351                 $questiondata->options->unitsleft);
352     }
354     public function initialise_numerical_answers(question_definition $question, $questiondata) {
355         $question->answers = array();
356         if (empty($questiondata->options->answers)) {
357             return;
358         }
359         foreach ($questiondata->options->answers as $a) {
360             $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer,
361                     $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);
362         }
363     }
365     public function make_answer_processor($units, $unitsleft) {
366         if (empty($units)) {
367             return new qtype_numerical_answer_processor(array());
368         }
370         $cleanedunits = array();
371         foreach ($units as $unit) {
372             $cleanedunits[$unit->unit] = $unit->multiplier;
373         }
375         return new qtype_numerical_answer_processor($cleanedunits, $unitsleft);
376     }
378     public function delete_question($questionid, $contextid) {
379         global $DB;
380         $DB->delete_records('question_numerical', array('question' => $questionid));
381         $DB->delete_records('question_numerical_options', array('question' => $questionid));
382         $DB->delete_records('question_numerical_units', array('question' => $questionid));
384         parent::delete_question($questionid, $contextid);
385     }
387     public function get_random_guess_score($questiondata) {
388         foreach ($questiondata->options->answers as $aid => $answer) {
389             if ('*' == trim($answer->answer)) {
390                 return max($answer->fraction - $questiondata->options->unitpenalty, 0);
391             }
392         }
393         return 0;
394     }
396     /**
397      * Add a unit to a response for display.
398      * @param object $questiondata the data defining the quetsion.
399      * @param string $answer a response.
400      * @param object $unit a unit. If null, {@link get_default_numerical_unit()}
401      * is used.
402      */
403     public function add_unit($questiondata, $answer, $unit = null) {
404         if (is_null($unit)) {
405             $unit = $this->get_default_numerical_unit($questiondata);
406         }
408         if (!$unit) {
409             return $answer;
410         }
412         if (!empty($questiondata->options->unitsleft)) {
413             return $unit->unit . ' ' . $answer;
414         } else {
415             return $answer . ' ' . $unit->unit;
416         }
417     }
419     public function get_possible_responses($questiondata) {
420         $responses = array();
422         $unit = $this->get_default_numerical_unit($questiondata);
424         $starfound = false;
425         foreach ($questiondata->options->answers as $aid => $answer) {
426             $responseclass = $answer->answer;
428             if ($responseclass === '*') {
429                 $starfound = true;
430             } else {
431                 $responseclass = $this->add_unit($questiondata, $responseclass, $unit);
433                 $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction,
434                         $answer->feedback, $answer->feedbackformat, $answer->tolerance);
435                 list($min, $max) = $ans->get_tolerance_interval();
436                 $responseclass .= " ($min..$max)";
437             }
439             $responses[$aid] = new question_possible_response($responseclass,
440                     $answer->fraction);
441         }
443         if (!$starfound) {
444             $responses[0] = new question_possible_response(
445                     get_string('didnotmatchanyanswer', 'question'), 0);
446         }
448         $responses[null] = question_possible_response::no_response();
450         return array($questiondata->id => $responses);
451     }
453     /**
454      * Checks if the $rawresponse has a unit and applys it if appropriate.
455      *
456      * @param string $rawresponse  The response string to be converted to a float.
457      * @param array $units         An array with the defined units, where the
458      *                             unit is the key and the multiplier the value.
459      * @return float               The rawresponse with the unit taken into
460      *                             account as a float.
461      */
462     public function apply_unit($rawresponse, $units, $unitsleft) {
463         $ap = $this->make_answer_processor($units, $unitsleft);
464         list($value, $unit, $multiplier) = $ap->apply_units($rawresponse);
465         if (!is_null($multiplier)) {
466             $value *= $multiplier;
467         }
468         return $value;
469     }
471     public function move_files($questionid, $oldcontextid, $newcontextid) {
472         $fs = get_file_storage();
474         parent::move_files($questionid, $oldcontextid, $newcontextid);
475         $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
476         $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
477     }
479     protected function delete_files($questionid, $contextid) {
480         $fs = get_file_storage();
482         parent::delete_files($questionid, $contextid);
483         $this->delete_files_in_answers($questionid, $contextid);
484         $this->delete_files_in_hints($questionid, $contextid);
485     }
489 /**
490  * This class processes numbers with units.
491  *
492  * @copyright 2010 The Open University
493  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
494  */
495 class qtype_numerical_answer_processor {
496     /** @var array unit name => multiplier. */
497     protected $units;
498     /** @var string character used as decimal point. */
499     protected $decsep;
500     /** @var string character used as thousands separator. */
501     protected $thousandssep;
502     /** @var boolean whether the units come before or after the number. */
503     protected $unitsbefore;
505     protected $regex = null;
507     public function __construct($units, $unitsbefore = false, $decsep = null,
508             $thousandssep = null) {
509         if (is_null($decsep)) {
510             $decsep = get_string('decsep', 'langconfig');
511         }
512         $this->decsep = $decsep;
514         if (is_null($thousandssep)) {
515             $thousandssep = get_string('thousandssep', 'langconfig');
516         }
517         $this->thousandssep = $thousandssep;
519         $this->units = $units;
520         $this->unitsbefore = $unitsbefore;
521     }
523     /**
524      * Set the decimal point and thousands separator character that should be used.
525      * @param string $decsep
526      * @param string $thousandssep
527      */
528     public function set_characters($decsep, $thousandssep) {
529         $this->decsep = $decsep;
530         $this->thousandssep = $thousandssep;
531         $this->regex = null;
532     }
534     /** @return string the decimal point character used. */
535     public function get_point() {
536         return $this->decsep;
537     }
539     /** @return string the thousands separator character used. */
540     public function get_separator() {
541         return $this->thousandssep;
542     }
544     /**
545      * @return book If the student's response contains a '.' or a ',' that
546      * matches the thousands separator in the current locale. In this case, the
547      * parsing in apply_unit can give a result that the student did not expect.
548      */
549     public function contains_thousands_seaparator($value) {
550         if (!in_array($this->thousandssep, array('.', ','))) {
551             return false;
552         }
554         return strpos($value, $this->thousandssep) !== false;
555     }
557     /**
558      * Create the regular expression that {@link parse_response()} requires.
559      * @return string
560      */
561     protected function build_regex() {
562         if (!is_null($this->regex)) {
563             return $this->regex;
564         }
566         $decsep = preg_quote($this->decsep, '/');
567         $thousandssep = preg_quote($this->thousandssep, '/');
568         $beforepointre = '([+-]?[' . $thousandssep . '\d]*)';
569         $decimalsre = $decsep . '(\d*)';
570         $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)';
572         $numberbit = "$beforepointre(?:$decimalsre)?(?:$exponentre)?";
574         if ($this->unitsbefore) {
575             $this->regex = "/$numberbit$/";
576         } else {
577             $this->regex = "/^$numberbit/";
578         }
579         return $this->regex;
580     }
582     /**
583      * This method can be used for more locale-strict parsing of repsonses. At the
584      * moment we don't use it, and instead use the more lax parsing in apply_units.
585      * This is just a note that this funciton was used in the past, so if you are
586      * intersted, look through version control history.
587      *
588      * Take a string which is a number with or without a decimal point and exponent,
589      * and possibly followed by one of the units, and split it into bits.
590      * @param string $response a value, optionally with a unit.
591      * @return array four strings (some of which may be blank) the digits before
592      * and after the decimal point, the exponent, and the unit. All four will be
593      * null if the response cannot be parsed.
594      */
595     protected function parse_response($response) {
596         if (!preg_match($this->build_regex(), $response, $matches)) {
597             return array(null, null, null, null);
598         }
600         $matches += array('', '', '', ''); // Fill in any missing matches.
601         list($matchedpart, $beforepoint, $decimals, $exponent) = $matches;
603         // Strip out thousands separators.
604         $beforepoint = str_replace($this->thousandssep, '', $beforepoint);
606         // Must be either something before, or something after the decimal point.
607         // (The only way to do this in the regex would make it much more complicated.)
608         if ($beforepoint === '' && $decimals === '') {
609             return array(null, null, null, null);
610         }
612         if ($this->unitsbefore) {
613             $unit = substr($response, 0, -strlen($matchedpart));
614         } else {
615             $unit = substr($response, strlen($matchedpart));
616         }
617         $unit = trim($unit);
619         return array($beforepoint, $decimals, $exponent, $unit);
620     }
622     /**
623      * Takes a number in almost any localised form, and possibly with a unit
624      * after it. It separates off the unit, if present, and converts to the
625      * default unit, by using the given unit multiplier.
626      *
627      * @param string $response a value, optionally with a unit.
628      * @return array(numeric, sting) the value with the unit stripped, and normalised
629      *      by the unit multiplier, if any, and the unit string, for reference.
630      */
631     public function apply_units($response, $separateunit = null) {
632         // Strip spaces (which may be thousands separators) and change other forms
633         // of writing e to e.
634         $response = str_replace(' ', '', $response);
635         $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response);
637         // If a . is present or there are multiple , (i.e. 2,456,789 ) assume ,
638         // is a thouseands separator, and strip it, else assume it is a decimal
639         // separator, and change it to ..
640         if (strpos($response, '.') !== false || substr_count($response, ',') > 1) {
641             $response = str_replace(',', '', $response);
642         } else {
643             $response = str_replace(',', '.', $response);
644         }
646         $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?';
647         if ($this->unitsbefore) {
648             $regex = "/$regex$/";
649         } else {
650             $regex = "/^$regex/";
651         }
652         if (!preg_match($regex, $response, $matches)) {
653             return array(null, null, null);
654         }
656         $numberstring = $matches[0];
657         if ($this->unitsbefore) {
658             // substr returns false when it means '', so cast back to string.
659             $unit = (string) substr($response, 0, -strlen($numberstring));
660         } else {
661             $unit = (string) substr($response, strlen($numberstring));
662         }
664         if (!is_null($separateunit)) {
665             $unit = $separateunit;
666         }
668         if ($this->is_known_unit($unit)) {
669             $multiplier = 1 / $this->units[$unit];
670         } else {
671             $multiplier = null;
672         }
674         return array($numberstring + 0, $unit, $multiplier); // + 0 to convert to number.
675     }
677     /**
678      * @return string the default unit.
679      */
680     public function get_default_unit() {
681         reset($this->units);
682         return key($this->units);
683     }
685     /**
686      * @param string $answer a response.
687      * @param string $unit a unit.
688      */
689     public function add_unit($answer, $unit = null) {
690         if (is_null($unit)) {
691             $unit = $this->get_default_unit();
692         }
694         if (!$unit) {
695             return $answer;
696         }
698         if ($this->unitsbefore) {
699             return $unit . ' ' . $answer;
700         } else {
701             return $answer . ' ' . $unit;
702         }
703     }
705     /**
706      * Is this unit recognised.
707      * @param string $unit the unit
708      * @return bool whether this is a unit we recognise.
709      */
710     public function is_known_unit($unit) {
711         return array_key_exists($unit, $this->units);
712     }
714     /**
715      * Whether the units go before or after the number.
716      * @return true = before, false = after.
717      */
718     public function are_units_before() {
719         return $this->unitsbefore;
720     }
722     /**
723      * Get the units as an array suitably for passing to html_writer::select.
724      * @return array of unit choices.
725      */
726     public function get_unit_options() {
727         $options = array();
728         foreach ($this->units as $unit => $notused) {
729             $options[$unit] = $unit;
730         }
731         return $options;
732     }