Skip to content
Snippets Groups Projects
questiontype.php 118 KiB
Newer Older
  • Learn to ignore specific revisions
  • // This file is part of Stack - http://stack.maths.ed.ac.uk/
    
    // Stack 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.
    //
    
    // Stack 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 Stack.  If not, see <http://www.gnu.org/licenses/>.
    
    
    /**
     * Question type class for the Stack question type.
     *
     * @package    qtype_stack
     * @copyright  2012 The Open University
     * @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(__DIR__ . '/stack/input/factory.class.php');
    require_once(__DIR__ . '/stack/answertest/controller.class.php');
    require_once(__DIR__ . '/stack/cas/keyval.class.php');
    
    require_once(__DIR__ . '/stack/cas/castext2/castext2_evaluatable.class.php');
    
    require_once(__DIR__ . '/stack/cas/castext2/castext2_static_replacer.class.php');
    
    require_once(__DIR__ . '/stack/questiontest.php');
    
    require_once(__DIR__ . '/stack/prt.class.php');
    
    require_once(__DIR__ . '/stack/potentialresponsetreestate.class.php');
    
    require_once(__DIR__ . '/stack/prt.class.php');
    
    require_once(__DIR__ . '/stack/graphlayout/graph.php');
    
    require_once(__DIR__ . '/lang/multilang.php');
    
    require_once(__DIR__ . '/stack/prt.class.php');
    
    
    
    /**
     * Stack question type class.
     *
     * @copyright  2012 The Open University
     * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
     */
    class qtype_stack extends question_type {
    
        /** @var int array key into the results of get_input_names_from_question_text for the count of input placeholders. */
        const INPUTS = 0;
        /** @var int array key into the results of get_input_names_from_question_text for the count of validation placeholders. */
        const VALIDATIONS = 1;
    
        /** @var int the CAS seed using during validation. */
        protected $seed = 1;
    
        /** @var stack_options the CAS options using during validation. */
        protected $options;
    
        /**
         * @var array prt name => stack_abstract_graph caches the result of
         * {@link get_prt_graph()}.
         */
        protected $prtgraph = array();
    
    
        public function save_question($question, $fromform) {
    
            if (!empty($fromform->fixdollars)) {
                $this->fix_dollars_in_form_data($fromform);
            }
    
    
            $fromform->penalty = stack_utils::fix_approximate_thirds($fromform->penalty);
    
    
            // If this questions is being derived from another one (either duplicate in any
            // Moodle version, or editing making a new version in Moodle 4.0+) then we need
            // to get the deployed variants and question tests, so they can be copied too.
            // The property used seems to be the reliable way to get the old question id.
            if (isset($question->options->questionid) && $question->options->questionid) {
                $fromform->deployedseeds = $this->get_question_deployed_seeds($question->options->questionid);
                $fromform->testcases = $this->load_question_tests($question->options->questionid);
    
            }
    
            $new = parent::save_question($question, $fromform);
    
            return $new;
    
        }
    
        /**
         * Replace any $...$ and $$...$$ delimiters in the question text from the
         * form with the recommended delimiters.
         * @param object $fromform the data from the form.
         */
        protected function fix_dollars_in_form_data($fromform) {
            $questionfields = array('questiontext', 'generalfeedback', 'specificfeedback',
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    'prtcorrect', 'prtpartiallycorrect', 'prtincorrect', 'questiondescription');
    
            foreach ($questionfields as $field) {
                $fromform->{$field}['text'] = stack_maths::replace_dollars($fromform->{$field}['text']);
            }
            $fromform->questionnote = stack_maths::replace_dollars($fromform->questionnote);
    
    
            $prtnames = array_keys($this->get_prt_names_from_question($fromform->questiontext['text'],
                    $fromform->specificfeedback['text']));
    
            foreach ($prtnames as $prt) {
                foreach ($fromform->{$prt . 'truefeedback'} as &$feedback) {
                    $feedback['text'] = stack_maths::replace_dollars($feedback['text']);
                }
    
                foreach ($fromform->{$prt . 'falsefeedback'} as &$feedback) {
                    $feedback['text'] = stack_maths::replace_dollars($feedback['text']);
                }
            }
    
            foreach ($fromform->hint as &$hint) {
                $hint['text'] = stack_maths::replace_dollars($hint['text']);
            }
        }
    
    
        public function save_question_options($fromform) {
            global $DB;
            $context = $fromform->context;
    
    
            parent::save_question_options($fromform);
    
    
            $options = $DB->get_record('qtype_stack_options', array('questionid' => $fromform->id));
    
            if (!$options) {
                $options = new stdClass();
                $options->questionid = $fromform->id;
                $options->questionvariables = '';
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $options->questiondescription = '';
    
                $options->specificfeedback = '';
                $options->prtcorrect = '';
                $options->prtpartiallycorrect = '';
                $options->prtincorrect = '';
    
                $options->stackversion = get_config('qtype_stack', 'version');
    
                $options->id = $DB->insert_record('qtype_stack_options', $options);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $options->stackversion              = $fromform->stackversion;
    
            $options->questionvariables         = $fromform->questionvariables;
            $options->specificfeedback          = $this->import_or_save_files($fromform->specificfeedback,
                        $context, 'qtype_stack', 'specificfeedback', $fromform->id);
            $options->specificfeedbackformat    = $fromform->specificfeedback['format'];
            $options->questionnote              = $fromform->questionnote;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $options->questiondescription       = $this->import_or_save_files($fromform->questiondescription,
                $context, 'qtype_stack', 'questiondescription', $fromform->id);
            $options->questiondescriptionformat = $fromform->questiondescription['format'];
    
            $options->questionsimplify          = $fromform->questionsimplify;
            $options->assumepositive            = $fromform->assumepositive;
    
            $options->assumereal                = $fromform->assumereal;
    
            $options->prtcorrect                = $this->import_or_save_files($fromform->prtcorrect,
                        $context, 'qtype_stack', 'prtcorrect', $fromform->id);
            $options->prtcorrectformat          = $fromform->prtcorrect['format'];
            $options->prtpartiallycorrect       = $this->import_or_save_files($fromform->prtpartiallycorrect,
                        $context, 'qtype_stack', 'prtpartiallycorrect', $fromform->id);
            $options->prtpartiallycorrectformat = $fromform->prtpartiallycorrect['format'];
            $options->prtincorrect              = $this->import_or_save_files($fromform->prtincorrect,
                        $context, 'qtype_stack', 'prtincorrect', $fromform->id);
            $options->prtincorrectformat        = $fromform->prtincorrect['format'];
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $options->decimals                  = $fromform->decimals;
    
            $options->multiplicationsign        = $fromform->multiplicationsign;
            $options->sqrtsign                  = $fromform->sqrtsign;
            $options->complexno                 = $fromform->complexno;
    
            $options->inversetrig               = $fromform->inversetrig;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $options->logicsymbol               = $fromform->logicsymbol;
    
            $options->matrixparens              = $fromform->matrixparens;
    
            $options->variantsselectionseed     = $fromform->variantsselectionseed;
    
            // We will not have the values for this.
            $options->compiledcache             = '{}';
    
            $DB->update_record('qtype_stack_options', $options);
    
            $inputnames = array_keys($this->get_input_names_from_question_text_lang($fromform->questiontext));
    
            $inputs = $DB->get_records('qtype_stack_inputs',
                    array('questionid' => $fromform->id), '', 'name, id, questionid');
    
            $questionhasinputs = false;
    
            foreach ($inputnames as $inputname) {
                if (array_key_exists($inputname, $inputs)) {
                    $input = $inputs[$inputname];
                    unset($inputs[$inputname]);
                } else {
                    $input = new stdClass();
                    $input->questionid = $fromform->id;
                    $input->name       = $inputname;
    
                    $input->options    = '';
    
                    $input->id = $DB->insert_record('qtype_stack_inputs', $input);
                }
    
                $input->type               = $fromform->{$inputname . 'type'};
    
                $input->tans               = $fromform->{$inputname . 'modelans'};
    
                $input->boxsize            = $fromform->{$inputname . 'boxsize'};
    
                // TODO: remove this when we remove strictsyntax from the DB.
                $input->strictsyntax       = true;
    
                $input->insertstars        = $fromform->{$inputname . 'insertstars'};
                $input->syntaxhint         = $fromform->{$inputname . 'syntaxhint'};
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $input->syntaxattribute    = $fromform->{$inputname . 'syntaxattribute'};
    
                $input->forbidwords        = $fromform->{$inputname . 'forbidwords'};
    
                $input->allowwords         = $fromform->{$inputname . 'allowwords'};
    
                $input->forbidfloat        = $fromform->{$inputname . 'forbidfloat'};
                $input->requirelowestterms = $fromform->{$inputname . 'requirelowestterms'};
                $input->checkanswertype    = $fromform->{$inputname . 'checkanswertype'};
    
                $input->mustverify         = $fromform->{$inputname . 'mustverify'};
    
                $input->showvalidation     = $fromform->{$inputname . 'showvalidation'};
    
                $input->options            = $fromform->{$inputname . 'options'};
    
                $questionhasinputs = true;
    
                $DB->update_record('qtype_stack_inputs', $input);
            }
    
            if ($inputs) {
                list($test, $params) = $DB->get_in_or_equal(array_keys($inputs));
                $params[] = $fromform->id;
                $DB->delete_records_select('qtype_stack_inputs',
                        'name ' . $test . ' AND questionid = ?', $params);
            }
    
    
            if (!$questionhasinputs) {
                // A question with no inputs is an information item.
                $DB->set_field('question', 'length', 0, array('id' => $fromform->id));
            }
    
    
            $prtnames = array_keys($this->get_prt_names_from_question($fromform->questiontext, $options->specificfeedback));
    
            $prts = $DB->get_records('qtype_stack_prts',
                    array('questionid' => $fromform->id), '', 'name, id, questionid');
            foreach ($prtnames as $prtname) {
    
                if (array_key_exists($prtname, $prts)) {
    
                    $prt = $prts[$prtname];
                    unset($prts[$prtname]);
                } else {
                    $prt = new stdClass();
                    $prt->questionid        = $fromform->id;
                    $prt->name              = $prtname;
                    $prt->feedbackvariables = '';
    
                    $prt->firstnodename     = 0;
    
                    $prt->id = $DB->insert_record('qtype_stack_prts', $prt);
                }
    
    
                // Find the root node of the PRT.
                // Otherwise, if an existing question is being edited, and this is an
                // existing PRT, base things on the existing question definition.
                $graph = new stack_abstract_graph();
                foreach ($fromform->{$prtname . 'answertest'} as $nodename => $notused) {
    
                    $description   = $fromform->{$prtname . 'description'}[$nodename];
    
                    $truenextnode  = $fromform->{$prtname . 'truenextnode'}[$nodename];
                    $falsenextnode = $fromform->{$prtname . 'falsenextnode'}[$nodename];
    
                    if ($truenextnode == -1) {
                        $left = null;
                    } else {
                        $left = $truenextnode + 1;
                    }
                    if ($falsenextnode == -1) {
                        $right = null;
                    } else {
                        $right = $falsenextnode + 1;
                    }
    
    
                    $graph->add_prt_node($nodename + 1, $description, $left, $right);
    
                }
                $graph->layout();
                $roots = $graph->get_roots();
                if (count($roots) != 1 || $graph->get_broken_cycles()) {
                    throw new coding_exception('The PRT ' . $prtname . ' is malformed.');
                }
                reset($roots);
                $firstnode = key($roots) - 1;
    
    
                $prt->value             = $fromform->{$prtname . 'value'};
                $prt->autosimplify      = $fromform->{$prtname . 'autosimplify'};
    
                $prt->feedbackstyle     = $fromform->{$prtname . 'feedbackstyle'};
    
                $prt->feedbackvariables = $fromform->{$prtname . 'feedbackvariables'};
    
                $prt->firstnodename     = $firstnode;
    
                $DB->update_record('qtype_stack_prts', $prt);
    
                $nodes = $DB->get_records('qtype_stack_prt_nodes',
                        array('questionid' => $fromform->id, 'prtname' => $prtname),
                        '', 'nodename, id, questionid, prtname');
    
                foreach ($fromform->{$prtname . 'answertest'} as $nodename => $notused) {
                    if (array_key_exists($nodename, $nodes)) {
                        $node = $nodes[$nodename];
                        unset($nodes[$nodename]);
                    } else {
                        $node = new stdClass();
                        $node->questionid    = $fromform->id;
                        $node->prtname       = $prtname;
                        $node->nodename      = $nodename;
                        $node->truefeedback  = '';
                        $node->falsefeedback = '';
                        $node->id = $DB->insert_record('qtype_stack_prt_nodes', $node);
                    }
    
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    $node->description         = $fromform->{$prtname . 'description'}[$nodename];
    
                    $node->answertest          = $fromform->{$prtname . 'answertest'}[$nodename];
                    $node->sans                = $fromform->{$prtname . 'sans'}[$nodename];
                    $node->tans                = $fromform->{$prtname . 'tans'}[$nodename];
    
    Georg Osang's avatar
    Georg Osang committed
                    // For input types which do not have test options, the input field is hidden
    
                    // and therefore null is passed to $node->testoptions, which crashes the form.
    
    Georg Osang's avatar
    Georg Osang committed
                    // The empty string should be used instead. (Also see issue #974).
    
                    $node->testoptions         = '';
                    if (property_exists($fromform, $prtname . 'testoptions')) {
    
                        if (array_key_exists($nodename, $fromform->{$prtname . 'testoptions'})) {
                            $node->testoptions         = $fromform->{$prtname . 'testoptions'}[$nodename];
                        }
    
                    $node->quiet               = $fromform->{$prtname . 'quiet'}[$nodename];
                    $node->truescoremode       = $fromform->{$prtname . 'truescoremode'}[$nodename];
                    $node->truescore           = $fromform->{$prtname . 'truescore'}[$nodename];
    
                    if (property_exists($fromform, $prtname . 'truepenalty')) {
                        $node->truepenalty         = stack_utils::fix_approximate_thirds(
                            $fromform->{$prtname . 'truepenalty'}[$nodename]);
                    } else {
                        // Else we just deleted a PRT.
                        $node->truepenalty = '';
                    }
    
                    $node->truenextnode        = $fromform->{$prtname . 'truenextnode'}[$nodename];
                    $node->trueanswernote      = $fromform->{$prtname . 'trueanswernote'}[$nodename];
                    $node->truefeedback        = $this->import_or_save_files(
                                    $fromform->{$prtname . 'truefeedback'}[$nodename],
                                    $context, 'qtype_stack', 'prtnodetruefeedback', $node->id);
                    $node->truefeedbackformat  = $fromform->{$prtname . 'truefeedback'}[$nodename]['format'];
                    $node->falsescoremode      = $fromform->{$prtname . 'falsescoremode'}[$nodename];
                    $node->falsescore          = $fromform->{$prtname . 'falsescore'}[$nodename];
    
                    if (property_exists($fromform, $prtname . 'falsepenalty')) {
                        $node->falsepenalty         = stack_utils::fix_approximate_thirds(
                            $fromform->{$prtname . 'falsepenalty'}[$nodename]);
                    } else {
                        // Else we just deleted a PRT.
                        $node->falsepenalty = '';
                    }
    
                    $node->falsenextnode       = $fromform->{$prtname . 'falsenextnode'}[$nodename];
                    $node->falseanswernote     = $fromform->{$prtname . 'falseanswernote'}[$nodename];
                    $node->falsefeedback        = $this->import_or_save_files(
                                    $fromform->{$prtname . 'falsefeedback'}[$nodename],
                                    $context, 'qtype_stack', 'prtnodefalsefeedback', $node->id);
                    $node->falsefeedbackformat  = $fromform->{$prtname . 'falsefeedback'}[$nodename]['format'];
    
    
                    if ('' === $node->truepenalty) {
                        $node->truepenalty = null;
                    }
                    if ('' === $node->falsepenalty) {
                        $node->falsepenalty = null;
                    }
    
    
                    $DB->update_record('qtype_stack_prt_nodes', $node);
                }
    
                if ($nodes) {
                    list($test, $params) = $DB->get_in_or_equal(array_keys($nodes));
                    $params[] = $fromform->id;
                    $params[] = $prt->name;
                    $DB->delete_records_select('qtype_stack_prt_nodes',
                            'nodename ' . $test . ' AND questionid = ? AND prtname = ?', $params);
                }
            }
    
            if ($prts) {
                list($test, $params) = $DB->get_in_or_equal(array_keys($prts));
                $params[] = $fromform->id;
    
                $DB->delete_records_select('qtype_stack_prt_nodes',
                        'prtname ' . $test . ' AND questionid = ?', $params);
    
                $DB->delete_records_select('qtype_stack_prts',
                        'name ' . $test . ' AND questionid = ?', $params);
            }
    
    Tim Hunt's avatar
    Tim Hunt committed
    
    
            $this->save_hints($fromform);
    
    
    Tim Hunt's avatar
    Tim Hunt committed
            if (isset($fromform->deployedseeds)) {
                $DB->delete_records('qtype_stack_deployed_seeds', array('questionid' => $fromform->id));
                foreach ($fromform->deployedseeds as $deployedseed) {
                    $record = new stdClass();
                    $record->questionid = $fromform->id;
                    $record->seed = $deployedseed;
                    $DB->insert_record('qtype_stack_deployed_seeds', $record, false);
                }
            }
    
            if (isset($fromform->testcases)) {
    
                // If the data includes the definition of the question tests that there
    
                // should be (i.e. when doing import) then replace the existing set
                // of tests with the new one.
    
    Tim Hunt's avatar
    Tim Hunt committed
                $this->save_question_tests($fromform->id, $fromform->testcases);
            }
    
    
            // Irrespective of what else has happened, ensure there is no garbage
            // in the database, for example if we delete a PRT, remove the expected
            // values for that PRT while leaving the rest of the testcases alone.
            list($nametest, $params) = $DB->get_in_or_equal($inputnames, SQL_PARAMS_NAMED, 'input', false, null);
            $params['questionid'] = $fromform->id;
            $DB->delete_records_select('qtype_stack_qtest_inputs',
                    'questionid = :questionid AND inputname ' . $nametest, $params);
    
            list($nametest, $params) = $DB->get_in_or_equal($prtnames, SQL_PARAMS_NAMED, 'prt', false, null);
            $params['questionid'] = $fromform->id;
            $DB->delete_records_select('qtype_stack_qtest_expected',
                    'questionid = :questionid AND prtname ' . $nametest, $params);
    
        public function get_question_options($question) {
            global $DB;
    
            parent::get_question_options($question);
    
    
            $question->options = $DB->get_record('qtype_stack_options',
    
                    array('questionid' => $question->id), '*', MUST_EXIST);
    
            $question->inputs = $DB->get_records('qtype_stack_inputs',
                    array('questionid' => $question->id), 'name',
                    'name, id, questionid, type, tans, boxsize, strictsyntax, insertstars, ' .
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    'syntaxhint, syntaxattribute, forbidwords, allowwords, forbidfloat, requirelowestterms, ' .
    
                    'checkanswertype, mustverify, showvalidation, options');
    
    
            $question->prts = $DB->get_records('qtype_stack_prts',
                    array('questionid' => $question->id), 'name',
    
                    'name, id, questionid, value, autosimplify, feedbackstyle, feedbackvariables, firstnodename');
    
    
            $noders = $DB->get_recordset('qtype_stack_prt_nodes',
    
                    array('questionid' => $question->id),
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    'prtname, nodename, description');
    
            foreach ($noders as $node) {
    
                if (!property_exists($question->prts[$node->prtname], 'nodes')) {
                    $question->prts[$node->prtname]->nodes = [];
                }
    
                $question->prts[$node->prtname]->nodes[$node->nodename] = $node;
            }
            $noders->close();
    
    
            $question->deployedseeds = $this->get_question_deployed_seeds($question->id);
    
            return true;
        }
    
        protected function get_question_deployed_seeds($qid) {
            global $DB;
    
            return $DB->get_fieldset_sql('
    
                    SELECT seed
                      FROM {qtype_stack_deployed_seeds}
                     WHERE questionid = ?
    
                  ORDER BY id', array($qid));
    
        }
    
        protected function initialise_question_instance(question_definition $question, $questiondata) {
            parent::initialise_question_instance($question, $questiondata);
    
    
            $question->stackversion              = $questiondata->options->stackversion;
    
            $question->questionvariables         = $questiondata->options->questionvariables;
            $question->questionnote              = $questiondata->options->questionnote;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $question->questiondescription       = $questiondata->options->questiondescription;
            $question->questiondescriptionformat = $questiondata->options->questiondescriptionformat;
    
            $question->specificfeedback          = $questiondata->options->specificfeedback;
            $question->specificfeedbackformat    = $questiondata->options->specificfeedbackformat;
            $question->prtcorrect                = $questiondata->options->prtcorrect;
            $question->prtcorrectformat          = $questiondata->options->prtcorrectformat;
            $question->prtpartiallycorrect       = $questiondata->options->prtpartiallycorrect;
            $question->prtpartiallycorrectformat = $questiondata->options->prtpartiallycorrectformat;
            $question->prtincorrect              = $questiondata->options->prtincorrect;
            $question->prtincorrectformat        = $questiondata->options->prtincorrectformat;
    
            $question->variantsselectionseed     = $questiondata->options->variantsselectionseed;
    
            $question->compiledcache             = $questiondata->options->compiledcache;
    
            // Parse the cache in advance.
            if (is_string($question->compiledcache)) {
                $question->compiledcache = json_decode($question->compiledcache, true);
            } else if ($question->compiledcache === null) {
                // If someone has done nulling through the database.
                $question->compiledcache = [];
            }
    
    
            $question->options = new stack_options();
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $question->options->set_option('decimals',           $questiondata->options->decimals);
    
            $question->options->set_option('multiplicationsign', $questiondata->options->multiplicationsign);
            $question->options->set_option('complexno',          $questiondata->options->complexno);
    
            $question->options->set_option('inversetrig',        $questiondata->options->inversetrig);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $question->options->set_option('logicsymbol',        $questiondata->options->logicsymbol);
    
            $question->options->set_option('matrixparens',       $questiondata->options->matrixparens);
    
            $question->options->set_option('sqrtsign',    (bool) $questiondata->options->sqrtsign);
            $question->options->set_option('simplify',    (bool) $questiondata->options->questionsimplify);
            $question->options->set_option('assumepos',   (bool) $questiondata->options->assumepositive);
    
            $question->options->set_option('assumereal',  (bool) $questiondata->options->assumereal);
    
            $requiredparams = stack_input_factory::get_parameters_used();
    
            foreach (stack_utils::extract_placeholders($question->questiontext, 'input') as $name) {
    
                $inputdata = $questiondata->inputs[$name];
    
                $allparameters = array(
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    'boxWidth'        => $inputdata->boxsize,
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                    'insertStars'     => (int) $inputdata->insertstars,
                    'syntaxHint'      => $inputdata->syntaxhint,
                    'syntaxAttribute' => $inputdata->syntaxattribute,
                    'forbidWords'     => $inputdata->forbidwords,
                    'allowWords'      => $inputdata->allowwords,
                    'forbidFloats'    => (bool) $inputdata->forbidfloat,
                    'lowestTerms'     => (bool) $inputdata->requirelowestterms,
                    'sameType'        => (bool) $inputdata->checkanswertype,
                    'mustVerify'      => (bool) $inputdata->mustverify,
                    'showValidation'  => $inputdata->showvalidation,
                    'options'         => $inputdata->options,
    
                $parameters = array();
                foreach ($requiredparams[$inputdata->type] as $paramname) {
                    if ($paramname == 'inputType') {
                        continue;
                    }
                    $parameters[$paramname] = $allparameters[$paramname];
                }
    
                $question->inputs[$name] = stack_input_factory::make(
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                        $inputdata->type, $inputdata->name, $inputdata->tans, $question->options, $parameters);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $prtnames = array_keys($this->get_prt_names_from_question($question->questiontext, $question->specificfeedback));
    
    
    Tim Hunt's avatar
    Tim Hunt committed
            $totalvalue = 0;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $allformative = true;
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            foreach ($prtnames as $name) {
    
                // If not then we have just created the PRT.
                if (array_key_exists($name, $questiondata->prts)) {
                    $prtdata = $questiondata->prts[$name];
                    // At this point we do not have the PRT method is_formative() available to us.
                    if ($prtdata->feedbackstyle > 0) {
                        $totalvalue += $prtdata->value;
                        $allformative = false;
                    }
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            if ($questiondata->prts && !$allformative && $totalvalue < 0.0000001) {
    
    Tim Hunt's avatar
    Tim Hunt committed
                throw new coding_exception('There is an error authoring your question. ' .
                        'The $totalvalue, the marks available for the question, must be positive in question ' .
                        $question->name);
    
    Tim Hunt's avatar
    Tim Hunt committed
    
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                if (array_key_exists($name, $questiondata->prts)) {
                    $prtvalue = 0;
                    if (!$allformative) {
                        $prtvalue = $questiondata->prts[$name]->value / $totalvalue;
                    }
                    $question->prts[$name] = new stack_potentialresponse_tree_lite($questiondata->prts[$name],
                        $prtvalue, $question);
                } // If not we just added a PRT.
    
    
            $question->deployedseeds = array_values($questiondata->deployedseeds);
    
        /**
         * Get the URL params required for linking to associated scripts like
         * questiontestrun.php.
         *
         * @param stdClass|qtype_stack_question $question question data, as from question_bank::load_question
         *      or question_bank::load_question_data.
         * @return array of URL params. Can be passed to moodle_url.
         */
        protected function get_question_url_params($question) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $urlparams = array('questionid' => $question->id);
            if (property_exists($question, 'seed')) {
                $urlparams['seed'] = $question->seed;
            }
    
            // This is a bit of a hack to find the right thing to put in the URL.
            // If we are already on a URL that gives us a clue what to do, use that.
    
            $context = context::instance_by_id($question->contextid);
    
            if ($cmid = optional_param('cmid', null, PARAM_INT)) {
                $urlparams['cmid'] = $cmid;
    
            } else if ($courseid = optional_param('courseid', null, PARAM_INT)) {
                $urlparams['courseid'] = $courseid;
    
            } else if ($context->contextlevel == CONTEXT_MODULE) {
                $urlparams['cmid'] = $context->instanceid;
    
            } else if ($context->contextlevel == CONTEXT_COURSE) {
                $urlparams['courseid'] = $context->instanceid;
    
            } else {
                $urlparams['courseid'] = get_site()->id;
    
            return $urlparams;
        }
    
        /**
         * Get the URL for questiontestrun.php for a question.
         *
         * @param stdClass|qtype_stack_question $question question data, as from question_bank::load_question
         *      or question_bank::load_question_data.
         * @return moodle_url the URL.
         */
        public function get_question_test_url($question) {
            $linkparams = $this->get_question_url_params($question);
            return new moodle_url('/question/type/stack/questiontestrun.php', $linkparams);
        }
    
        /**
         * Get the URL for tidyquestion.php for a question.
         *
         * @param stdClass|qtype_stack_question $question question data, as from question_bank::load_question
         *      or question_bank::load_question_data.
         * @return moodle_url the URL.
         */
        public function get_tidy_question_url($question) {
            $linkparams = $this->get_question_url_params($question);
            return new moodle_url('/question/type/stack/tidyquestion.php', $linkparams);
        }
    
        public function get_extra_question_bank_actions(stdClass $question): array {
            $actions = parent::get_extra_question_bank_actions($question);
    
            $linkparams = $this->get_question_url_params($question);
    
    
            // Directly link to question tests and deployed variants.
            if (question_has_capability_on($question, 'view')) {
                $actions[] = new \action_menu_link_secondary(
                        new moodle_url('/question/type/stack/questiontestrun.php', $linkparams),
                        new \pix_icon('t/approve', ''),
                        get_string('runquestiontests', 'qtype_stack'));
            }
    
            // Directly link to tidy question script.
            if (question_has_capability_on($question, 'view')) {
                $actions[] = new \action_menu_link_secondary(
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                        new moodle_url('/question/type/stack/tidyquestion.php', $linkparams),
    
                        new \pix_icon('t/edit', ''),
                        get_string('tidyquestion', 'qtype_stack'));
            }
    
            return $actions;
        }
    
    
        public function delete_question($questionid, $contextid) {
            global $DB;
    
    Tim Hunt's avatar
    Tim Hunt committed
            $this->delete_question_tests($questionid);
    
            $DB->delete_records('qtype_stack_deployed_seeds', array('questionid' => $questionid));
    
            $DB->delete_records('qtype_stack_prt_nodes',      array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_prts',           array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_inputs',         array('questionid' => $questionid));
    
            $DB->delete_records('qtype_stack_options',        array('questionid' => $questionid));
    
            parent::delete_question($questionid, $contextid);
        }
    
        public function move_files($questionid, $oldcontextid, $newcontextid) {
            global $DB;
            $fs = get_file_storage();
    
            parent::move_files($questionid, $oldcontextid, $newcontextid);
    
            $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
    
    
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'specificfeedback',    $questionid);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'questiondescription', $questionid);
    
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'prtcorrect',          $questionid);
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'prtpartiallycorrect', $questionid);
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'prtincorrect',        $questionid);
    
            $nodeids = $DB->get_records_menu('qtype_stack_prt_nodes', array('questionid' => $questionid), 'id', 'id,1');
            foreach ($nodeids as $nodeid => $notused) {
                $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodetruefeedback', $nodeid);
                $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodefalsefeedback', $nodeid);
            }
        }
    
        protected function delete_files($questionid, $contextid) {
            global $DB;
            $fs = get_file_storage();
    
            parent::delete_files($questionid, $contextid);
    
            $this->delete_files_in_hints($questionid, $contextid);
    
    
            $fs->delete_area_files($contextid, 'qtype_stack', 'specificfeedback',    $questionid);
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            $fs->delete_area_files($contextid, 'qtype_stack', 'questiondescription', $questionid);
    
            $fs->delete_area_files($contextid, 'qtype_stack', 'prtcorrect',          $questionid);
            $fs->delete_area_files($contextid, 'qtype_stack', 'prtpartiallycorrect', $questionid);
            $fs->delete_area_files($contextid, 'qtype_stack', 'prtincorrect',        $questionid);
    
            $nodeids = $DB->get_records_menu('qtype_stack_prt_nodes', array('questionid' => $questionid), 'id', 'id,1');
            foreach ($nodeids as $nodeid => $notused) {
                $fs->delete_area_files($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodetruefeedback', $nodeid);
                $fs->delete_area_files($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodefalsefeedback', $nodeid);
            }
        }
    
    Tim Hunt's avatar
    Tim Hunt committed
        /**
         * Save a set of question tests for a question, replacing any existing tests.
         * @param int $questionid the question id of the question we are manipulating the tests for.
         * @param array $testcases testcase number => stack_question_test
         */
        public function save_question_tests($questionid, $testcases) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
            $this->delete_question_tests($questionid);
            foreach ($testcases as $number => $testcase) {
                $this->save_question_test($questionid, $testcase, $number);
            }
            $transaction->allow_commit();
        }
    
        /**
         * Save a question tests for a question, either replacing the test at a given
         * number, or adding a new test, either with a given number, or taking the
         * first unused number.
         * @param int $questionid the question id of the question we are manipulating the tests for.
         * @param stack_question_test $qtest
         * @param int $testcases testcase number to replace/add. If not given, the first unused number is found.
         */
        public function save_question_test($questionid, stack_question_test $qtest, $testcase = null) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
    
            // Limit the length of descriptions.
            $description = substr($qtest->description, 0, 255);
    
    
    Tim Hunt's avatar
    Tim Hunt committed
            if (!$testcase || !$DB->record_exists('qtype_stack_qtests',
                    array('questionid' => $questionid, 'testcase' => $testcase))) {
                // Find the first unused testcase number.
                $testcase = $DB->get_field_sql('
                            SELECT MIN(qt.testcase) + 1
                            FROM (
                                SELECT testcase FROM {qtype_stack_qtests} WHERE questionid = ?
                                UNION
                                SELECT 0
                            ) qt
                            LEFT JOIN {qtype_stack_qtests} qt2 ON qt2.questionid = ? AND
                                                                  qt2.testcase = qt.testcase + 1
                            WHERE qt2.id IS NULL
                            ', array($questionid, $questionid));
                $testcasedata = new stdClass();
                $testcasedata->questionid = $questionid;
                $testcasedata->testcase = $testcase;
    
                $testcasedata->description = $description;
    
                $testcasedata->timemodified = time();
    
    Tim Hunt's avatar
    Tim Hunt committed
                $DB->insert_record('qtype_stack_qtests', $testcasedata);
    
            } else {
                $DB->set_field('qtype_stack_qtests', 'timemodified', time(),
                        array('questionid' => $questionid, 'testcase' => $testcase));
    
                $DB->set_field('qtype_stack_qtests', 'description', $description,
                    array('questionid' => $questionid, 'testcase' => $testcase));
    
    Tim Hunt's avatar
    Tim Hunt committed
            }
    
            // Save the input data.
            $DB->delete_records('qtype_stack_qtest_inputs', array('questionid' => $questionid, 'testcase' => $testcase));
            foreach ($qtest->inputs as $name => $value) {
                $testinput = new stdClass();
                $testinput->questionid = $questionid;
                $testinput->testcase   = $testcase;
                $testinput->inputname  = $name;
                $testinput->value      = $value;
                $DB->insert_record('qtype_stack_qtest_inputs', $testinput);
            }
    
            // Save the expected outcome data.
            $DB->delete_records('qtype_stack_qtest_expected', array('questionid' => $questionid, 'testcase' => $testcase));
            foreach ($qtest->expectedresults as $prtname => $expectedresults) {
                $expected = new stdClass();
                $expected->questionid         = $questionid;
                $expected->testcase           = $testcase;
                $expected->prtname            = $prtname;
    
                if ($expectedresults->score === '' || $expectedresults->score === null) {
    
                    $expected->expectedscore = (float) $expectedresults->score;
    
                }
                if ($expectedresults->penalty === '' || $expectedresults->penalty === null) {
    
                    $expected->expectedpenalty = stack_utils::fix_approximate_thirds(
                            (float) $expectedresults->penalty);
    
                $expected->expectedanswernote = $expectedresults->answernotes[0];
    
    Tim Hunt's avatar
    Tim Hunt committed
                $DB->insert_record('qtype_stack_qtest_expected', $expected);
            }
    
            $transaction->allow_commit();
        }
    
    
        /**
         * Deploy a variant of a question.
         * @param int $questionid the question id.
         * @param int $seed the seed to deploy.
         */
        public function deploy_variant($questionid, $seed) {
            global $DB;
    
            $record = new stdClass();
            $record->questionid = $questionid;
            $record->seed = $seed;
            $DB->insert_record('qtype_stack_deployed_seeds', $record);
    
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Un-deploy a variant of a question.
         * @param int $questionid the question id.
         * @param int $seed the seed to un-deploy.
         */
        public function undeploy_variant($questionid, $seed) {
            global $DB;
    
            $DB->delete_records('qtype_stack_deployed_seeds',
                    array('questionid' => $questionid, 'seed' => $seed));
    
            $this->notify_question_edited($questionid);
        }
    
    
        /**
         * Rename an input in the question data. It is the caller's responsibility
         * to ensure that the $to name will not violate any unique constraints.
         * @param int $questionid the question id.
         * @param string $from the input to rename.
         * @param string $to the new name to give it.
         */
        public function rename_input($questionid, $from, $to) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            // Place-holders in the question text.
            $questiontext = $DB->get_field('question', 'questiontext', array('id' => $questionid));
            $questiontext = str_replace(array("[[input:{$from}]]", "[[validation:{$from}]]"),
                    array("[[input:{$to}]]", "[[validation:{$to}]]"), $questiontext);
            $DB->set_field('question', 'questiontext', $questiontext, array('id' => $questionid));
    
            // Input names in question test data.
            $DB->set_field('qtype_stack_qtest_inputs', 'inputname', $to,
                    array('questionid' => $questionid, 'inputname' => $from));
    
            // The input itself.
            $DB->set_field('qtype_stack_inputs', 'name', $to,
                    array('questionid' => $questionid, 'name' => $from));
    
            $regex = '~\b' . preg_quote($from, '~') . '\b~';
    
    Chris Sangwin's avatar
    Chris Sangwin committed
            // Where the input name appears in expressions in PRTs.
    
            $prts = $DB->get_records('qtype_stack_prts', array('questionid' => $questionid),
                        'id, feedbackvariables');
            foreach ($prts as $prt) {
    
    Chris Sangwin's avatar
    Chris Sangwin committed
                $prt->feedbackvariables = preg_replace($regex, $to, $prt->feedbackvariables, -1, $changes);
    
                if ($changes) {
                    $DB->update_record('qtype_stack_prts', $prt);
                }
            }
    
            // Where the input name appears in expressions in PRT node.
            $nodes = $DB->get_records('qtype_stack_prt_nodes', array('questionid' => $questionid),
                            'id, sans, tans, testoptions, truefeedback, falsefeedback');
            foreach ($nodes as $node) {
                $changes = false;
                $node->sans = preg_replace($regex, $to, $node->sans, -1, $count);
                $changes = $changes || $count;
                $node->tans = preg_replace($regex, $to, $node->tans, -1, $count);
                $changes = $changes || $count;
                $node->testoptions = preg_replace($regex, $to, $node->testoptions, -1, $count);
                $changes = $changes || $count;
                $node->truefeedback = preg_replace($regex, $to, $node->truefeedback, -1, $count);
                $changes = $changes || $count;
                $node->falsefeedback = preg_replace($regex, $to, $node->falsefeedback, -1, $count);
                $changes = $changes || $count;
                if ($changes) {
                    $DB->update_record('qtype_stack_prt_nodes', $node);
                }
            }
    
    
            // If someone plays with input names we need to clear compiledcache.
    
            $sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
    
            $params[] = '{}';
            $params[] = $questionid;
            $DB->execute($sql, $params);
    
    
            $transaction->allow_commit();
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Rename a PRT in the question data. It is the caller's responsibility
         * to ensure that the $to name will not violate any unique constraints.
         * @param int $questionid the question id.
         * @param string $from the PRT to rename.
         * @param string $to the new name to give it.
         */
        public function rename_prt($questionid, $from, $to) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            // Place-holders in the question text.
            $questiontext = $DB->get_field('question', 'questiontext', array('id' => $questionid));
            $questiontext = str_replace("[[feedback:{$from}]]", "[[feedback:{$to}]]", $questiontext);
            $DB->set_field('question', 'questiontext', $questiontext, array('id' => $questionid));
    
            // Place-holders in the specific feedback.
            $specificfeedback = $DB->get_field('qtype_stack_options', 'specificfeedback',
                    array('questionid' => $questionid));
            $specificfeedback = str_replace("[[feedback:{$from}]]", "[[feedback:{$to}]]", $specificfeedback);
            $DB->set_field('qtype_stack_options', 'specificfeedback', $specificfeedback,
                    array('questionid' => $questionid));
    
            // PRT names in question test data.
            $DB->set_field('qtype_stack_qtest_expected', 'prtname', $to,
                    array('questionid' => $questionid, 'prtname' => $from));
    
            // The PRT name in its nodes.
            $DB->set_field('qtype_stack_prt_nodes', 'prtname', $to,
                    array('questionid' => $questionid, 'prtname' => $from));
    
            // The PRT itself.
            $DB->set_field('qtype_stack_prts', 'name', $to,
                    array('questionid' => $questionid, 'name' => $from));
    
    
            // If someone plays with PRT names we need to clear compiledcache.
    
            $sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
    
            $params[] = '{}';
            $params[] = $questionid;
            $DB->execute($sql, $params);
    
    
            $transaction->allow_commit();
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Rename a PRT node in the question data. It is the caller's responsibility
         * to ensure that the $to name will not violate any unique constraints.
         * @param int $questionid the question id.
         * @param string $prtname the PRT that the node belongs to.
         * @param string $from the input to rename.
         * @param string $to the new name to give it.
         */
        public function rename_prt_node($questionid, $prtname, $from, $to) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            // The PRT node itself.
            $DB->set_field('qtype_stack_prt_nodes', 'nodename', $to,
                    array('questionid' => $questionid, 'prtname' => $prtname, 'nodename' => $from));
    
            // True next node links.
            $DB->set_field('qtype_stack_prt_nodes', 'truenextnode', $to,
                    array('questionid' => $questionid, 'prtname' => $prtname, 'truenextnode' => $from));
    
            // False next node links.
            $DB->set_field('qtype_stack_prt_nodes', 'falsenextnode', $to,
                    array('questionid' => $questionid, 'prtname' => $prtname, 'falsenextnode' => $from));
    
    
            // PRT first node link.
            $DB->set_field('qtype_stack_prts', 'firstnodename', $to,
                    array('questionid' => $questionid, 'name' => $prtname, 'firstnodename' => $from));
    
    
            // If someone plays with PRT node names we need to clear compiledcache.
    
            $sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
    
            $params[] = '{}';
            $params[] = $questionid;
            $DB->execute($sql, $params);
    
    
            $transaction->allow_commit();
            $this->notify_question_edited($questionid);
        }
    
    
        /**
         * From Moodle 2.4 onwards, we need to clear the entry from the question
         * cache if a question definition changes. This method deals with doing
         * that without causing errors on earlier versions of Moodle.
         * @param int $questionid the question id to clear from the cache.
         */
        protected function notify_question_edited($questionid) {
            if (method_exists('question_bank', 'notify_question_edited')) {
                call_user_func(array('question_bank', 'notify_question_edited'), $questionid);
            }
        }
    
    
    Tim Hunt's avatar
    Tim Hunt committed
        /**
         * Load all the question tests for a question.
         * @param int $questionid the id of the question to load the tests for.
         * @return array testcase number => stack_question_test
         */
        public function load_question_tests($questionid) {
    
            global $DB;
    
            $testinputdata = $DB->get_records('qtype_stack_qtest_inputs',
    
    Tim Hunt's avatar
    Tim Hunt committed
                    array('questionid' => $questionid), 'testcase, inputname');
    
            $testinputs = array();
            foreach ($testinputdata as $data) {
                $testinputs[$data->testcase][$data->inputname] = $data->value;
            }
    
            $testcasenumbers = $DB->get_records_menu('qtype_stack_qtests',
    
                    array('questionid' => $questionid), 'testcase', 'testcase, description');
    
            $testcases = array();
    
            foreach ($testcasenumbers as $number => $description) {
    
                if (!array_key_exists($number, $testinputs)) {
                    $testinputs[$number] = array();
    
                $testcase = new stack_question_test($description, $testinputs[$number], $number);