Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.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); * Save the units and the answers associated with this question. > $this->set_default_value('unitrole', $fromform->unitrole); */ > $this->set_default_value('unitpenalty', $fromform->unitpenalty); public function save_question_options($question) { > $this->set_default_value('unitgradingtypes', $fromform->unitgradingtypes); global $DB; > $this->set_default_value('multichoicedisplay', $fromform->multichoicedisplay); $context = $question->context; > $this->set_default_value('unitsleft', $fromform->unitsleft); > } // 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; } }