1 |
efrain |
1 |
<?php
|
|
|
2 |
// This file is part of Moodle - http://moodle.org/
|
|
|
3 |
//
|
|
|
4 |
// Moodle is free software: you can redistribute it and/or modify
|
|
|
5 |
// it under the terms of the GNU General Public License as published by
|
|
|
6 |
// the Free Software Foundation, either version 3 of the License, or
|
|
|
7 |
// (at your option) any later version.
|
|
|
8 |
//
|
|
|
9 |
// Moodle is distributed in the hope that it will be useful,
|
|
|
10 |
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
11 |
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
12 |
// GNU General Public License for more details.
|
|
|
13 |
//
|
|
|
14 |
// You should have received a copy of the GNU General Public License
|
|
|
15 |
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
|
|
16 |
|
|
|
17 |
/**
|
|
|
18 |
* Question type class for the multi-answer question type.
|
|
|
19 |
*
|
|
|
20 |
* @package qtype
|
|
|
21 |
* @subpackage multianswer
|
|
|
22 |
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
|
|
|
23 |
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
|
24 |
*/
|
|
|
25 |
|
|
|
26 |
|
|
|
27 |
defined('MOODLE_INTERNAL') || die();
|
|
|
28 |
|
|
|
29 |
require_once($CFG->dirroot . '/question/type/questiontypebase.php');
|
|
|
30 |
require_once($CFG->dirroot . '/question/type/multichoice/question.php');
|
|
|
31 |
require_once($CFG->dirroot . '/question/type/numerical/questiontype.php');
|
|
|
32 |
|
|
|
33 |
/**
|
|
|
34 |
* The multi-answer question type class.
|
|
|
35 |
*
|
|
|
36 |
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
|
|
|
37 |
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
|
38 |
*/
|
|
|
39 |
class qtype_multianswer extends question_type {
|
|
|
40 |
|
|
|
41 |
/**
|
|
|
42 |
* Generate a subquestion replacement question class.
|
|
|
43 |
*
|
|
|
44 |
* Due to a bug, subquestions can be lost (see MDL-54724). This class exists to take
|
|
|
45 |
* the place of those lost questions so that the system can keep working and inform
|
|
|
46 |
* the user of the corrupted data.
|
|
|
47 |
*
|
|
|
48 |
* @return question_automatically_gradable The replacement question class.
|
|
|
49 |
*/
|
|
|
50 |
public static function deleted_subquestion_replacement(): question_automatically_gradable {
|
|
|
51 |
return new class implements question_automatically_gradable {
|
|
|
52 |
public $qtype;
|
|
|
53 |
|
|
|
54 |
public function __construct() {
|
|
|
55 |
$this->qtype = new class() {
|
|
|
56 |
public function name() {
|
|
|
57 |
return 'subquestion_replacement';
|
|
|
58 |
}
|
|
|
59 |
};
|
|
|
60 |
}
|
|
|
61 |
|
|
|
62 |
public function is_gradable_response(array $response) {
|
|
|
63 |
return false;
|
|
|
64 |
}
|
|
|
65 |
|
|
|
66 |
public function is_complete_response(array $response) {
|
|
|
67 |
return false;
|
|
|
68 |
}
|
|
|
69 |
|
|
|
70 |
public function is_same_response(array $prevresponse, array $newresponse) {
|
|
|
71 |
return false;
|
|
|
72 |
}
|
|
|
73 |
|
|
|
74 |
public function summarise_response(array $response) {
|
|
|
75 |
return '';
|
|
|
76 |
}
|
|
|
77 |
|
|
|
78 |
public function un_summarise_response(string $summary) {
|
|
|
79 |
return [];
|
|
|
80 |
}
|
|
|
81 |
|
|
|
82 |
public function classify_response(array $response) {
|
|
|
83 |
return [];
|
|
|
84 |
}
|
|
|
85 |
|
|
|
86 |
public function get_validation_error(array $response) {
|
|
|
87 |
return '';
|
|
|
88 |
}
|
|
|
89 |
|
|
|
90 |
public function grade_response(array $response) {
|
|
|
91 |
return [];
|
|
|
92 |
}
|
|
|
93 |
|
|
|
94 |
public function get_hint($hintnumber, question_attempt $qa) {
|
|
|
95 |
return;
|
|
|
96 |
}
|
|
|
97 |
|
|
|
98 |
public function get_right_answer_summary() {
|
|
|
99 |
return null;
|
|
|
100 |
}
|
|
|
101 |
};
|
|
|
102 |
}
|
|
|
103 |
|
|
|
104 |
public function can_analyse_responses() {
|
|
|
105 |
return false;
|
|
|
106 |
}
|
|
|
107 |
|
|
|
108 |
public function get_question_options($question) {
|
|
|
109 |
global $DB;
|
|
|
110 |
|
|
|
111 |
parent::get_question_options($question);
|
|
|
112 |
// Get relevant data indexed by positionkey from the multianswers table.
|
|
|
113 |
$sequence = $DB->get_field('question_multianswer', 'sequence',
|
|
|
114 |
array('question' => $question->id), MUST_EXIST);
|
|
|
115 |
|
|
|
116 |
if (empty($sequence)) {
|
|
|
117 |
$question->options->questions = [];
|
|
|
118 |
return true;
|
|
|
119 |
}
|
|
|
120 |
|
|
|
121 |
$wrappedquestions = $DB->get_records_list('question', 'id',
|
|
|
122 |
explode(',', $sequence), 'id ASC');
|
|
|
123 |
|
|
|
124 |
// We want an array with question ids as index and the positions as values.
|
|
|
125 |
$sequence = array_flip(explode(',', $sequence));
|
|
|
126 |
array_walk($sequence, function(&$val) {
|
|
|
127 |
$val++;
|
|
|
128 |
});
|
|
|
129 |
|
|
|
130 |
// Due to a bug, questions can be lost (see MDL-54724). So we first fill the question
|
|
|
131 |
// options with this dummy "replacement" type. These are overridden in the loop below
|
|
|
132 |
// leaving behind only those questions which no longer exist. The renderer then looks
|
|
|
133 |
// for this deleted type to display information to the user about the corrupted question
|
|
|
134 |
// data.
|
|
|
135 |
foreach ($sequence as $seq) {
|
|
|
136 |
$question->options->questions[$seq] = (object)[
|
|
|
137 |
'qtype' => 'subquestion_replacement',
|
|
|
138 |
'defaultmark' => 1,
|
|
|
139 |
'options' => (object)[
|
|
|
140 |
'answers' => []
|
|
|
141 |
]
|
|
|
142 |
];
|
|
|
143 |
}
|
|
|
144 |
|
|
|
145 |
foreach ($wrappedquestions as $wrapped) {
|
|
|
146 |
question_bank::get_qtype($wrapped->qtype)->get_question_options($wrapped);
|
|
|
147 |
// For wrapped questions the maxgrade is always equal to the defaultmark,
|
|
|
148 |
// there is no entry in the question_instances table for them.
|
|
|
149 |
$wrapped->category = $question->categoryobject->id;
|
|
|
150 |
$question->options->questions[$sequence[$wrapped->id]] = $wrapped;
|
|
|
151 |
}
|
|
|
152 |
$question->hints = $DB->get_records('question_hints',
|
|
|
153 |
array('questionid' => $question->id), 'id ASC');
|
|
|
154 |
|
|
|
155 |
return true;
|
|
|
156 |
}
|
|
|
157 |
|
|
|
158 |
public function save_question_options($question) {
|
|
|
159 |
global $DB;
|
|
|
160 |
$result = new stdClass();
|
|
|
161 |
|
|
|
162 |
// This function needs to be able to handle the case where the existing set of wrapped
|
|
|
163 |
// questions does not match the new set of wrapped questions so that some need to be
|
|
|
164 |
// created, some modified and some deleted.
|
|
|
165 |
// Unfortunately the code currently simply overwrites existing ones in sequence. This
|
|
|
166 |
// will make re-marking after a re-ordering of wrapped questions impossible and
|
|
|
167 |
// will also create difficulties if questiontype specific tables reference the id.
|
|
|
168 |
|
|
|
169 |
// First we get all the existing wrapped questions.
|
|
|
170 |
$oldwrappedquestions = [];
|
|
|
171 |
if (isset($question->oldparent)) {
|
|
|
172 |
if ($oldwrappedids = $DB->get_field('question_multianswer', 'sequence',
|
|
|
173 |
['question' => $question->oldparent])) {
|
|
|
174 |
$oldwrappedidsarray = explode(',', $oldwrappedids);
|
|
|
175 |
$unorderedquestions = $DB->get_records_list('question', 'id', $oldwrappedidsarray);
|
|
|
176 |
|
|
|
177 |
// Keep the order as given in the sequence field.
|
|
|
178 |
foreach ($oldwrappedidsarray as $questionid) {
|
|
|
179 |
if (isset($unorderedquestions[$questionid])) {
|
|
|
180 |
$oldwrappedquestions[] = $unorderedquestions[$questionid];
|
|
|
181 |
}
|
|
|
182 |
}
|
|
|
183 |
}
|
|
|
184 |
}
|
|
|
185 |
|
|
|
186 |
$sequence = array();
|
|
|
187 |
foreach ($question->options->questions as $wrapped) {
|
|
|
188 |
if (!empty($wrapped)) {
|
|
|
189 |
// If we still have some old wrapped question ids, reuse the next of them.
|
|
|
190 |
$wrapped->id = 0;
|
|
|
191 |
if (is_array($oldwrappedquestions) &&
|
|
|
192 |
$oldwrappedquestion = array_shift($oldwrappedquestions)) {
|
|
|
193 |
$wrapped->oldid = $oldwrappedquestion->id;
|
|
|
194 |
if ($oldwrappedquestion->qtype != $wrapped->qtype) {
|
|
|
195 |
switch ($oldwrappedquestion->qtype) {
|
|
|
196 |
case 'multichoice':
|
|
|
197 |
$DB->delete_records('qtype_multichoice_options',
|
|
|
198 |
array('questionid' => $oldwrappedquestion->id));
|
|
|
199 |
break;
|
|
|
200 |
case 'shortanswer':
|
|
|
201 |
$DB->delete_records('qtype_shortanswer_options',
|
|
|
202 |
array('questionid' => $oldwrappedquestion->id));
|
|
|
203 |
break;
|
|
|
204 |
case 'numerical':
|
|
|
205 |
$DB->delete_records('question_numerical',
|
|
|
206 |
array('question' => $oldwrappedquestion->id));
|
|
|
207 |
break;
|
|
|
208 |
default:
|
|
|
209 |
throw new moodle_exception('qtypenotrecognized',
|
|
|
210 |
'qtype_multianswer', '', $oldwrappedquestion->qtype);
|
|
|
211 |
}
|
|
|
212 |
}
|
|
|
213 |
}
|
|
|
214 |
}
|
|
|
215 |
$wrapped->name = $question->name;
|
|
|
216 |
$wrapped->parent = $question->id;
|
|
|
217 |
$previousid = $wrapped->id;
|
|
|
218 |
// Save_question strips this extra bit off the category again.
|
|
|
219 |
$wrapped->category = $question->category . ',1';
|
|
|
220 |
$wrapped = question_bank::get_qtype($wrapped->qtype)->save_question(
|
|
|
221 |
$wrapped, clone($wrapped));
|
|
|
222 |
$sequence[] = $wrapped->id;
|
|
|
223 |
if ($previousid != 0 && $previousid != $wrapped->id) {
|
|
|
224 |
// For some reasons a new question has been created
|
|
|
225 |
// so delete the old one.
|
|
|
226 |
question_delete_question($previousid);
|
|
|
227 |
}
|
|
|
228 |
}
|
|
|
229 |
|
|
|
230 |
// Delete redundant wrapped questions.
|
|
|
231 |
if (is_array($oldwrappedquestions) && count($oldwrappedquestions)) {
|
|
|
232 |
foreach ($oldwrappedquestions as $oldwrappedquestion) {
|
|
|
233 |
question_delete_question($oldwrappedquestion->id);
|
|
|
234 |
}
|
|
|
235 |
}
|
|
|
236 |
|
|
|
237 |
if (!empty($sequence)) {
|
|
|
238 |
$multianswer = new stdClass();
|
|
|
239 |
$multianswer->question = $question->id;
|
|
|
240 |
$multianswer->sequence = implode(',', $sequence);
|
|
|
241 |
if ($oldid = $DB->get_field('question_multianswer', 'id',
|
|
|
242 |
array('question' => $question->id))) {
|
|
|
243 |
$multianswer->id = $oldid;
|
|
|
244 |
$DB->update_record('question_multianswer', $multianswer);
|
|
|
245 |
} else {
|
|
|
246 |
$DB->insert_record('question_multianswer', $multianswer);
|
|
|
247 |
}
|
|
|
248 |
}
|
|
|
249 |
|
|
|
250 |
$this->save_hints($question, true);
|
|
|
251 |
}
|
|
|
252 |
|
|
|
253 |
public function save_question($authorizedquestion, $form) {
|
|
|
254 |
$question = qtype_multianswer_extract_question($form->questiontext);
|
|
|
255 |
if (isset($authorizedquestion->id)) {
|
|
|
256 |
$question->id = $authorizedquestion->id;
|
|
|
257 |
}
|
|
|
258 |
|
|
|
259 |
$question->category = $form->category;
|
|
|
260 |
$form->defaultmark = $question->defaultmark;
|
|
|
261 |
$form->questiontext = $question->questiontext;
|
|
|
262 |
$form->questiontextformat = 0;
|
|
|
263 |
$form->options = clone($question->options);
|
|
|
264 |
unset($question->options);
|
|
|
265 |
return parent::save_question($question, $form);
|
|
|
266 |
}
|
|
|
267 |
|
|
|
268 |
protected function make_hint($hint) {
|
|
|
269 |
return question_hint_with_parts::load_from_record($hint);
|
|
|
270 |
}
|
|
|
271 |
|
|
|
272 |
public function delete_question($questionid, $contextid) {
|
|
|
273 |
global $DB;
|
|
|
274 |
$DB->delete_records('question_multianswer', array('question' => $questionid));
|
|
|
275 |
|
|
|
276 |
parent::delete_question($questionid, $contextid);
|
|
|
277 |
}
|
|
|
278 |
|
|
|
279 |
protected function initialise_question_instance(question_definition $question, $questiondata) {
|
|
|
280 |
parent::initialise_question_instance($question, $questiondata);
|
|
|
281 |
|
|
|
282 |
$bits = preg_split('/\{#(\d+)\}/', $question->questiontext,
|
|
|
283 |
-1, PREG_SPLIT_DELIM_CAPTURE);
|
|
|
284 |
$question->textfragments[0] = array_shift($bits);
|
|
|
285 |
$i = 1;
|
|
|
286 |
while (!empty($bits)) {
|
|
|
287 |
$question->places[$i] = array_shift($bits);
|
|
|
288 |
$question->textfragments[$i] = array_shift($bits);
|
|
|
289 |
$i += 1;
|
|
|
290 |
}
|
|
|
291 |
foreach ($questiondata->options->questions as $key => $subqdata) {
|
|
|
292 |
if ($subqdata->qtype == 'subquestion_replacement') {
|
|
|
293 |
continue;
|
|
|
294 |
}
|
|
|
295 |
|
|
|
296 |
$subqdata->contextid = $questiondata->contextid;
|
|
|
297 |
if ($subqdata->qtype == 'multichoice') {
|
|
|
298 |
$answerregs = array();
|
|
|
299 |
if ($subqdata->options->shuffleanswers == 1 && isset($questiondata->options->shuffleanswers)
|
|
|
300 |
&& $questiondata->options->shuffleanswers == 0 ) {
|
|
|
301 |
$subqdata->options->shuffleanswers = 0;
|
|
|
302 |
}
|
|
|
303 |
}
|
|
|
304 |
$question->subquestions[$key] = question_bank::make_question($subqdata);
|
|
|
305 |
$question->subquestions[$key]->defaultmark = $subqdata->defaultmark;
|
|
|
306 |
if (isset($subqdata->options->layout)) {
|
|
|
307 |
$question->subquestions[$key]->layout = $subqdata->options->layout;
|
|
|
308 |
}
|
|
|
309 |
}
|
|
|
310 |
}
|
|
|
311 |
|
|
|
312 |
public function get_random_guess_score($questiondata) {
|
|
|
313 |
$fractionsum = 0;
|
|
|
314 |
$fractionmax = 0;
|
|
|
315 |
foreach ($questiondata->options->questions as $key => $subqdata) {
|
|
|
316 |
if ($subqdata->qtype == 'subquestion_replacement') {
|
|
|
317 |
continue;
|
|
|
318 |
}
|
|
|
319 |
$fractionmax += $subqdata->defaultmark;
|
|
|
320 |
$fractionsum += question_bank::get_qtype(
|
|
|
321 |
$subqdata->qtype)->get_random_guess_score($subqdata);
|
|
|
322 |
}
|
|
|
323 |
if ($fractionmax > question_utils::MARK_TOLERANCE) {
|
|
|
324 |
return $fractionsum / $fractionmax;
|
|
|
325 |
} else {
|
|
|
326 |
return null;
|
|
|
327 |
}
|
|
|
328 |
}
|
|
|
329 |
|
|
|
330 |
public function move_files($questionid, $oldcontextid, $newcontextid) {
|
|
|
331 |
parent::move_files($questionid, $oldcontextid, $newcontextid);
|
|
|
332 |
$this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
|
|
|
333 |
}
|
|
|
334 |
|
|
|
335 |
protected function delete_files($questionid, $contextid) {
|
|
|
336 |
parent::delete_files($questionid, $contextid);
|
|
|
337 |
$this->delete_files_in_hints($questionid, $contextid);
|
|
|
338 |
}
|
|
|
339 |
}
|
|
|
340 |
|
|
|
341 |
|
|
|
342 |
// ANSWER_ALTERNATIVE regexes.
|
|
|
343 |
define('ANSWER_ALTERNATIVE_FRACTION_REGEX',
|
|
|
344 |
'=|%(-?[0-9]+(?:[.,][0-9]*)?)%');
|
|
|
345 |
// For the syntax '(?<!' see http://www.perl.com/doc/manual/html/pod/perlre.html#item_C.
|
|
|
346 |
define('ANSWER_ALTERNATIVE_ANSWER_REGEX',
|
|
|
347 |
'.+?(?<!\\\\|&|&)(?=[~#}]|$)');
|
|
|
348 |
define('ANSWER_ALTERNATIVE_FEEDBACK_REGEX',
|
|
|
349 |
'.*?(?<!\\\\)(?=[~}]|$)');
|
|
|
350 |
define('ANSWER_ALTERNATIVE_REGEX',
|
|
|
351 |
'(' . ANSWER_ALTERNATIVE_FRACTION_REGEX .')?' .
|
|
|
352 |
'(' . ANSWER_ALTERNATIVE_ANSWER_REGEX . ')' .
|
|
|
353 |
'(#(' . ANSWER_ALTERNATIVE_FEEDBACK_REGEX .'))?');
|
|
|
354 |
|
|
|
355 |
// Parenthesis positions for ANSWER_ALTERNATIVE_REGEX.
|
|
|
356 |
define('ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION', 2);
|
|
|
357 |
define('ANSWER_ALTERNATIVE_REGEX_FRACTION', 1);
|
|
|
358 |
define('ANSWER_ALTERNATIVE_REGEX_ANSWER', 3);
|
|
|
359 |
define('ANSWER_ALTERNATIVE_REGEX_FEEDBACK', 5);
|
|
|
360 |
|
|
|
361 |
// NUMBER_FORMATED_ALTERNATIVE_ANSWER_REGEX is used
|
|
|
362 |
// for identifying numerical answers in ANSWER_ALTERNATIVE_REGEX_ANSWER.
|
|
|
363 |
define('NUMBER_REGEX',
|
|
|
364 |
'-?(([0-9]+[.,]?[0-9]*|[.,][0-9]+)([eE][-+]?[0-9]+)?)');
|
|
|
365 |
define('NUMERICAL_ALTERNATIVE_REGEX',
|
|
|
366 |
'^(' . NUMBER_REGEX . ')(:' . NUMBER_REGEX . ')?$');
|
|
|
367 |
|
|
|
368 |
// Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX.
|
|
|
369 |
define('NUMERICAL_CORRECT_ANSWER', 1);
|
|
|
370 |
define('NUMERICAL_ABS_ERROR_MARGIN', 6);
|
|
|
371 |
|
|
|
372 |
// Remaining ANSWER regexes.
|
|
|
373 |
define('ANSWER_TYPE_DEF_REGEX',
|
|
|
374 |
'(NUMERICAL|NM)|(MULTICHOICE|MC)|(MULTICHOICE_V|MCV)|(MULTICHOICE_H|MCH)|' .
|
|
|
375 |
'(SHORTANSWER|SA|MW)|(SHORTANSWER_C|SAC|MWC)|' .
|
|
|
376 |
'(MULTICHOICE_S|MCS)|(MULTICHOICE_VS|MCVS)|(MULTICHOICE_HS|MCHS)|'.
|
|
|
377 |
'(MULTIRESPONSE|MR)|(MULTIRESPONSE_H|MRH)|(MULTIRESPONSE_S|MRS)|(MULTIRESPONSE_HS|MRHS)');
|
|
|
378 |
define('ANSWER_START_REGEX',
|
|
|
379 |
'\{([0-9]*):(' . ANSWER_TYPE_DEF_REGEX . '):');
|
|
|
380 |
|
|
|
381 |
define('ANSWER_REGEX',
|
|
|
382 |
ANSWER_START_REGEX
|
|
|
383 |
. '(' . ANSWER_ALTERNATIVE_REGEX
|
|
|
384 |
. '(~'
|
|
|
385 |
. ANSWER_ALTERNATIVE_REGEX
|
|
|
386 |
. ')*)\}');
|
|
|
387 |
|
|
|
388 |
// Parenthesis positions for singulars in ANSWER_REGEX.
|
|
|
389 |
define('ANSWER_REGEX_NORM', 1);
|
|
|
390 |
define('ANSWER_REGEX_ANSWER_TYPE_NUMERICAL', 3);
|
|
|
391 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE', 4);
|
|
|
392 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR', 5);
|
|
|
393 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL', 6);
|
|
|
394 |
define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER', 7);
|
|
|
395 |
define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C', 8);
|
|
|
396 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED', 9);
|
|
|
397 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED', 10);
|
|
|
398 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED', 11);
|
|
|
399 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE', 12);
|
|
|
400 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL', 13);
|
|
|
401 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_SHUFFLED', 14);
|
|
|
402 |
define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL_SHUFFLED', 15);
|
|
|
403 |
define('ANSWER_REGEX_ALTERNATIVES', 16);
|
|
|
404 |
|
|
|
405 |
/**
|
|
|
406 |
* Initialise subquestion fields that are constant across all MULTICHOICE
|
|
|
407 |
* types.
|
|
|
408 |
*
|
|
|
409 |
* @param objet $wrapped The subquestion to initialise
|
|
|
410 |
*
|
|
|
411 |
*/
|
|
|
412 |
function qtype_multianswer_initialise_multichoice_subquestion($wrapped) {
|
|
|
413 |
$wrapped->qtype = 'multichoice';
|
|
|
414 |
$wrapped->single = 1;
|
|
|
415 |
$wrapped->answernumbering = 0;
|
|
|
416 |
$wrapped->correctfeedback['text'] = '';
|
|
|
417 |
$wrapped->correctfeedback['format'] = FORMAT_HTML;
|
|
|
418 |
$wrapped->correctfeedback['itemid'] = '';
|
|
|
419 |
$wrapped->partiallycorrectfeedback['text'] = '';
|
|
|
420 |
$wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML;
|
|
|
421 |
$wrapped->partiallycorrectfeedback['itemid'] = '';
|
|
|
422 |
$wrapped->incorrectfeedback['text'] = '';
|
|
|
423 |
$wrapped->incorrectfeedback['format'] = FORMAT_HTML;
|
|
|
424 |
$wrapped->incorrectfeedback['itemid'] = '';
|
|
|
425 |
}
|
|
|
426 |
|
|
|
427 |
function qtype_multianswer_extract_question($text) {
|
|
|
428 |
// Variable $text is an array [text][format][itemid].
|
|
|
429 |
$question = new stdClass();
|
|
|
430 |
$question->qtype = 'multianswer';
|
|
|
431 |
$question->questiontext = $text;
|
|
|
432 |
$question->generalfeedback['text'] = '';
|
|
|
433 |
$question->generalfeedback['format'] = FORMAT_HTML;
|
|
|
434 |
$question->generalfeedback['itemid'] = '';
|
|
|
435 |
|
|
|
436 |
$question->options = new stdClass();
|
|
|
437 |
$question->options->questions = array();
|
|
|
438 |
$question->defaultmark = 0; // Will be increased for each answer norm.
|
|
|
439 |
|
|
|
440 |
for ($positionkey = 1;
|
|
|
441 |
preg_match('/'.ANSWER_REGEX.'/s', $question->questiontext['text'], $answerregs);
|
|
|
442 |
++$positionkey) {
|
|
|
443 |
$wrapped = new stdClass();
|
|
|
444 |
$wrapped->generalfeedback['text'] = '';
|
|
|
445 |
$wrapped->generalfeedback['format'] = FORMAT_HTML;
|
|
|
446 |
$wrapped->generalfeedback['itemid'] = '';
|
|
|
447 |
if (isset($answerregs[ANSWER_REGEX_NORM]) && $answerregs[ANSWER_REGEX_NORM] !== '') {
|
|
|
448 |
$wrapped->defaultmark = $answerregs[ANSWER_REGEX_NORM];
|
|
|
449 |
} else {
|
|
|
450 |
$wrapped->defaultmark = '1';
|
|
|
451 |
}
|
|
|
452 |
if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])) {
|
|
|
453 |
$wrapped->qtype = 'numerical';
|
|
|
454 |
$wrapped->multiplier = array();
|
|
|
455 |
$wrapped->units = array();
|
|
|
456 |
$wrapped->instructions['text'] = '';
|
|
|
457 |
$wrapped->instructions['format'] = FORMAT_HTML;
|
|
|
458 |
$wrapped->instructions['itemid'] = '';
|
|
|
459 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER])) {
|
|
|
460 |
$wrapped->qtype = 'shortanswer';
|
|
|
461 |
$wrapped->usecase = 0;
|
|
|
462 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C])) {
|
|
|
463 |
$wrapped->qtype = 'shortanswer';
|
|
|
464 |
$wrapped->usecase = 1;
|
|
|
465 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE])) {
|
|
|
466 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
467 |
$wrapped->shuffleanswers = 0;
|
|
|
468 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
|
|
|
469 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED])) {
|
|
|
470 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
471 |
$wrapped->shuffleanswers = 1;
|
|
|
472 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN;
|
|
|
473 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR])) {
|
|
|
474 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
475 |
$wrapped->shuffleanswers = 0;
|
|
|
476 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
|
|
|
477 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED])) {
|
|
|
478 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
479 |
$wrapped->shuffleanswers = 1;
|
|
|
480 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
|
|
|
481 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL])) {
|
|
|
482 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
483 |
$wrapped->shuffleanswers = 0;
|
|
|
484 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
|
|
|
485 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED])) {
|
|
|
486 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
487 |
$wrapped->shuffleanswers = 1;
|
|
|
488 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
|
|
|
489 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE])) {
|
|
|
490 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
491 |
$wrapped->single = 0;
|
|
|
492 |
$wrapped->shuffleanswers = 0;
|
|
|
493 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
|
|
|
494 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL])) {
|
|
|
495 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
496 |
$wrapped->single = 0;
|
|
|
497 |
$wrapped->shuffleanswers = 0;
|
|
|
498 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
|
|
|
499 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_SHUFFLED])) {
|
|
|
500 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
501 |
$wrapped->single = 0;
|
|
|
502 |
$wrapped->shuffleanswers = 1;
|
|
|
503 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL;
|
|
|
504 |
} else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL_SHUFFLED])) {
|
|
|
505 |
qtype_multianswer_initialise_multichoice_subquestion($wrapped);
|
|
|
506 |
$wrapped->single = 0;
|
|
|
507 |
$wrapped->shuffleanswers = 1;
|
|
|
508 |
$wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL;
|
|
|
509 |
} else {
|
|
|
510 |
throw new \moodle_exception('unknownquestiontype', 'question', '', $answerregs[2]);
|
|
|
511 |
return false;
|
|
|
512 |
}
|
|
|
513 |
|
|
|
514 |
// Each $wrapped simulates a $form that can be processed by the
|
|
|
515 |
// respective save_question and save_question_options methods of the
|
|
|
516 |
// wrapped questiontypes.
|
|
|
517 |
$wrapped->answer = array();
|
|
|
518 |
$wrapped->fraction = array();
|
|
|
519 |
$wrapped->feedback = array();
|
|
|
520 |
$wrapped->questiontext['text'] = $answerregs[0];
|
|
|
521 |
$wrapped->questiontext['format'] = FORMAT_HTML;
|
|
|
522 |
$wrapped->questiontext['itemid'] = '';
|
|
|
523 |
$answerindex = 0;
|
|
|
524 |
|
|
|
525 |
$hasspecificfraction = false;
|
|
|
526 |
$remainingalts = $answerregs[ANSWER_REGEX_ALTERNATIVES];
|
|
|
527 |
while (preg_match('/~?'.ANSWER_ALTERNATIVE_REGEX.'/s', $remainingalts, $altregs)) {
|
|
|
528 |
if ('=' == $altregs[ANSWER_ALTERNATIVE_REGEX_FRACTION]) {
|
|
|
529 |
$wrapped->fraction["{$answerindex}"] = '1';
|
|
|
530 |
} else if ($percentile = $altregs[ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION]) {
|
|
|
531 |
// Accept either decimal place character.
|
|
|
532 |
$wrapped->fraction["{$answerindex}"] = .01 * str_replace(',', '.', $percentile);
|
|
|
533 |
$hasspecificfraction = true;
|
|
|
534 |
} else {
|
|
|
535 |
$wrapped->fraction["{$answerindex}"] = '0';
|
|
|
536 |
}
|
|
|
537 |
if (isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])) {
|
|
|
538 |
$feedback = html_entity_decode(
|
|
|
539 |
$altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK], ENT_QUOTES, 'UTF-8');
|
|
|
540 |
$feedback = str_replace('\}', '}', $feedback);
|
|
|
541 |
$wrapped->feedback["{$answerindex}"]['text'] = str_replace('\#', '#', $feedback);
|
|
|
542 |
$wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
|
|
|
543 |
$wrapped->feedback["{$answerindex}"]['itemid'] = '';
|
|
|
544 |
} else {
|
|
|
545 |
$wrapped->feedback["{$answerindex}"]['text'] = '';
|
|
|
546 |
$wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML;
|
|
|
547 |
$wrapped->feedback["{$answerindex}"]['itemid'] = '';
|
|
|
548 |
|
|
|
549 |
}
|
|
|
550 |
if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])
|
|
|
551 |
&& preg_match('~'.NUMERICAL_ALTERNATIVE_REGEX.'~s',
|
|
|
552 |
$altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], $numregs)) {
|
|
|
553 |
$wrapped->answer[] = $numregs[NUMERICAL_CORRECT_ANSWER];
|
|
|
554 |
if (array_key_exists(NUMERICAL_ABS_ERROR_MARGIN, $numregs)) {
|
|
|
555 |
$wrapped->tolerance["{$answerindex}"] =
|
|
|
556 |
$numregs[NUMERICAL_ABS_ERROR_MARGIN];
|
|
|
557 |
} else {
|
|
|
558 |
$wrapped->tolerance["{$answerindex}"] = 0;
|
|
|
559 |
}
|
|
|
560 |
} else { // Tolerance can stay undefined for non numerical questions.
|
|
|
561 |
// Undo quoting done by the HTML editor.
|
|
|
562 |
$answer = html_entity_decode(
|
|
|
563 |
$altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], ENT_QUOTES, 'UTF-8');
|
|
|
564 |
$answer = str_replace('\}', '}', $answer);
|
|
|
565 |
$wrapped->answer["{$answerindex}"] = str_replace('\#', '#', $answer);
|
|
|
566 |
if ($wrapped->qtype == 'multichoice') {
|
|
|
567 |
$wrapped->answer["{$answerindex}"] = array(
|
|
|
568 |
'text' => $wrapped->answer["{$answerindex}"],
|
|
|
569 |
'format' => FORMAT_HTML,
|
|
|
570 |
'itemid' => '');
|
|
|
571 |
}
|
|
|
572 |
}
|
|
|
573 |
$tmp = explode($altregs[0], $remainingalts, 2);
|
|
|
574 |
$remainingalts = $tmp[1];
|
|
|
575 |
$answerindex++;
|
|
|
576 |
}
|
|
|
577 |
|
|
|
578 |
// Fix the score for multichoice_multi questions (as positive scores should add up to 1, not have a maximum of 1).
|
|
|
579 |
if (isset($wrapped->single) && $wrapped->single == 0) {
|
|
|
580 |
$total = 0;
|
|
|
581 |
foreach ($wrapped->fraction as $idx => $fraction) {
|
|
|
582 |
if ($fraction > 0) {
|
|
|
583 |
$total += $fraction;
|
|
|
584 |
}
|
|
|
585 |
}
|
|
|
586 |
if ($total) {
|
|
|
587 |
foreach ($wrapped->fraction as $idx => $fraction) {
|
|
|
588 |
if ($fraction > 0) {
|
|
|
589 |
$wrapped->fraction[$idx] = $fraction / $total;
|
|
|
590 |
} else if (!$hasspecificfraction) {
|
|
|
591 |
// If no specific fractions are given, set incorrect answers to each cancel out one correct answer.
|
|
|
592 |
$wrapped->fraction[$idx] = -(1.0 / $total);
|
|
|
593 |
}
|
|
|
594 |
}
|
|
|
595 |
}
|
|
|
596 |
}
|
|
|
597 |
|
|
|
598 |
$question->defaultmark += $wrapped->defaultmark;
|
|
|
599 |
$question->options->questions[$positionkey] = clone($wrapped);
|
|
|
600 |
$question->questiontext['text'] = implode("{#$positionkey}",
|
|
|
601 |
explode($answerregs[0], $question->questiontext['text'], 2));
|
|
|
602 |
}
|
|
|
603 |
return $question;
|
|
|
604 |
}
|
|
|
605 |
|
|
|
606 |
/**
|
|
|
607 |
* Validate a multianswer question.
|
|
|
608 |
*
|
|
|
609 |
* @param object $question The multianswer question to validate as returned by qtype_multianswer_extract_question
|
|
|
610 |
* @return array Array of error messages with questions field names as keys.
|
|
|
611 |
*/
|
|
|
612 |
function qtype_multianswer_validate_question(stdClass $question): array {
|
|
|
613 |
$errors = array();
|
|
|
614 |
if (!isset($question->options->questions)) {
|
|
|
615 |
$errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer');
|
|
|
616 |
} else {
|
|
|
617 |
$subquestions = fullclone($question->options->questions);
|
|
|
618 |
if (count($subquestions)) {
|
|
|
619 |
$sub = 1;
|
|
|
620 |
foreach ($subquestions as $subquestion) {
|
|
|
621 |
$prefix = 'sub_'.$sub.'_';
|
|
|
622 |
$answercount = 0;
|
|
|
623 |
$maxgrade = false;
|
|
|
624 |
$maxfraction = -1;
|
|
|
625 |
|
|
|
626 |
foreach ($subquestion->answer as $key => $answer) {
|
|
|
627 |
if (is_array($answer)) {
|
|
|
628 |
$answer = $answer['text'];
|
|
|
629 |
}
|
|
|
630 |
$trimmedanswer = trim($answer);
|
|
|
631 |
if ($trimmedanswer !== '') {
|
|
|
632 |
$answercount++;
|
|
|
633 |
if ($subquestion->qtype == 'numerical' &&
|
|
|
634 |
!(qtype_numerical::is_valid_number($trimmedanswer) || $trimmedanswer == '*')) {
|
|
|
635 |
$errors[$prefix.'answer['.$key.']'] =
|
|
|
636 |
get_string('answermustbenumberorstar', 'qtype_numerical');
|
|
|
637 |
}
|
|
|
638 |
if ($subquestion->fraction[$key] == 1) {
|
|
|
639 |
$maxgrade = true;
|
|
|
640 |
}
|
|
|
641 |
if ($subquestion->fraction[$key] > $maxfraction) {
|
|
|
642 |
$maxfraction = $subquestion->fraction[$key];
|
|
|
643 |
}
|
|
|
644 |
// For 'multiresponse' we are OK if there is at least one fraction > 0.
|
|
|
645 |
if ($subquestion->qtype == 'multichoice' && $subquestion->single == 0 &&
|
|
|
646 |
$subquestion->fraction[$key] > 0) {
|
|
|
647 |
$maxgrade = true;
|
|
|
648 |
}
|
|
|
649 |
}
|
|
|
650 |
}
|
|
|
651 |
if ($subquestion->qtype == 'multichoice' && $answercount < 2) {
|
|
|
652 |
$errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'qtype_multichoice', 2);
|
|
|
653 |
} else if ($answercount == 0) {
|
|
|
654 |
$errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'question', 1);
|
|
|
655 |
}
|
|
|
656 |
if ($maxgrade == false) {
|
|
|
657 |
$errors[$prefix.'fraction[0]'] = get_string('fractionsnomax', 'question');
|
|
|
658 |
}
|
|
|
659 |
$sub++;
|
|
|
660 |
}
|
|
|
661 |
} else {
|
|
|
662 |
$errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer');
|
|
|
663 |
}
|
|
|
664 |
}
|
|
|
665 |
return $errors;
|
|
|
666 |
}
|