See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]
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->maxmark = $wrapped->defaultmark; 150 $wrapped->category = $question->categoryobject->id; 151 $question->options->questions[$sequence[$wrapped->id]] = $wrapped; 152 } 153 $question->hints = $DB->get_records('question_hints', 154 array('questionid' => $question->id), 'id ASC'); 155 156 return true; 157 } 158 159 public function save_question_options($question) { 160 global $DB; 161 $result = new stdClass(); 162 163 // This function needs to be able to handle the case where the existing set of wrapped 164 // questions does not match the new set of wrapped questions so that some need to be 165 // created, some modified and some deleted. 166 // Unfortunately the code currently simply overwrites existing ones in sequence. This 167 // will make re-marking after a re-ordering of wrapped questions impossible and 168 // will also create difficulties if questiontype specific tables reference the id. 169 170 // First we get all the existing wrapped questions. 171 $oldwrappedquestions = []; 172 if (isset($question->oldparent)) { 173 if ($oldwrappedids = $DB->get_field('question_multianswer', 'sequence', 174 ['question' => $question->oldparent])) { 175 $oldwrappedidsarray = explode(',', $oldwrappedids); 176 $unorderedquestions = $DB->get_records_list('question', 'id', $oldwrappedidsarray); 177 178 // Keep the order as given in the sequence field. 179 foreach ($oldwrappedidsarray as $questionid) { 180 if (isset($unorderedquestions[$questionid])) { 181 $oldwrappedquestions[] = $unorderedquestions[$questionid]; 182 } 183 } 184 } 185 } 186 187 $sequence = array(); 188 foreach ($question->options->questions as $wrapped) { 189 if (!empty($wrapped)) { 190 // If we still have some old wrapped question ids, reuse the next of them. 191 $wrapped->id = 0; 192 if (is_array($oldwrappedquestions) && 193 $oldwrappedquestion = array_shift($oldwrappedquestions)) { 194 $wrapped->oldid = $oldwrappedquestion->id; 195 if ($oldwrappedquestion->qtype != $wrapped->qtype) { 196 switch ($oldwrappedquestion->qtype) { 197 case 'multichoice': 198 $DB->delete_records('qtype_multichoice_options', 199 array('questionid' => $oldwrappedquestion->id)); 200 break; 201 case 'shortanswer': 202 $DB->delete_records('qtype_shortanswer_options', 203 array('questionid' => $oldwrappedquestion->id)); 204 break; 205 case 'numerical': 206 $DB->delete_records('question_numerical', 207 array('question' => $oldwrappedquestion->id)); 208 break; 209 default: 210 throw new moodle_exception('qtypenotrecognized', 211 'qtype_multianswer', '', $oldwrappedquestion->qtype); 212 } 213 } 214 } 215 } 216 $wrapped->name = $question->name; 217 $wrapped->parent = $question->id; 218 $previousid = $wrapped->id; 219 // Save_question strips this extra bit off the category again. 220 $wrapped->category = $question->category . ',1'; 221 $wrapped = question_bank::get_qtype($wrapped->qtype)->save_question( 222 $wrapped, clone($wrapped)); 223 $sequence[] = $wrapped->id; 224 if ($previousid != 0 && $previousid != $wrapped->id) { 225 // For some reasons a new question has been created 226 // so delete the old one. 227 question_delete_question($previousid); 228 } 229 } 230 231 // Delete redundant wrapped questions. 232 if (is_array($oldwrappedquestions) && count($oldwrappedquestions)) { 233 foreach ($oldwrappedquestions as $oldwrappedquestion) { 234 question_delete_question($oldwrappedquestion->id); 235 } 236 } 237 238 if (!empty($sequence)) { 239 $multianswer = new stdClass(); 240 $multianswer->question = $question->id; 241 $multianswer->sequence = implode(',', $sequence); 242 if ($oldid = $DB->get_field('question_multianswer', 'id', 243 array('question' => $question->id))) { 244 $multianswer->id = $oldid; 245 $DB->update_record('question_multianswer', $multianswer); 246 } else { 247 $DB->insert_record('question_multianswer', $multianswer); 248 } 249 } 250 251 $this->save_hints($question, true); 252 } 253 254 public function save_question($authorizedquestion, $form) { 255 $question = qtype_multianswer_extract_question($form->questiontext); 256 if (isset($authorizedquestion->id)) { 257 $question->id = $authorizedquestion->id; 258 } 259 260 $question->category = $form->category; 261 $form->defaultmark = $question->defaultmark; 262 $form->questiontext = $question->questiontext; 263 $form->questiontextformat = 0; 264 $form->options = clone($question->options); 265 unset($question->options); 266 return parent::save_question($question, $form); 267 } 268 269 protected function make_hint($hint) { 270 return question_hint_with_parts::load_from_record($hint); 271 } 272 273 public function delete_question($questionid, $contextid) { 274 global $DB; 275 $DB->delete_records('question_multianswer', array('question' => $questionid)); 276 277 parent::delete_question($questionid, $contextid); 278 } 279 280 protected function initialise_question_instance(question_definition $question, $questiondata) { 281 parent::initialise_question_instance($question, $questiondata); 282 283 $bits = preg_split('/\{#(\d+)\}/', $question->questiontext, 284 -1, PREG_SPLIT_DELIM_CAPTURE); 285 $question->textfragments[0] = array_shift($bits); 286 $i = 1; 287 while (!empty($bits)) { 288 $question->places[$i] = array_shift($bits); 289 $question->textfragments[$i] = array_shift($bits); 290 $i += 1; 291 } 292 foreach ($questiondata->options->questions as $key => $subqdata) { 293 if ($subqdata->qtype == 'subquestion_replacement') { 294 continue; 295 } 296 297 $subqdata->contextid = $questiondata->contextid; 298 if ($subqdata->qtype == 'multichoice') { 299 $answerregs = array(); 300 if ($subqdata->options->shuffleanswers == 1 && isset($questiondata->options->shuffleanswers) 301 && $questiondata->options->shuffleanswers == 0 ) { 302 $subqdata->options->shuffleanswers = 0; 303 } 304 } 305 $question->subquestions[$key] = question_bank::make_question($subqdata); 306 $question->subquestions[$key]->maxmark = $subqdata->defaultmark; 307 if (isset($subqdata->options->layout)) { 308 $question->subquestions[$key]->layout = $subqdata->options->layout; 309 } 310 } 311 } 312 313 public function get_random_guess_score($questiondata) { 314 $fractionsum = 0; 315 $fractionmax = 0; 316 foreach ($questiondata->options->questions as $key => $subqdata) { 317 if ($subqdata->qtype == 'subquestion_replacement') { 318 continue; 319 } 320 $fractionmax += $subqdata->defaultmark; 321 $fractionsum += question_bank::get_qtype( 322 $subqdata->qtype)->get_random_guess_score($subqdata); 323 } 324 if ($fractionmax > question_utils::MARK_TOLERANCE) { 325 return $fractionsum / $fractionmax; 326 } else { 327 return null; 328 } 329 } 330 331 public function move_files($questionid, $oldcontextid, $newcontextid) { 332 parent::move_files($questionid, $oldcontextid, $newcontextid); 333 $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); 334 } 335 336 protected function delete_files($questionid, $contextid) { 337 parent::delete_files($questionid, $contextid); 338 $this->delete_files_in_hints($questionid, $contextid); 339 } 340 } 341 342 343 // ANSWER_ALTERNATIVE regexes. 344 define('ANSWER_ALTERNATIVE_FRACTION_REGEX', 345 '=|%(-?[0-9]+(?:[.,][0-9]*)?)%'); 346 // For the syntax '(?<!' see http://www.perl.com/doc/manual/html/pod/perlre.html#item_C. 347 define('ANSWER_ALTERNATIVE_ANSWER_REGEX', 348 '.+?(?<!\\\\|&|&)(?=[~#}]|$)'); 349 define('ANSWER_ALTERNATIVE_FEEDBACK_REGEX', 350 '.*?(?<!\\\\)(?=[~}]|$)'); 351 define('ANSWER_ALTERNATIVE_REGEX', 352 '(' . ANSWER_ALTERNATIVE_FRACTION_REGEX .')?' . 353 '(' . ANSWER_ALTERNATIVE_ANSWER_REGEX . ')' . 354 '(#(' . ANSWER_ALTERNATIVE_FEEDBACK_REGEX .'))?'); 355 356 // Parenthesis positions for ANSWER_ALTERNATIVE_REGEX. 357 define('ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION', 2); 358 define('ANSWER_ALTERNATIVE_REGEX_FRACTION', 1); 359 define('ANSWER_ALTERNATIVE_REGEX_ANSWER', 3); 360 define('ANSWER_ALTERNATIVE_REGEX_FEEDBACK', 5); 361 362 // NUMBER_FORMATED_ALTERNATIVE_ANSWER_REGEX is used 363 // for identifying numerical answers in ANSWER_ALTERNATIVE_REGEX_ANSWER. 364 define('NUMBER_REGEX', 365 '-?(([0-9]+[.,]?[0-9]*|[.,][0-9]+)([eE][-+]?[0-9]+)?)'); 366 define('NUMERICAL_ALTERNATIVE_REGEX', 367 '^(' . NUMBER_REGEX . ')(:' . NUMBER_REGEX . ')?$'); 368 369 // Parenthesis positions for NUMERICAL_FORMATED_ALTERNATIVE_ANSWER_REGEX. 370 define('NUMERICAL_CORRECT_ANSWER', 1); 371 define('NUMERICAL_ABS_ERROR_MARGIN', 6); 372 373 // Remaining ANSWER regexes. 374 define('ANSWER_TYPE_DEF_REGEX', 375 '(NUMERICAL|NM)|(MULTICHOICE|MC)|(MULTICHOICE_V|MCV)|(MULTICHOICE_H|MCH)|' . 376 '(SHORTANSWER|SA|MW)|(SHORTANSWER_C|SAC|MWC)|' . 377 '(MULTICHOICE_S|MCS)|(MULTICHOICE_VS|MCVS)|(MULTICHOICE_HS|MCHS)|'. 378 '(MULTIRESPONSE|MR)|(MULTIRESPONSE_H|MRH)|(MULTIRESPONSE_S|MRS)|(MULTIRESPONSE_HS|MRHS)'); 379 define('ANSWER_START_REGEX', 380 '\{([0-9]*):(' . ANSWER_TYPE_DEF_REGEX . '):'); 381 382 define('ANSWER_REGEX', 383 ANSWER_START_REGEX 384 . '(' . ANSWER_ALTERNATIVE_REGEX 385 . '(~' 386 . ANSWER_ALTERNATIVE_REGEX 387 . ')*)\}'); 388 389 // Parenthesis positions for singulars in ANSWER_REGEX. 390 define('ANSWER_REGEX_NORM', 1); 391 define('ANSWER_REGEX_ANSWER_TYPE_NUMERICAL', 3); 392 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE', 4); 393 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR', 5); 394 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL', 6); 395 define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER', 7); 396 define('ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C', 8); 397 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED', 9); 398 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED', 10); 399 define('ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED', 11); 400 define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE', 12); 401 define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL', 13); 402 define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_SHUFFLED', 14); 403 define('ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL_SHUFFLED', 15); 404 define('ANSWER_REGEX_ALTERNATIVES', 16); 405 406 /** 407 * Initialise subquestion fields that are constant across all MULTICHOICE 408 * types. 409 * 410 * @param objet $wrapped The subquestion to initialise 411 * 412 */ 413 function qtype_multianswer_initialise_multichoice_subquestion($wrapped) { 414 $wrapped->qtype = 'multichoice'; 415 $wrapped->single = 1; 416 $wrapped->answernumbering = 0; 417 $wrapped->correctfeedback['text'] = ''; 418 $wrapped->correctfeedback['format'] = FORMAT_HTML; 419 $wrapped->correctfeedback['itemid'] = ''; 420 $wrapped->partiallycorrectfeedback['text'] = ''; 421 $wrapped->partiallycorrectfeedback['format'] = FORMAT_HTML; 422 $wrapped->partiallycorrectfeedback['itemid'] = ''; 423 $wrapped->incorrectfeedback['text'] = ''; 424 $wrapped->incorrectfeedback['format'] = FORMAT_HTML; 425 $wrapped->incorrectfeedback['itemid'] = ''; 426 } 427 428 function qtype_multianswer_extract_question($text) { 429 // Variable $text is an array [text][format][itemid]. 430 $question = new stdClass(); 431 $question->qtype = 'multianswer'; 432 $question->questiontext = $text; 433 $question->generalfeedback['text'] = ''; 434 $question->generalfeedback['format'] = FORMAT_HTML; 435 $question->generalfeedback['itemid'] = ''; 436 437 $question->options = new stdClass(); 438 $question->options->questions = array(); 439 $question->defaultmark = 0; // Will be increased for each answer norm. 440 441 for ($positionkey = 1; 442 preg_match('/'.ANSWER_REGEX.'/s', $question->questiontext['text'], $answerregs); 443 ++$positionkey) { 444 $wrapped = new stdClass(); 445 $wrapped->generalfeedback['text'] = ''; 446 $wrapped->generalfeedback['format'] = FORMAT_HTML; 447 $wrapped->generalfeedback['itemid'] = ''; 448 if (isset($answerregs[ANSWER_REGEX_NORM]) && $answerregs[ANSWER_REGEX_NORM] !== '') { 449 $wrapped->defaultmark = $answerregs[ANSWER_REGEX_NORM]; 450 } else { 451 $wrapped->defaultmark = '1'; 452 } 453 if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL])) { 454 $wrapped->qtype = 'numerical'; 455 $wrapped->multiplier = array(); 456 $wrapped->units = array(); 457 $wrapped->instructions['text'] = ''; 458 $wrapped->instructions['format'] = FORMAT_HTML; 459 $wrapped->instructions['itemid'] = ''; 460 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER])) { 461 $wrapped->qtype = 'shortanswer'; 462 $wrapped->usecase = 0; 463 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_SHORTANSWER_C])) { 464 $wrapped->qtype = 'shortanswer'; 465 $wrapped->usecase = 1; 466 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE])) { 467 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 468 $wrapped->shuffleanswers = 0; 469 $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN; 470 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_SHUFFLED])) { 471 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 472 $wrapped->shuffleanswers = 1; 473 $wrapped->layout = qtype_multichoice_base::LAYOUT_DROPDOWN; 474 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR])) { 475 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 476 $wrapped->shuffleanswers = 0; 477 $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL; 478 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_REGULAR_SHUFFLED])) { 479 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 480 $wrapped->shuffleanswers = 1; 481 $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL; 482 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL])) { 483 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 484 $wrapped->shuffleanswers = 0; 485 $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL; 486 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTICHOICE_HORIZONTAL_SHUFFLED])) { 487 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 488 $wrapped->shuffleanswers = 1; 489 $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL; 490 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE])) { 491 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 492 $wrapped->single = 0; 493 $wrapped->shuffleanswers = 0; 494 $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL; 495 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL])) { 496 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 497 $wrapped->single = 0; 498 $wrapped->shuffleanswers = 0; 499 $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL; 500 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_SHUFFLED])) { 501 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 502 $wrapped->single = 0; 503 $wrapped->shuffleanswers = 1; 504 $wrapped->layout = qtype_multichoice_base::LAYOUT_VERTICAL; 505 } else if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_MULTIRESPONSE_HORIZONTAL_SHUFFLED])) { 506 qtype_multianswer_initialise_multichoice_subquestion($wrapped); 507 $wrapped->single = 0; 508 $wrapped->shuffleanswers = 1; 509 $wrapped->layout = qtype_multichoice_base::LAYOUT_HORIZONTAL; 510 } else { 511 throw new \moodle_exception('unknownquestiontype', 'question', '', $answerregs[2]); 512 return false; 513 } 514 515 // Each $wrapped simulates a $form that can be processed by the 516 // respective save_question and save_question_options methods of the 517 // wrapped questiontypes. 518 $wrapped->answer = array(); 519 $wrapped->fraction = array(); 520 $wrapped->feedback = array(); 521 $wrapped->questiontext['text'] = $answerregs[0]; 522 $wrapped->questiontext['format'] = FORMAT_HTML; 523 $wrapped->questiontext['itemid'] = ''; 524 $answerindex = 0; 525 526 $hasspecificfraction = false; 527 $remainingalts = $answerregs[ANSWER_REGEX_ALTERNATIVES]; 528 while (preg_match('/~?'.ANSWER_ALTERNATIVE_REGEX.'/s', $remainingalts, $altregs)) { 529 if ('=' == $altregs[ANSWER_ALTERNATIVE_REGEX_FRACTION]) { 530 $wrapped->fraction["{$answerindex}"] = '1'; 531 } else if ($percentile = $altregs[ANSWER_ALTERNATIVE_REGEX_PERCENTILE_FRACTION]) { 532 // Accept either decimal place character. 533 $wrapped->fraction["{$answerindex}"] = .01 * str_replace(',', '.', $percentile); 534 $hasspecificfraction = true; 535 } else { 536 $wrapped->fraction["{$answerindex}"] = '0'; 537 } 538 if (isset($altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK])) { 539 $feedback = html_entity_decode( 540 $altregs[ANSWER_ALTERNATIVE_REGEX_FEEDBACK], ENT_QUOTES, 'UTF-8'); 541 $feedback = str_replace('\}', '}', $feedback); 542 $wrapped->feedback["{$answerindex}"]['text'] = str_replace('\#', '#', $feedback); 543 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML; 544 $wrapped->feedback["{$answerindex}"]['itemid'] = ''; 545 } else { 546 $wrapped->feedback["{$answerindex}"]['text'] = ''; 547 $wrapped->feedback["{$answerindex}"]['format'] = FORMAT_HTML; 548 $wrapped->feedback["{$answerindex}"]['itemid'] = ''; 549 550 } 551 if (!empty($answerregs[ANSWER_REGEX_ANSWER_TYPE_NUMERICAL]) 552 && preg_match('~'.NUMERICAL_ALTERNATIVE_REGEX.'~s', 553 $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], $numregs)) { 554 $wrapped->answer[] = $numregs[NUMERICAL_CORRECT_ANSWER]; 555 if (array_key_exists(NUMERICAL_ABS_ERROR_MARGIN, $numregs)) { 556 $wrapped->tolerance["{$answerindex}"] = 557 $numregs[NUMERICAL_ABS_ERROR_MARGIN]; 558 } else { 559 $wrapped->tolerance["{$answerindex}"] = 0; 560 } 561 } else { // Tolerance can stay undefined for non numerical questions. 562 // Undo quoting done by the HTML editor. 563 $answer = html_entity_decode( 564 $altregs[ANSWER_ALTERNATIVE_REGEX_ANSWER], ENT_QUOTES, 'UTF-8'); 565 $answer = str_replace('\}', '}', $answer); 566 $wrapped->answer["{$answerindex}"] = str_replace('\#', '#', $answer); 567 if ($wrapped->qtype == 'multichoice') { 568 $wrapped->answer["{$answerindex}"] = array( 569 'text' => $wrapped->answer["{$answerindex}"], 570 'format' => FORMAT_HTML, 571 'itemid' => ''); 572 } 573 } 574 $tmp = explode($altregs[0], $remainingalts, 2); 575 $remainingalts = $tmp[1]; 576 $answerindex++; 577 } 578 579 // Fix the score for multichoice_multi questions (as positive scores should add up to 1, not have a maximum of 1). 580 if (isset($wrapped->single) && $wrapped->single == 0) { 581 $total = 0; 582 foreach ($wrapped->fraction as $idx => $fraction) { 583 if ($fraction > 0) { 584 $total += $fraction; 585 } 586 } 587 if ($total) { 588 foreach ($wrapped->fraction as $idx => $fraction) { 589 if ($fraction > 0) { 590 $wrapped->fraction[$idx] = $fraction / $total; 591 } else if (!$hasspecificfraction) { 592 // If no specific fractions are given, set incorrect answers to each cancel out one correct answer. 593 $wrapped->fraction[$idx] = -(1.0 / $total); 594 } 595 } 596 } 597 } 598 599 $question->defaultmark += $wrapped->defaultmark; 600 $question->options->questions[$positionkey] = clone($wrapped); 601 $question->questiontext['text'] = implode("{#$positionkey}", 602 explode($answerregs[0], $question->questiontext['text'], 2)); 603 } 604 return $question; 605 } 606 607 /** 608 * Validate a multianswer question. 609 * 610 * @param object $question The multianswer question to validate as returned by qtype_multianswer_extract_question 611 * @return array Array of error messages with questions field names as keys. 612 */ 613 function qtype_multianswer_validate_question(stdClass $question) : array { 614 $errors = array(); 615 if (!isset($question->options->questions)) { 616 $errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer'); 617 } else { 618 $subquestions = fullclone($question->options->questions); 619 if (count($subquestions)) { 620 $sub = 1; 621 foreach ($subquestions as $subquestion) { 622 $prefix = 'sub_'.$sub.'_'; 623 $answercount = 0; 624 $maxgrade = false; 625 $maxfraction = -1; 626 627 foreach ($subquestion->answer as $key => $answer) { 628 if (is_array($answer)) { 629 $answer = $answer['text']; 630 } 631 $trimmedanswer = trim($answer); 632 if ($trimmedanswer !== '') { 633 $answercount++; 634 if ($subquestion->qtype == 'numerical' && 635 !(qtype_numerical::is_valid_number($trimmedanswer) || $trimmedanswer == '*')) { 636 $errors[$prefix.'answer['.$key.']'] = 637 get_string('answermustbenumberorstar', 'qtype_numerical'); 638 } 639 if ($subquestion->fraction[$key] == 1) { 640 $maxgrade = true; 641 } 642 if ($subquestion->fraction[$key] > $maxfraction) { 643 $maxfraction = $subquestion->fraction[$key]; 644 } 645 // For 'multiresponse' we are OK if there is at least one fraction > 0. 646 if ($subquestion->qtype == 'multichoice' && $subquestion->single == 0 && 647 $subquestion->fraction[$key] > 0) { 648 $maxgrade = true; 649 } 650 } 651 } 652 if ($subquestion->qtype == 'multichoice' && $answercount < 2) { 653 $errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'qtype_multichoice', 2); 654 } else if ($answercount == 0) { 655 $errors[$prefix.'answer[0]'] = get_string('notenoughanswers', 'question', 1); 656 } 657 if ($maxgrade == false) { 658 $errors[$prefix.'fraction[0]'] = get_string('fractionsnomax', 'question'); 659 } 660 $sub++; 661 } 662 } else { 663 $errors['questiontext'] = get_string('questionsmissing', 'qtype_multianswer'); 664 } 665 } 666 return $errors; 667 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body