Skip to content

Commit f1ab403

Browse files
authored
Merge pull request #1458 from maths/broken-save
WIP - Allow saving of a broken question
2 parents 3a755cd + a1da5d3 commit f1ab403

21 files changed

Lines changed: 251 additions & 24 deletions

adminui/dependencies.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@
9898
echo $OUTPUT->single_button(
9999
new moodle_url($PAGE->url, ['todo' => 1, 'sesskey' => sesskey()]),
100100
'Find "todo"');
101+
echo $OUTPUT->single_button(
102+
new moodle_url($PAGE->url, ['broken' => 1, 'sesskey' => sesskey()]),
103+
'Find "broken"');
101104

102105
if (data_submitted() && optional_param('includes', false, PARAM_BOOL)) {
103106
/*
@@ -329,4 +332,37 @@
329332
echo '</tbody></table>';
330333
}
331334

335+
if (data_submitted() && optional_param('broken', false, PARAM_BOOL)) {
336+
/*
337+
* Question marked as broken.
338+
*/
339+
$qs = $DB->get_recordset_sql('SELECT q.id as questionid, q.name as name
340+
FROM {question} q
341+
JOIN {question_versions} qv ON qv.questionid = q.id
342+
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
343+
JOIN {qtype_stack_options} o ON q.id = o.questionid
344+
WHERE o.isbroken = 1
345+
AND qv.version = (
346+
SELECT MAX(version)
347+
FROM {question_versions} v
348+
WHERE v.questionbankentryid = qbe.id
349+
)');
350+
echo '<h4>Questions with most recent version marked as broken</h4>';
351+
echo '<table><thead><tr><th>Question</th><th>Version</th></thead><tbody>';
352+
// Load the whole question, simpler to get the contexts correct that way.
353+
foreach ($qs as $item) {
354+
$q = question_bank::load_question($item->questionid);
355+
list($context, $seed, $urlparams) = qtype_stack_setup_question_test_page($q);
356+
$editurl = new \moodle_url('/question/bank/editquestion/question.php', [
357+
'id' => $urlparams['questionid'],
358+
'returnURL' => new moodle_url('/question/type/stack/adminui/dependencies.php'),
359+
'courseid' => $urlparams['courseid'],
360+
]);
361+
echo '<tr><td>' . $item->name . ' ' .
362+
$OUTPUT->action_icon($editurl, new pix_icon('t/edit', get_string('edit'))) .
363+
'</td><td>' . $q->version . '</td></tr>';
364+
}
365+
echo '</tbody></table>';
366+
}
367+
332368
echo $OUTPUT->footer();

api/util/StackQuestionLoader.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ public static function loadxml($xml, $includetests=false) {
121121
$question->variantsselectionseed =
122122
(string) $xmldata->question->variantsselectionseed ? (string) $xmldata->question->variantsselectionseed : '';
123123
$question->compiledcache = [];
124-
124+
$question->isbroken = (array) $xmldata->question->isbroken ? self::parseboolean($xmldata->question->isbroken) : 0;
125125
$question->options = new \stack_options();
126126
$question->options->set_option(
127127
'multiplicationsign',

backup/moodle2/backup_qtype_stack_plugin.class.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ protected function define_question_plugin_structure() {
5656
'decimals',
5757
'scientificnotation',
5858
'multiplicationsign', 'sqrtsign',
59-
'complexno', 'inversetrig', 'logicsymbol', 'matrixparens', 'variantsselectionseed',
59+
'complexno', 'inversetrig', 'logicsymbol', 'matrixparens', 'variantsselectionseed', 'isbroken',
6060
]);
6161

6262
$stackinputs = new backup_nested_element('stackinputs');

backup/moodle2/restore_qtype_stack_plugin.class.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ public static function convert_backup_to_questiondata(array $backupdata): stdCla
8686
if (!property_exists($questiondata->options, 'stackversion')) {
8787
$questiondata->options->stackversion = '';
8888
}
89+
if (!property_exists($questiondata->options, 'isbroken')) {
90+
$questiondata->options->isbroken = 0;
91+
}
8992

9093
if (!property_exists($questiondata->options, 'inversetrig')) {
9194
$questiondata->options->inversetrig = 'cos-1';
@@ -202,6 +205,10 @@ public function process_qtype_stack_options($data) {
202205
$data->stackversion = '';
203206
}
204207

208+
if (!property_exists($data, 'isbroken')) {
209+
$data->isbroken = 0;
210+
}
211+
205212
if (!property_exists($data, 'inversetrig')) {
206213
$data->inversetrig = 'cos-1';
207214
}

db/install.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
1010
<FIELD NAME="questionid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Foreign key link to question.id"/>
1111
<FIELD NAME="stackversion" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The version of the STACK plugin on which this question was authored."/>
12+
<FIELD NAME="isbroken" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Has this question been saved in a broken state."/>
1213
<FIELD NAME="questionvariables" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Maxima code that is run when the question is started to initialise variables."/>
1314
<FIELD NAME="specificfeedback" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="CAS text for the specific feedback for the question."/>
1415
<FIELD NAME="specificfeedbackformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The FORMAT_... for the specific feedback"/>

db/upgrade.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,17 @@ function xmldb_qtype_stack_upgrade($oldversion) {
973973
// STACK savepoint reached.
974974
upgrade_plugin_savepoint(true, 2024043000, 'qtype', 'stack');
975975
}
976+
977+
if ($oldversion < 2025042500) {
978+
$table = new xmldb_table('qtype_stack_options');
979+
$field = new xmldb_field('isbroken', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '0', 'stackversion');
980+
if (!$dbman->field_exists($table, $field)) {
981+
$dbman->add_field($table, $field);
982+
}
983+
984+
// STACK savepoint reached.
985+
upgrade_plugin_savepoint(true, 2025042500, 'qtype', 'stack');
986+
}
976987
// Add new upgrade blocks just above here.
977988

978989
// Check the version of the Maxima library code that comes with this version

edit_stack_form.php

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ public function set_data($question) {
118118
}
119119

120120
parent::set_data($question);
121+
// If the question is broken we run validation when the form is loaded to display errors.
122+
// We have to temporarily remove the broken flag from the form to stop the validation
123+
// being by-passed.
124+
if (!empty($this->question->options->isbroken)) {
125+
$mform = $this->_form;
126+
$mform->setDefault('isbroken', 0);
127+
$this->is_validated();
128+
$mform->setDefault('isbroken', 1);
129+
}
121130
}
122131

123132
/**
@@ -182,6 +191,11 @@ protected function definition() {
182191
stack_string('fixdollars'), stack_string('fixdollarslabel'));
183192
$mform->insertElementBefore($fixdollars, 'buttonar');
184193
$mform->addHelpButton('fixdollars', 'fixdollars', 'qtype_stack');
194+
$isbroken = $mform->createElement('checkbox', 'isbroken',
195+
stack_string('isbroken'), stack_string('isbrokenlabel'));
196+
$mform->setDefault('isbroken', (!empty($this->question->options->isbroken) ? 1 : 0));
197+
$mform->insertElementBefore($isbroken, 'buttonar');
198+
$mform->addHelpButton('isbroken', 'isbroken', 'qtype_stack');
185199
$mform->closeHeaderBefore('fixdollars');
186200

187201
// There is no un-closeHeaderBefore, so fake it.
@@ -511,10 +525,13 @@ protected function definition_input($inputname, MoodleQuickForm $mform, $counts)
511525
// Set a default for the new question.
512526
if ($inputname === self::DEFAULT_INPUT) {
513527
$mform->setDefault($inputname . 'modelans', self::DEFAULT_TEACHER_ANSWER);
528+
} else {
529+
// Default for all parts of an input required in order to save a broken question.
530+
$mform->setDefault($inputname . 'modelans', '');
514531
}
515532

516533
$mform->addElement('text', $inputname . 'boxsize', stack_string('boxsize'), ['size' => 3]);
517-
$mform->setDefault($inputname . 'boxsize', $this->stackconfig->inputboxsize);
534+
$mform->setDefault($inputname . 'boxsize', (int) $this->stackconfig->inputboxsize);
518535
$mform->setType($inputname . 'boxsize', PARAM_INT);
519536
$mform->addHelpButton($inputname . 'boxsize', 'boxsize', 'qtype_stack');
520537
$mform->hideIf($inputname . 'boxsize', $inputname . 'type', 'in',
@@ -589,6 +606,7 @@ protected function definition_input($inputname, MoodleQuickForm $mform, $counts)
589606
$mform->hideIf($inputname . 'showvalidation', $inputname . 'type', 'in', []);
590607

591608
$mform->addElement('text', $inputname . 'options', stack_string('inputextraoptions'), ['size' => 30]);
609+
$mform->setDefault($inputname . 'options', '');
592610
$mform->setType($inputname . 'options', PARAM_RAW);
593611
$mform->addHelpButton($inputname . 'options', 'inputextraoptions', 'qtype_stack');
594612
}
@@ -819,6 +837,7 @@ protected function data_preprocessing_options($question) {
819837
$question->questionsimplify = $opt->questionsimplify;
820838
$question->assumepositive = $opt->assumepositive;
821839
$question->assumereal = $opt->assumereal;
840+
$question->isbroken = $opt->isbroken;
822841

823842
return $question;
824843
}
@@ -836,7 +855,8 @@ protected function data_preprocessing_inputs($question) {
836855
foreach ($question->inputs as $inputname => $input) {
837856
$question->{$inputname . 'type'} = $input->type;
838857
$question->{$inputname . 'modelans'} = $input->tans;
839-
$question->{$inputname . 'boxsize'} = $input->boxsize;
858+
// Cast to int required to avoid erroneous validation messages on loading a broken question.
859+
$question->{$inputname . 'boxsize'} = (int) $input->boxsize;
840860
// TO-DO: remove this when we delete it from the DB.
841861
$question->{$inputname . 'strictsyntax'} = true;
842862
$question->{$inputname . 'insertstars'} = $input->insertstars;
@@ -973,9 +993,42 @@ protected function prepare_text_field($field, $text, $format, $itemid, $filearea
973993
// phpcs:ignore moodle.Commenting.MissingDocblock.Function
974994
public function validation($fromform, $files) {
975995
$errors = parent::validation($fromform, $files);
976-
977996
$qtype = new qtype_stack();
978-
return $qtype->validate_fromform($fromform, $errors);
997+
$allerrors = $qtype->validate_fromform($fromform, $errors, $this->question);
998+
$mustconfirm = (array_search(stack_string('youmustconfirm'), $allerrors) === false) ? false : true;
999+
// Add not saved warning but only when attempting to save, not on loading question.
1000+
// Moodle errors and confirmation issues will always be on save as question cannot be saved and thus
1001+
// cannot be loaded in this state even if marked as broken. If there are STACK issues, we only flag
1002+
// if question is not marked as broken and it's a submit.
1003+
if ($errors || $mustconfirm ||
1004+
($allerrors && empty($fromform['isbroken']) && !empty(optional_param_array('questiontext', [], PARAM_RAW)))) {
1005+
$errortext = stack_string('notsaved') . '<br>';
1006+
if ($errors) {
1007+
$errortext .= stack_string_error('moodleerrors') . '<br>';
1008+
}
1009+
if ($mustconfirm) {
1010+
$errortext .= stack_string_error('mustconfirm') . '<br>';
1011+
}
1012+
if (empty($fromform['isbroken']) && $allerrors != $errors) {
1013+
$errortext .= stack_string_error('stackerrors') . '<br>';
1014+
}
1015+
1016+
$allerrors['versioninfo'] = isset($allerrors['versioninfo']) ?
1017+
$errortext . ' ' . $allerrors['versioninfo'] : $errortext;
1018+
}
1019+
// Ignore STACK-specific validation if question is marked as broken unless
1020+
// a confirmation is required.
1021+
// Moodle validation errors always returned.
1022+
if (empty($fromform['isbroken'])) {
1023+
return $allerrors;
1024+
} else {
1025+
if ($errors || $mustconfirm) {
1026+
return $allerrors;
1027+
} else {
1028+
// This is going to be [].
1029+
return $errors;
1030+
}
1031+
}
9791032
}
9801033

9811034
// phpcs:ignore moodle.Commenting.MissingDocblock.Function

lang/en/qtype_stack.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
$string['exceptionmessage'] = '{$a}';
4040
$string['seekhelp'] = 'Please ask your teacher about this.';
4141
$string['runtimeerror'] = 'This question generated an unexpected internal error.';
42+
$string['questionbroken'] = 'The question has been marked as broken during editing.';
4243
$string['runtimefielderr'] = 'The field ""{$a->field}"" generated the following error: {$a->err}';
4344
$string['version'] = 'Version';
4445

@@ -140,6 +141,9 @@
140141
$string['fixdollarslabel'] = 'Replace <code>$...$</code> with <code>\(...\)</code>, <code>$$...$$</code> with <code>\[...\]</code> and <code>@...@</code> with <code>{@...@}</code> on save.';
141142
$string['fixdollars_help'] = 'This option is useful if are copying and pasting (or typing) TeX with <code>$...$</code> and <code>$$...$$</code> delimiters. Those delimiters will be replaced by the recommended delimiters during the save process.';
142143
$string['forbiddendoubledollars'] = 'Please use the delimiters <code>\(...\)</code> for inline maths and <code>\[...\]</code> for display maths. <code>$...$</code> and <code>$$...$$</code> are not permitted. There is an option at the end of the form to fix this automatically.';
144+
$string['isbroken'] = 'Save as broken';
145+
$string['isbrokenlabel'] = 'Mark the question as broken. This will allow the question to be saved even with most errors but it will not be displayed to students.';
146+
$string['isbroken_help'] = 'This option allows you to save your work even if a question is incomplete or has errors. You or a colleague can then return to work on it later. You will still need to supply required fields and tick the boxes to confirm any deletion of inputs or PRTs before the question will save.';
143147
$string['forbidfloat'] = 'Forbid float';
144148
$string['forbidfloat_help'] = 'If set to yes, then any answer of the student which has a floating point number will be rejected as invalid.';
145149
$string['forbidfloat_link'] = '%%WWWROOT%%/question/type/stack/doc/doc.php/Authoring/Inputs.md#Forbid_Floats';
@@ -400,6 +404,10 @@
400404
$string['variantsselectionseed_help'] = 'Normally you can leave this box blank. If, however, you want two different questions in a quiz to use the same random seed, then type the same string in this box for the two questions (and deploy the same set of random seeds, if you are using deployed variants) and the random seeds for the two questions will be synchronised.';
401405
$string['verifyquestionandupdate'] = 'Verify the question text and update the form';
402406
$string['youmustconfirm'] = 'You must confirm here.';
407+
$string['notsaved'] = '** QUESTION WAS NOT SAVED **';
408+
$string['mustconfirm'] = 'You have changes to confirm.';
409+
$string['moodleerrors'] = 'You have errors related to Moodle\'s basic question setup.';
410+
$string['stackerrors'] = 'You have errors in your question.';
403411

404412
// Strings used by input elements.
405413
$string['studentinputtoolong'] = 'Your input is longer than permitted by STACK.';

question.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,11 @@ class qtype_stack_question extends question_graded_automatically_with_countback
251251
*/
252252
public $pluginfiles = [];
253253

254+
/**
255+
* @var bool is this question marked as broken.
256+
*/
257+
public $isbroken = false;
258+
254259
/**
255260
* Make sure the cache is valid for the current response. If not, clear it.
256261
*
@@ -359,9 +364,13 @@ public function start_attempt(question_attempt_step $step, $variant) {
359364
* Once we know the random seed, we can initialise all the other parts of the question.
360365
*/
361366
public function initialise_question_from_seed() {
367+
// If the question is marked as broken skip straight to error output.
362368
// We can detect a logically faulty question by checking if the cache can
363369
// return anything if it can't then we can simply skip to the output of errors.
364-
if ($this->get_cached('units') !== null) {
370+
if ($this->isbroken) {
371+
// Question is marked as broken. Students should not see it.
372+
$this->runtimeerrors[stack_string('questionbroken')] = true;
373+
} else if ($this->get_cached('units') !== null) {
365374
// Build up the question session out of all the bits that need to go into it.
366375
// 1. question variables.
367376
$session = new stack_cas_session2([], $this->options, $this->seed);

0 commit comments

Comments
 (0)