Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body