Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.


/**
 * Question type class for the numerical question type.
 *
 * @package    qtype
 * @subpackage numerical
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */


defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir . '/questionlib.php');
require_once($CFG->dirroot . '/question/type/numerical/question.php');


/**
 * The numerical question type class.
 *
 * This class contains some special features in order to make the
 * question type embeddable within a multianswer (cloze) question
 *
 * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class qtype_numerical extends question_type {
    const UNITINPUT = 0;
    const UNITRADIO = 1;
    const UNITSELECT = 2;

    const UNITNONE = 3;
    const UNITGRADED = 1;
    const UNITOPTIONAL = 0;

    const UNITGRADEDOUTOFMARK = 1;
    const UNITGRADEDOUTOFMAX = 2;

    /**
     * Validate that a string is a number formatted correctly for the current locale.
     * @param string $x a string
     * @return bool whether $x is a number that the numerical question type can interpret.
     */
    public static function is_valid_number(string $x) : bool {
        $ap = new qtype_numerical_answer_processor(array());
        list($value, $unit) = $ap->apply_units($x);
        return !is_null($value) && !$unit;
    }

    public function get_question_options($question) {
        global $CFG, $DB, $OUTPUT;
        parent::get_question_options($question);
        // Get the question answers and their respective tolerances
        // Note: question_numerical is an extension of the answer table rather than
        //       the question table as is usually the case for qtype
        //       specific tables.
        if (!$question->options->answers = $DB->get_records_sql(
                                "SELECT a.*, n.tolerance " .
                                "FROM {question_answers} a, " .
                                "     {question_numerical} n " .
                                "WHERE a.question = ? " .
                                "    AND   a.id = n.answer " .
                                "ORDER BY a.id ASC", array($question->id))) {
            echo $OUTPUT->notification('Error: Missing question answer for numerical question ' .
                    $question->id . '!');
            return false;
        }

        $question->hints = $DB->get_records('question_hints',
                array('questionid' => $question->id), 'id ASC');

        $this->get_numerical_units($question);
        // Get_numerical_options() need to know if there are units
        // to set correctly default values.
        $this->get_numerical_options($question);

        // If units are defined we strip off the default unit from the answer, if
        // it is present. (Required for compatibility with the old code and DB).
        if ($defaultunit = $this->get_default_numerical_unit($question)) {
            foreach ($question->options->answers as $key => $val) {
                $answer = trim($val->answer);
                $length = strlen($defaultunit->unit);
                if ($length && substr($answer, -$length) == $defaultunit->unit) {
                    $question->options->answers[$key]->answer =
                            substr($answer, 0, strlen($answer)-$length);
                }
            }
        }

        return true;
    }

    public function get_numerical_units(&$question) {
        global $DB;

        if ($units = $DB->get_records('question_numerical_units',
                array('question' => $question->id), 'id ASC')) {
            $units = array_values($units);
        } else {
            $units = array();
        }
        foreach ($units as $key => $unit) {
            $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT);
        }
        $question->options->units = $units;
        return true;
    }

    public function get_default_numerical_unit($question) {
        if (isset($question->options->units[0])) {
            foreach ($question->options->units as $unit) {
                if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) {
                    return $unit;
                }
            }
        }
        return false;
    }

    public function get_numerical_options($question) {
        global $DB;
        if (!$options = $DB->get_record('question_numerical_options',
                array('question' => $question->id))) {
            // Old question, set defaults.
            $question->options->unitgradingtype = 0;
            $question->options->unitpenalty = 0.1;
            if ($defaultunit = $this->get_default_numerical_unit($question)) {
                $question->options->showunits = self::UNITINPUT;
            } else {
                $question->options->showunits = self::UNITNONE;
            }
            $question->options->unitsleft = 0;

        } else {
            $question->options->unitgradingtype = $options->unitgradingtype;
            $question->options->unitpenalty = $options->unitpenalty;
            $question->options->showunits = $options->showunits;
            $question->options->unitsleft = $options->unitsleft;
        }

        return true;
    }

    public function save_defaults_for_new_questions(stdClass $fromform): void {
        parent::save_defaults_for_new_questions($fromform);
        $this->set_default_value('unitrole', $fromform->unitrole);
        $this->set_default_value('unitpenalty', $fromform->unitpenalty);
        $this->set_default_value('unitgradingtypes', $fromform->unitgradingtypes);
        $this->set_default_value('multichoicedisplay', $fromform->multichoicedisplay);
        $this->set_default_value('unitsleft', $fromform->unitsleft);
    }

    /**
     * Save the units and the answers associated with this question.
     */
    public function save_question_options($question) {
        global $DB;
        $context = $question->context;

        // Get old versions of the objects.
        $oldanswers = $DB->get_records('question_answers',
                array('question' => $question->id), 'id ASC');
        $oldoptions = $DB->get_records('question_numerical',
                array('question' => $question->id), 'answer ASC');

        // Save the units.
        $result = $this->save_units($question);
        if (isset($result->error)) {
            return $result;
        } else {
            $units = $result->units;
        }

        // Insert all the new answers.
        foreach ($question->answer as $key => $answerdata) {
            // Check for, and ingore, completely blank answer from the form.
            if (trim($answerdata) == '' && $question->fraction[$key] == 0 &&
                    html_is_blank($question->feedback[$key]['text'])) {
                continue;
            }

            // Update an existing answer if possible.
            $answer = array_shift($oldanswers);
            if (!$answer) {
                $answer = new stdClass();
                $answer->question = $question->id;
                $answer->answer = '';
                $answer->feedback = '';
                $answer->id = $DB->insert_record('question_answers', $answer);
            }

            if (trim($answerdata) === '*') {
                $answer->answer = '*';
            } else {
                $answer->answer = $this->apply_unit($answerdata, $units,
                        !empty($question->unitsleft));
                if ($answer->answer === false) {
                    $result->notice = get_string('invalidnumericanswer', 'qtype_numerical');
                }
            }
            $answer->fraction = $question->fraction[$key];
            $answer->feedback = $this->import_or_save_files($question->feedback[$key],
                    $context, 'question', 'answerfeedback', $answer->id);
            $answer->feedbackformat = $question->feedback[$key]['format'];
            $DB->update_record('question_answers', $answer);

            // Set up the options object.
            if (!$options = array_shift($oldoptions)) {
                $options = new stdClass();
            }
            $options->question = $question->id;
            $options->answer   = $answer->id;
            if (trim($question->tolerance[$key]) == '') {
                $options->tolerance = '';
            } else {
                $options->tolerance = $this->apply_unit($question->tolerance[$key],
                        $units, !empty($question->unitsleft));
                if ($options->tolerance === false) {
                    $result->notice = get_string('invalidnumerictolerance', 'qtype_numerical');
                }
                $options->tolerance = (string)$options->tolerance;
            }
            if (isset($options->id)) {
                $DB->update_record('question_numerical', $options);
            } else {
                $DB->insert_record('question_numerical', $options);
            }
        }

        // Delete any left over old answer records.
        $fs = get_file_storage();
        foreach ($oldanswers as $oldanswer) {
            $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id);
            $DB->delete_records('question_answers', array('id' => $oldanswer->id));
        }
        foreach ($oldoptions as $oldoption) {
            $DB->delete_records('question_numerical', array('id' => $oldoption->id));
        }

        $result = $this->save_unit_options($question);
        if (!empty($result->error) || !empty($result->notice)) {
            return $result;
        }

        $this->save_hints($question);

        return true;
    }

    /**
     * The numerical options control the display and the grading of the unit
     * part of the numerical question and related types (calculateds)
     * Questions previous to 2.0 do not have this table as multianswer questions
     * in all versions including 2.0. The default values are set to give the same grade
     * as old question.
     *
     */
    public function save_unit_options($question) {
        global $DB;
        $result = new stdClass();

        $update = true;
        $options = $DB->get_record('question_numerical_options',
                array('question' => $question->id));
        if (!$options) {
            $options = new stdClass();
            $options->question = $question->id;
            $options->id = $DB->insert_record('question_numerical_options', $options);
        }

        if (isset($question->unitpenalty)) {
            $options->unitpenalty = $question->unitpenalty;
        } else {
            // Either an old question or a close question type.
            $options->unitpenalty = 1;
        }

        $options->unitgradingtype = 0;
        if (isset($question->unitrole)) {
            // Saving the editing form.
            $options->showunits = $question->unitrole;
            if ($question->unitrole == self::UNITGRADED) {
                $options->unitgradingtype = $question->unitgradingtypes;
                $options->showunits = $question->multichoicedisplay;
            }

        } else if (isset($question->showunits)) {
            // Updated import, e.g. Moodle XML.
            $options->showunits = $question->showunits;
            if (isset($question->unitgradingtype)) {
                $options->unitgradingtype = $question->unitgradingtype;
            }
        } else {
            // Legacy import.
            if ($defaultunit = $this->get_default_numerical_unit($question)) {
                $options->showunits = self::UNITINPUT;
            } else {
                $options->showunits = self::UNITNONE;
            }
        }

        $options->unitsleft = !empty($question->unitsleft);

        $DB->update_record('question_numerical_options', $options);

        // Report any problems.
        if (!empty($result->notice)) {
            return $result;
        }

        return true;
    }

    public function save_units($question) {
        global $DB;
        $result = new stdClass();

        // Delete the units previously saved for this question.
        $DB->delete_records('question_numerical_units', array('question' => $question->id));

        // Nothing to do.
        if (!isset($question->multiplier)) {
            $result->units = array();
            return $result;
        }

        // Save the new units.
        $units = array();
        $unitalreadyinsert = array();
        foreach ($question->multiplier as $i => $multiplier) {
            // Discard any unit which doesn't specify the unit or the multiplier.
            if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) &&
                    !array_key_exists($question->unit[$i], $unitalreadyinsert)) {
                $unitalreadyinsert[$question->unit[$i]] = 1;
                $units[$i] = new stdClass();
                $units[$i]->question = $question->id;
                $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i],
                        array(), false);
                $units[$i]->unit = $question->unit[$i];
                $DB->insert_record('question_numerical_units', $units[$i]);
            }
        }
        unset($question->multiplier, $question->unit);

        $result->units = &$units;
        return $result;
    }

    protected function initialise_question_instance(question_definition $question, $questiondata) {
        parent::initialise_question_instance($question, $questiondata);
        $this->initialise_numerical_answers($question, $questiondata);
        $question->unitdisplay = $questiondata->options->showunits;
        $question->unitgradingtype = $questiondata->options->unitgradingtype;
        $question->unitpenalty = $questiondata->options->unitpenalty;
        $question->unitsleft = $questiondata->options->unitsleft;
        $question->ap = $this->make_answer_processor($questiondata->options->units,
                $questiondata->options->unitsleft);
    }

    public function initialise_numerical_answers(question_definition $question, $questiondata) {
        $question->answers = array();
        if (empty($questiondata->options->answers)) {
            return;
        }
        foreach ($questiondata->options->answers as $a) {
            $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer,
                    $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance);
        }
    }

    public function make_answer_processor($units, $unitsleft) {
        if (empty($units)) {
            return new qtype_numerical_answer_processor(array());
        }

        $cleanedunits = array();
        foreach ($units as $unit) {
            $cleanedunits[$unit->unit] = $unit->multiplier;
        }

        return new qtype_numerical_answer_processor($cleanedunits, $unitsleft);
    }

    public function delete_question($questionid, $contextid) {
        global $DB;
        $DB->delete_records('question_numerical', array('question' => $questionid));
        $DB->delete_records('question_numerical_options', array('question' => $questionid));
        $DB->delete_records('question_numerical_units', array('question' => $questionid));

        parent::delete_question($questionid, $contextid);
    }

    public function get_random_guess_score($questiondata) {
        foreach ($questiondata->options->answers as $aid => $answer) {
            if ('*' == trim($answer->answer)) {
                return max($answer->fraction - $questiondata->options->unitpenalty, 0);
            }
        }
        return 0;
    }

    /**
     * Add a unit to a response for display.
     * @param object $questiondata the data defining the quetsion.
     * @param string $answer a response.
     * @param object $unit a unit. If null, {@link get_default_numerical_unit()}
     * is used.
     */
    public function add_unit($questiondata, $answer, $unit = null) {
        if (is_null($unit)) {
            $unit = $this->get_default_numerical_unit($questiondata);
        }

        if (!$unit) {
            return $answer;
        }

        if (!empty($questiondata->options->unitsleft)) {
            return $unit->unit . ' ' . $answer;
        } else {
            return $answer . ' ' . $unit->unit;
        }
    }

    public function get_possible_responses($questiondata) {
        $responses = array();

        $unit = $this->get_default_numerical_unit($questiondata);

        $starfound = false;
        foreach ($questiondata->options->answers as $aid => $answer) {
            $responseclass = $answer->answer;

            if ($responseclass === '*') {
                $starfound = true;
            } else {
                $responseclass = $this->add_unit($questiondata, $responseclass, $unit);

                $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction,
                        $answer->feedback, $answer->feedbackformat, $answer->tolerance);
                list($min, $max) = $ans->get_tolerance_interval();
                $responseclass .= " ({$min}..{$max})";
            }

            $responses[$aid] = new question_possible_response($responseclass,
                    $answer->fraction);
        }

        if (!$starfound) {
            $responses[0] = new question_possible_response(
                    get_string('didnotmatchanyanswer', 'question'), 0);
        }

        $responses[null] = question_possible_response::no_response();

        return array($questiondata->id => $responses);
    }

    /**
     * Checks if the $rawresponse has a unit and applys it if appropriate.
     *
     * @param string $rawresponse  The response string to be converted to a float.
     * @param array $units         An array with the defined units, where the
     *                             unit is the key and the multiplier the value.
     * @return float               The rawresponse with the unit taken into
     *                             account as a float.
     */
    public function apply_unit($rawresponse, $units, $unitsleft) {
        $ap = $this->make_answer_processor($units, $unitsleft);
        list($value, $unit, $multiplier) = $ap->apply_units($rawresponse);
        if (!is_null($multiplier)) {
            $value *= $multiplier;
        }
        return $value;
    }

    public function move_files($questionid, $oldcontextid, $newcontextid) {
        $fs = get_file_storage();

        parent::move_files($questionid, $oldcontextid, $newcontextid);
        $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid);
        $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
    }

    protected function delete_files($questionid, $contextid) {
        $fs = get_file_storage();

        parent::delete_files($questionid, $contextid);
        $this->delete_files_in_answers($questionid, $contextid);
        $this->delete_files_in_hints($questionid, $contextid);
    }
}


/**
 * This class processes numbers with units.
 *
 * @copyright 2010 The Open University
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class qtype_numerical_answer_processor {
    /** @var array unit name => multiplier. */
    protected $units;
    /** @var string character used as decimal point. */
    protected $decsep;
    /** @var string character used as thousands separator. */
    protected $thousandssep;
    /** @var boolean whether the units come before or after the number. */
    protected $unitsbefore;

    protected $regex = null;

    public function __construct($units, $unitsbefore = false, $decsep = null,
            $thousandssep = null) {
        if (is_null($decsep)) {
            $decsep = get_string('decsep', 'langconfig');
        }
        $this->decsep = $decsep;

        if (is_null($thousandssep)) {
            $thousandssep = get_string('thousandssep', 'langconfig');
        }
        $this->thousandssep = $thousandssep;

        $this->units = $units;
        $this->unitsbefore = $unitsbefore;
    }

    /**
     * Set the decimal point and thousands separator character that should be used.
     * @param string $decsep
     * @param string $thousandssep
     */
    public function set_characters($decsep, $thousandssep) {
        $this->decsep = $decsep;
        $this->thousandssep = $thousandssep;
        $this->regex = null;
    }

    /** @return string the decimal point character used. */
    public function get_point() {
        return $this->decsep;
    }

    /** @return string the thousands separator character used. */
    public function get_separator() {
        return $this->thousandssep;
    }

    /**
     * @return bool If the student's response contains a '.' or a ',' that
     * matches the thousands separator in the current locale. In this case, the
     * parsing in apply_unit can give a result that the student did not expect.
     */
    public function contains_thousands_seaparator($value) {
        if (!in_array($this->thousandssep, array('.', ','))) {
            return false;
        }

        return strpos($value, $this->thousandssep) !== false;
    }

    /**
     * Create the regular expression that {@link parse_response()} requires.
     * @return string
     */
    protected function build_regex() {
        if (!is_null($this->regex)) {
            return $this->regex;
        }

        $decsep = preg_quote($this->decsep, '/');
        $thousandssep = preg_quote($this->thousandssep, '/');
        $beforepointre = '([+-]?[' . $thousandssep . '\d]*)';
        $decimalsre = $decsep . '(\d*)';
        $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)';

        $numberbit = "{$beforepointre}(?:{$decimalsre})?(?:{$exponentre})?";

        if ($this->unitsbefore) {
            $this->regex = "/{$numberbit}$/";
        } else {
            $this->regex = "/^{$numberbit}/";
        }
        return $this->regex;
    }

    /**
     * This method can be used for more locale-strict parsing of repsonses. At the
     * moment we don't use it, and instead use the more lax parsing in apply_units.
     * This is just a note that this funciton was used in the past, so if you are
     * intersted, look through version control history.
     *
     * Take a string which is a number with or without a decimal point and exponent,
     * and possibly followed by one of the units, and split it into bits.
     * @param string $response a value, optionally with a unit.
     * @return array four strings (some of which may be blank) the digits before
     * and after the decimal point, the exponent, and the unit. All four will be
     * null if the response cannot be parsed.
     */
    protected function parse_response($response) {
        if (!preg_match($this->build_regex(), $response, $matches)) {
            return array(null, null, null, null);
        }

        $matches += array('', '', '', ''); // Fill in any missing matches.
        list($matchedpart, $beforepoint, $decimals, $exponent) = $matches;

        // Strip out thousands separators.
        $beforepoint = str_replace($this->thousandssep, '', $beforepoint);

        // Must be either something before, or something after the decimal point.
        // (The only way to do this in the regex would make it much more complicated.)
        if ($beforepoint === '' && $decimals === '') {
            return array(null, null, null, null);
        }

        if ($this->unitsbefore) {
            $unit = substr($response, 0, -strlen($matchedpart));
        } else {
            $unit = substr($response, strlen($matchedpart));
        }
        $unit = trim($unit);

        return array($beforepoint, $decimals, $exponent, $unit);
    }

    /**
     * Takes a number in almost any localised form, and possibly with a unit
     * after it. It separates off the unit, if present, and converts to the
     * default unit, by using the given unit multiplier.
     *
     * @param string $response a value, optionally with a unit.
< * @return array(numeric, sting) the value with the unit stripped, and normalised
> * @return array(numeric, string, multiplier) the value with the unit stripped, and normalised
* by the unit multiplier, if any, and the unit string, for reference. */
< public function apply_units($response, $separateunit = null) {
> public function apply_units($response, $separateunit = null): array { > if ($response === null || trim($response) === '') { > return [null, null, null]; > } >
// Strip spaces (which may be thousands separators) and change other forms // of writing e to e. $response = str_replace(' ', '', $response); $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response); // If a . is present or there are multiple , (i.e. 2,456,789 ) assume , // is a thouseands separator, and strip it, else assume it is a decimal // separator, and change it to .. if (strpos($response, '.') !== false || substr_count($response, ',') > 1) { $response = str_replace(',', '', $response); } else { $response = str_replace([$this->thousandssep, $this->decsep, ','], ['', '.', '.'], $response); } $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?'; if ($this->unitsbefore) { $regex = "/{$regex}$/"; } else { $regex = "/^{$regex}/"; } if (!preg_match($regex, $response, $matches)) { return array(null, null, null); } $numberstring = $matches[0]; if ($this->unitsbefore) { // Substr returns false when it means '', so cast back to string. $unit = (string) substr($response, 0, -strlen($numberstring)); } else { $unit = (string) substr($response, strlen($numberstring)); } if (!is_null($separateunit)) { $unit = $separateunit; } if ($this->is_known_unit($unit)) { $multiplier = 1 / $this->units[$unit]; } else { $multiplier = null; } return array($numberstring + 0, $unit, $multiplier); // The + 0 is to convert to number. } /** * @return string the default unit. */ public function get_default_unit() { reset($this->units); return key($this->units); } /** * @param string $answer a response. * @param string $unit a unit. */ public function add_unit($answer, $unit = null) { if (is_null($unit)) { $unit = $this->get_default_unit(); } if (!$unit) { return $answer; } if ($this->unitsbefore) { return $unit . ' ' . $answer; } else { return $answer . ' ' . $unit; } } /** * Is this unit recognised. * @param string $unit the unit * @return bool whether this is a unit we recognise. */ public function is_known_unit($unit) { return array_key_exists($unit, $this->units); } /** * Whether the units go before or after the number. * @return true = before, false = after. */ public function are_units_before() { return $this->unitsbefore; } /** * Get the units as an array suitably for passing to html_writer::select. * @return array of unit choices. */ public function get_unit_options() { $options = array(); foreach ($this->units as $unit => $notused) { $options[$unit] = $unit; } return $options; } }