Merge branch 'MDL-54724-master' of https://github.com/cameron1729/moodle

This commit is contained in:
Víctor Déniz
2022-03-10 23:21:24 +00:00
11 changed files with 230 additions and 14 deletions
+7 -1
View File
@@ -354,8 +354,14 @@ function question_delete_question($questionid): void {
WHERE q.id = ?';
$questiondata = $DB->get_record_sql($sql, [$question->id]);
$questionstocheck = [$question->id];
if ($question->parent !== 0) {
$questionstocheck[] = $question->parent;
}
// Do not delete a question if it is used by an activity module
if (questions_in_use([$question->id])) {
if (questions_in_use($questionstocheck)) {
return;
}
+18
View File
@@ -1301,6 +1301,24 @@ abstract class qbehaviour_walkthrough_test_base extends question_testcase {
// Does not currently verify hint text.
return new question_contains_tag_with_attribute('div', 'class', 'hint');
}
/**
* Returns an expectation that a string contains a corrupted question notification.
*
* @return question_pattern_expectation an expectation for use with check_current_output.
*/
protected function get_contains_corruption_notification() {
return new question_pattern_expectation('/' . preg_quote(get_string('corruptedquestion', 'qtype_multianswer'), '/') . '/');
}
/**
* Returns an expectation that a string contains a corrupted subquestion message.
*
* @return question_pattern_expectation an expectation for use with check_current_output.
*/
protected function get_contains_corrupted_subquestion_message() {
return new question_pattern_expectation('/' . preg_quote(get_string('missingsubquestion', 'qtype_multianswer'), '/') . '/');
}
}
/**
@@ -110,7 +110,7 @@ class restore_qtype_multianswer_plugin extends restore_qtype_plugin {
foreach ($sequencearr as $key => $question) {
$sequencearr[$key] = $this->get_mappingid('question', $question);
}
$sequence = implode(',', $sequencearr);
$sequence = implode(',', array_filter($sequencearr));
$DB->set_field('question_multianswer', 'sequence', $sequence,
array('id' => $rec->id));
if (!empty($sequence)) {
@@ -150,7 +150,8 @@ class qtype_multianswer_edit_form extends question_edit_form {
$storemess = '';
if (isset($this->savedquestiondisplay->options->questions[$sub]->qtype) &&
$this->savedquestiondisplay->options->questions[$sub]->qtype !=
$this->questiondisplay->options->questions[$sub]->qtype) {
$this->questiondisplay->options->questions[$sub]->qtype &&
$this->savedquestiondisplay->options->questions[$sub]->qtype != 'subquestion_replacement') {
$this->qtypechange = true;
$storemess = ' ' . html_writer::tag('span', get_string(
'storedqtype', 'qtype_multianswer', question_bank::get_qtype_name(
@@ -276,6 +277,8 @@ class qtype_multianswer_edit_form extends question_edit_form {
case 'numerical':
$parsableanswerdef .= 'NUMERICAL:';
break;
case 'subquestion_replacement':
continue 2;
default:
print_error('unknownquestiontype', 'question', '',
$wrapped->qtype);
@@ -27,6 +27,7 @@ $string['confirmquestionsaveasedited'] = 'I confirm that I want the question to
$string['confirmsave'] = 'Confirm then save {$a}';
$string['correctanswer'] = 'Correct answer';
$string['correctanswerandfeedback'] = 'Correct answer and feedback';
$string['corruptedquestion'] = 'This question is corrupted and contains subquestions that are not present in your system.';
$string['decodeverifyquestiontext'] = 'Decode and verify the question text';
$string['invalidmultianswerquestion'] = 'Invalid embedded answers (Cloze) question ({$a}).';
$string['layout'] = 'Layout';
@@ -36,6 +37,7 @@ $string['layoutmultiple_vertical'] = 'Vertical column of checkboxes';
$string['layoutselectinline'] = 'Drop-down menu in-line in the text';
$string['layoutundefined'] = 'Undefined layout';
$string['layoutvertical'] = 'Vertical column of radio buttons';
$string['missingsubquestion'] = 'This subquestion is missing from your system and cannot be displayed.';
$string['nooptionsforsubquestion'] = 'Unable to get options for question part # {$a->sub} (question->id={$a->id})';
$string['noquestions'] = 'The Cloze(multianswer) question "<strong>{$a}</strong>" does not contain any question';
$string['pleaseananswerallparts'] = 'Please answer all parts of the question.';
+2 -2
View File
@@ -110,7 +110,7 @@ class qtype_multianswer_question extends question_graded_automatically_with_coun
$fractionmax += $subq->defaultmark;
$fractionsum += $subq->defaultmark * $subq->get_min_fraction();
}
return $fractionsum / $fractionmax;
return $fractionsum / (!empty($this->subquestions) ? $fractionmax : 1);
}
public function get_max_fraction() {
@@ -120,7 +120,7 @@ class qtype_multianswer_question extends question_graded_automatically_with_coun
$fractionmax += $subq->defaultmark;
$fractionsum += $subq->defaultmark * $subq->get_max_fraction();
}
return $fractionsum / $fractionmax;
return $fractionsum / (!empty($this->subquestions) ? $fractionmax : 1);
}
public function get_expected_data() {
+85 -7
View File
@@ -38,18 +38,86 @@ require_once($CFG->dirroot . '/question/type/numerical/questiontype.php');
*/
class qtype_multianswer extends question_type {
/**
* Generate a subquestion replacement question class.
*
* Due to a bug, subquestions can be lost (see MDL-54724). This class exists to take
* the place of those lost questions so that the system can keep working and inform
* the user of the corrupted data.
*
* @return question_automatically_gradable The replacement question class.
*/
public static function deleted_subquestion_replacement(): question_automatically_gradable {
return new class implements question_automatically_gradable {
public $qtype;
public function __construct() {
$this->qtype = new class() {
public function name() {
return 'subquestion_replacement';
}
};
}
public function is_gradable_response(array $response) {
return false;
}
public function is_complete_response(array $response) {
return false;
}
public function is_same_response(array $prevresponse, array $newresponse) {
return false;
}
public function summarise_response(array $response) {
return '';
}
public function un_summarise_response(string $summary) {
return [];
}
public function classify_response(array $response) {
return [];
}
public function get_validation_error(array $response) {
return '';
}
public function grade_response(array $response) {
return [];
}
public function get_hint($hintnumber, question_attempt $qa) {
return;
}
public function get_right_answer_summary() {
return null;
}
};
}
public function can_analyse_responses() {
return false;
}
public function get_question_options($question) {
global $DB, $OUTPUT;
global $DB;
parent::get_question_options($question);
// Get relevant data indexed by positionkey from the multianswers table.
$sequence = $DB->get_field('question_multianswer', 'sequence',
array('question' => $question->id), MUST_EXIST);
if (empty($sequence)) {
$question->options->questions = [];
return true;
}
$wrappedquestions = $DB->get_records_list('question', 'id',
explode(',', $sequence), 'id ASC');
@@ -59,13 +127,19 @@ class qtype_multianswer extends question_type {
$val++;
});
// If a question is lost, the corresponding index is null
// so this null convention is used to test $question->options->questions
// before using the values.
// First all possible questions from sequence are nulled
// then filled with the data if available in $wrappedquestions.
// Due to a bug, questions can be lost (see MDL-54724). So we first fill the question
// options with this dummy "replacement" type. These are overridden in the loop below
// leaving behind only those questions which no longer exist. The renderer then looks
// for this deleted type to display information to the user about the corrupted question
// data.
foreach ($sequence as $seq) {
$question->options->questions[$seq] = '';
$question->options->questions[$seq] = (object)[
'qtype' => 'subquestion_replacement',
'defaultmark' => 1,
'options' => (object)[
'answers' => []
]
];
}
foreach ($wrappedquestions as $wrapped) {
@@ -216,6 +290,10 @@ class qtype_multianswer extends question_type {
$i += 1;
}
foreach ($questiondata->options->questions as $key => $subqdata) {
if ($subqdata->qtype == 'subquestion_replacement') {
continue;
}
$subqdata->contextid = $questiondata->contextid;
if ($subqdata->qtype == 'multichoice') {
$answerregs = array();
+27 -1
View File
@@ -46,17 +46,38 @@ class qtype_multianswer_renderer extends qtype_renderer {
$output = '';
$subquestions = array();
$missingsubquestions = false;
foreach ($question->textfragments as $i => $fragment) {
if ($i > 0) {
$index = $question->places[$i];
$questionisvalid = !empty($question->subquestions[$index]) &&
$question->subquestions[$index]->qtype->name() !== 'subquestion_replacement';
if (!$questionisvalid) {
$missingsubquestions = true;
$questionreplacement = qtype_multianswer::deleted_subquestion_replacement();
// It is possible that the subquestion index does not exist. When corrupted quizzes (see MDL-54724) are
// restored, the sequence column of mdl_quiz_multianswer can be empty, in this case
// qtype_multianswer::get_question_options cannot fill in deleted questions, so we need to do it here.
$question->subquestions[$index] = $question->subquestions[$index] ?? $questionreplacement;
}
$token = 'qtypemultianswer' . $i . 'marker';
$token = '<span class="nolink">' . $token . '</span>';
$output .= $token;
$subquestions[$token] = $this->subquestion($qa, $options, $index,
$question->subquestions[$index]);
}
$output .= $fragment;
}
if ($missingsubquestions) {
$output = $this->notification(get_string('corruptedquestion', 'qtype_multianswer'), 'error') . $output;
}
$output = $question->format_text($output, $question->questiontextformat,
$qa, 'question', 'questiontext', $question->id);
$output = str_replace(array_keys($subquestions), array_values($subquestions), $output);
@@ -78,7 +99,7 @@ class qtype_multianswer_renderer extends qtype_renderer {
}
public function subquestion(question_attempt $qa,
question_display_options $options, $index, question_graded_automatically $subq) {
question_display_options $options, $index, question_automatically_gradable $subq) {
$subtype = $subq->qtype->name();
if ($subtype == 'numerical' || $subtype == 'shortanswer') {
@@ -99,6 +120,11 @@ class qtype_multianswer_renderer extends qtype_renderer {
$subrenderer = 'multichoice_vertical';
}
}
} else if ($subtype == 'subquestion_replacement') {
return html_writer::div(
get_string('missingsubquestion', 'qtype_multianswer'),
'notifyproblem'
);
} else {
throw new coding_exception('Unexpected subquestion type.', $subq);
}
@@ -20,6 +20,7 @@ use qtype_multianswer;
use qtype_multianswer_edit_form;
use qtype_multichoice_base;
use question_bank;
use test_question_maker;
defined('MOODLE_INTERNAL') || die();
@@ -360,4 +361,43 @@ class question_type_test extends \advanced_testcase {
}
}
}
/**
* Test get_question_options.
*
* @covers \qtype_multianswer::get_question_options
*/
public function test_get_question_options() {
global $DB;
$this->resetAfterTest(true);
$syscontext = \context_system::instance();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $generator->create_question_category(['contextid' => $syscontext->id]);
$fromform = test_question_maker::get_question_form_data('multianswer', 'twosubq');
$fromform->category = $category->id . ',' . $syscontext->id;
$question = new \stdClass();
$question->category = $category->id;
$question->qtype = 'multianswer';
$question->createdby = 0;
$question = $this->qtype->save_question($question, $fromform);
$questiondata = question_bank::load_question_data($question->id);
$questiontodeletekey = array_keys($questiondata->options->questions)[0];
$questiontodelete = $questiondata->options->questions[$questiontodeletekey];
$this->assertCount(2, $questiondata->options->questions);
$this->assertEquals('shortanswer', $questiondata->options->questions[$questiontodeletekey]->qtype);
// Forcibly delete a subquestion to ensure get_question_options replaces it.
$DB->delete_records('question', ['id' => $questiontodelete->id]);
$this->qtype->get_question_options($questiondata);
$this->assertCount(2, $questiondata->options->questions);
$this->assertEquals('subquestion_replacement', $questiondata->options->questions[$questiontodeletekey]->qtype);
}
}
@@ -16,14 +16,18 @@
namespace qtype_multianswer;
use qtype_multianswer;
use question_bank;
use question_display_options;
use question_hint_with_parts;
use question_state;
use test_question_maker;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
require_once($CFG->dirroot . '/question/type/multianswer/questiontype.php');
/**
@@ -530,4 +534,43 @@ class walkthrough_test extends \qbehaviour_walkthrough_test_base {
$this->get_contains_partcorrect_expectation(),
$this->get_does_not_contain_validation_error_expectation());
}
/**
* Test corrupted question display.
*
* @covers \qtype_multianswer_renderer::subquestion
*/
public function test_corrupted_question() {
global $DB;
$syscontext = \context_system::instance();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$category = $generator->create_question_category(['contextid' => $syscontext->id]);
$fromform = test_question_maker::get_question_form_data('multianswer', 'twosubq');
$fromform->category = $category->id . ',' . $syscontext->id;
$question = new \stdClass();
$question->category = $category->id;
$question->qtype = 'multianswer';
$question->createdby = 0;
$question = (new qtype_multianswer)->save_question($question, $fromform);
$questiondata = question_bank::load_question_data($question->id);
$questiontodeletekey = array_keys($questiondata->options->questions)[0];
$questiontodelete = $questiondata->options->questions[$questiontodeletekey];
$DB->delete_records('question', ['id' => $questiontodelete->id]);
question_bank::notify_question_edited($question->id);
$question = question_bank::load_question($question->id);
$this->start_attempt_at_question($question, 'deferredfeedback', 2);
$this->check_current_output(
$this->get_contains_marked_out_of_summary(),
$this->get_does_not_contain_feedback_expectation(),
$this->get_does_not_contain_validation_error_expectation(),
$this->get_contains_corruption_notification(),
$this->get_contains_corrupted_subquestion_message(),
);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'qtype_multianswer';
$plugin->version = 2021052500;
$plugin->version = 2021052500.01;
$plugin->requires = 2021052500;
$plugin->dependencies = array(