Newer
Older
// 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');
Chris Sangwin
committed
require_once(__DIR__ . '/stack/input/factory.class.php');
require_once(__DIR__ . '/stack/answertest/controller.class.php');
require_once(__DIR__ . '/stack/cas/keyval.class.php');
Matti Harjula
committed
require_once(__DIR__ . '/stack/cas/castext2/castext2_evaluatable.class.php');
Matti Harjula
committed
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');
Chris Sangwin
committed
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 {
Chris Sangwin
committed
/** @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);
Tim Hunt
committed
// 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',
'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);
Chris Sangwin
committed
$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
committed
$options->questionnote = '';
$options->specificfeedback = '';
$options->prtcorrect = '';
$options->prtpartiallycorrect = '';
$options->prtincorrect = '';
Chris Sangwin
committed
$options->stackversion = get_config('qtype_stack', 'version');
$options->id = $DB->insert_record('qtype_stack_options', $options);
$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;
$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'];
$options->multiplicationsign = $fromform->multiplicationsign;
$options->sqrtsign = $fromform->sqrtsign;
$options->complexno = $fromform->complexno;
$options->inversetrig = $fromform->inversetrig;
$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);
Chris Sangwin
committed
$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->id = $DB->insert_record('qtype_stack_inputs', $input);
}
$input->type = $fromform->{$inputname . 'type'};
$input->tans = $fromform->{$inputname . 'modelans'};
$input->boxsize = $fromform->{$inputname . 'boxsize'};
Chris Sangwin
committed
// TODO: remove this when we remove strictsyntax from the DB.
$input->strictsyntax = true;
$input->insertstars = $fromform->{$inputname . 'insertstars'};
$input->syntaxhint = $fromform->{$inputname . 'syntaxhint'};
$input->syntaxattribute = $fromform->{$inputname . 'syntaxattribute'};
$input->forbidwords = $fromform->{$inputname . 'forbidwords'};
Chris Sangwin
committed
$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));
}
Chris Sangwin
committed
$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);
}
$node->description = $fromform->{$prtname . 'description'}[$nodename];
$node->answertest = $fromform->{$prtname . 'answertest'}[$nodename];
$node->sans = $fromform->{$prtname . 'sans'}[$nodename];
$node->tans = $fromform->{$prtname . 'tans'}[$nodename];
// 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.
// 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];
Chris Sangwin
committed
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];
Chris Sangwin
committed
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);
}
$this->save_hints($fromform);
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.
$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, ' .
'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',
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 = ?
}
protected function initialise_question_instance(question_definition $question, $questiondata) {
parent::initialise_question_instance($question, $questiondata);
Chris Sangwin
committed
$question->stackversion = $questiondata->options->stackversion;
$question->questionvariables = $questiondata->options->questionvariables;
$question->questionnote = $questiondata->options->questionnote;
$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();
$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);
$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();
Huong Nguyen
committed
foreach (stack_utils::extract_placeholders($question->questiontext, 'input') as $name) {
$inputdata = $questiondata->inputs[$name];
Chris Sangwin
committed
'strictSyntax' => true,
'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(
$inputdata->type, $inputdata->name, $inputdata->tans, $question->options, $parameters);
$prtnames = array_keys($this->get_prt_names_from_question($question->questiontext, $question->specificfeedback));
// 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;
}
}
if ($questiondata->prts && !$allformative && $totalvalue < 0.0000001) {
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);
Chris Sangwin
committed
foreach ($prtnames as $name) {
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) {
$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;
}
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
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(
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;
$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);
$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);
$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);
}
}
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
/**
* 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);
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();
$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));
}
// 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) {
Chris Sangwin
committed
$expected->expectedscore = null;
Chris Sangwin
committed
$expected->expectedscore = (float) $expectedresults->score;
}
if ($expectedresults->penalty === '' || $expectedresults->penalty === null) {
Chris Sangwin
committed
$expected->expectedpenalty = null;
$expected->expectedpenalty = stack_utils::fix_approximate_thirds(
(float) $expectedresults->penalty);
$expected->expectedanswernote = $expectedresults->answernotes[0];
$DB->insert_record('qtype_stack_qtest_expected', $expected);
}
$transaction->allow_commit();
}
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
/**
* 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);
}
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
/**
* 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~';
// 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) {
$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);
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
$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);
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
$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);
}
}
/**
* 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',
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');
foreach ($testcasenumbers as $number => $description) {
if (!array_key_exists($number, $testinputs)) {
$testinputs[$number] = array();
Chris Sangwin
committed
}
$testcase = new stack_question_test($description, $testinputs[$number], $number);