Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 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 * Matching question definition class. 19 * 20 * @package qtype_match 21 * @copyright 2009 The Open University 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 26 defined('MOODLE_INTERNAL') || die(); 27 28 require_once($CFG->dirroot . '/question/type/questionbase.php'); 29 30 /** 31 * Represents a matching question. 32 * 33 * @copyright 2009 The Open University 34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 */ 36 class qtype_match_question extends question_graded_automatically_with_countback { 37 /** @var boolean Whether the question stems should be shuffled. */ 38 public $shufflestems; 39 40 public $correctfeedback; 41 public $correctfeedbackformat; 42 public $partiallycorrectfeedback; 43 public $partiallycorrectfeedbackformat; 44 public $incorrectfeedback; 45 public $incorrectfeedbackformat; 46 47 /** @var array of question stems. */ 48 public $stems; 49 /** @var int[] FORMAT_... type for each stem. */ 50 public $stemformat; 51 /** @var array of choices that can be matched to each stem. */ 52 public $choices; 53 /** @var array index of the right choice for each stem. */ 54 public $right; 55 56 /** @var array shuffled stem indexes. */ 57 protected $stemorder; 58 /** @var array shuffled choice indexes. */ 59 protected $choiceorder; 60 61 public function start_attempt(question_attempt_step $step, $variant) { 62 $this->stemorder = array_keys($this->stems); 63 if ($this->shufflestems) { 64 shuffle($this->stemorder); 65 } 66 $step->set_qt_var('_stemorder', implode(',', $this->stemorder)); 67 68 $choiceorder = array_keys($this->choices); 69 shuffle($choiceorder); 70 $step->set_qt_var('_choiceorder', implode(',', $choiceorder)); 71 $this->set_choiceorder($choiceorder); 72 } 73 74 public function apply_attempt_state(question_attempt_step $step) { 75 $this->stemorder = explode(',', $step->get_qt_var('_stemorder')); 76 $this->set_choiceorder(explode(',', $step->get_qt_var('_choiceorder'))); 77 78 // Add any missing subquestions. Sometimes people edit questions after they 79 // have been attempted which breaks things. 80 foreach ($this->stemorder as $stemid) { 81 if (!isset($this->stems[$stemid])) { 82 $this->stems[$stemid] = html_writer::span( 83 get_string('deletedsubquestion', 'qtype_match'), 'notifyproblem'); 84 $this->stemformat[$stemid] = FORMAT_HTML; 85 $this->right[$stemid] = 0; 86 } 87 } 88 89 // Add any missing choices. Sometimes people edit questions after they 90 // have been attempted which breaks things. 91 foreach ($this->choiceorder as $choiceid) { 92 if (!isset($this->choices[$choiceid])) { 93 $this->choices[$choiceid] = get_string('deletedchoice', 'qtype_match'); 94 } 95 } 96 } 97 98 /** 99 * Helper method used by both {@link start_attempt()} and 100 * {@link apply_attempt_state()}. 101 * @param array $choiceorder the choices, in order. 102 */ 103 protected function set_choiceorder($choiceorder) { 104 $this->choiceorder = array(); 105 foreach ($choiceorder as $key => $choiceid) { 106 $this->choiceorder[$key + 1] = $choiceid; 107 } 108 } 109 110 public function validate_can_regrade_with_other_version(question_definition $otherversion): ?string { 111 $basemessage = parent::validate_can_regrade_with_other_version($otherversion); 112 if ($basemessage) { 113 return $basemessage; 114 } 115 116 if (count($this->stems) != count($otherversion->stems)) { 117 return get_string('regradeissuenumstemschanged', 'qtype_match'); 118 } 119 120 if (count($this->choices) != count($otherversion->choices)) { 121 return get_string('regradeissuenumchoiceschanged', 'qtype_match'); 122 } 123 124 return null; 125 } 126 127 public function update_attempt_state_data_for_new_version( 128 question_attempt_step $oldstep, question_definition $otherversion) { 129 $startdata = parent::update_attempt_state_data_for_new_version($oldstep, $otherversion); 130 131 // Process stems. 132 $mapping = array_combine(array_keys($otherversion->stems), array_keys($this->stems)); 133 $oldstemorder = explode(',', $oldstep->get_qt_var('_stemorder')); 134 $newstemorder = []; 135 foreach ($oldstemorder as $oldid) { 136 $newstemorder[] = $mapping[$oldid] ?? $oldid; 137 } 138 $startdata['_stemorder'] = implode(',', $newstemorder); 139 140 // Process choices. 141 $mapping = array_combine(array_keys($otherversion->choices), array_keys($this->choices)); 142 $oldchoiceorder = explode(',', $oldstep->get_qt_var('_choiceorder')); 143 $newchoiceorder = []; 144 foreach ($oldchoiceorder as $oldid) { 145 $newchoiceorder[] = $mapping[$oldid] ?? $oldid; 146 } 147 $startdata['_choiceorder'] = implode(',', $newchoiceorder); 148 149 return $startdata; 150 } 151 152 public function get_question_summary() { 153 $question = $this->html_to_text($this->questiontext, $this->questiontextformat); 154 $stems = array(); 155 foreach ($this->stemorder as $stemid) { 156 $stems[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]); 157 } 158 $choices = array(); 159 foreach ($this->choiceorder as $choiceid) { 160 $choices[] = $this->choices[$choiceid]; 161 } 162 return $question . ' {' . implode('; ', $stems) . '} -> {' . 163 implode('; ', $choices) . '}'; 164 } 165 166 public function summarise_response(array $response) { 167 $matches = array(); 168 foreach ($this->stemorder as $key => $stemid) { 169 if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) { 170 $matches[] = $this->html_to_text($this->stems[$stemid], 171 $this->stemformat[$stemid]) . ' -> ' . 172 $this->choices[$this->choiceorder[$response[$this->field($key)]]]; 173 } 174 } 175 if (empty($matches)) { 176 return null; 177 } 178 return implode('; ', $matches); 179 } 180 181 public function classify_response(array $response) { 182 $selectedchoicekeys = array(); 183 foreach ($this->stemorder as $key => $stemid) { 184 if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) { 185 $selectedchoicekeys[$stemid] = $this->choiceorder[$response[$this->field($key)]]; 186 } else { 187 $selectedchoicekeys[$stemid] = 0; 188 } 189 } 190 191 $parts = array(); 192 foreach ($this->stems as $stemid => $stem) { 193 if ($this->right[$stemid] == 0 || !isset($selectedchoicekeys[$stemid])) { 194 // Choice for a deleted subquestion, ignore. (See apply_attempt_state.) 195 continue; 196 } 197 $selectedchoicekey = $selectedchoicekeys[$stemid]; 198 if (empty($selectedchoicekey)) { 199 $parts[$stemid] = question_classified_response::no_response(); 200 continue; 201 } 202 $choice = $this->choices[$selectedchoicekey]; 203 if ($choice == get_string('deletedchoice', 'qtype_match')) { 204 // Deleted choice, ignore. (See apply_attempt_state.) 205 continue; 206 } 207 $parts[$stemid] = new question_classified_response( 208 $selectedchoicekey, $choice, 209 ($selectedchoicekey == $this->right[$stemid]) / count($this->stems)); 210 } 211 return $parts; 212 } 213 214 public function clear_wrong_from_response(array $response) { 215 foreach ($this->stemorder as $key => $stemid) { 216 if (!array_key_exists($this->field($key), $response) || 217 $response[$this->field($key)] != $this->get_right_choice_for($stemid)) { 218 $response[$this->field($key)] = 0; 219 } 220 } 221 return $response; 222 } 223 224 public function get_num_parts_right(array $response) { 225 $numright = 0; 226 foreach ($this->stemorder as $key => $stemid) { 227 $fieldname = $this->field($key); 228 if (!array_key_exists($fieldname, $response)) { 229 continue; 230 } 231 232 $choice = $response[$fieldname]; 233 if ($choice && $this->choiceorder[$choice] == $this->right[$stemid]) { 234 $numright += 1; 235 } 236 } 237 return array($numright, count($this->stemorder)); 238 } 239 240 /** 241 * @param int $key stem number 242 * @return string the question-type variable name. 243 */ 244 protected function field($key) { 245 return 'sub' . $key; 246 } 247 248 public function get_expected_data() { 249 $vars = array(); 250 foreach ($this->stemorder as $key => $notused) { 251 $vars[$this->field($key)] = PARAM_INT; 252 } 253 return $vars; 254 } 255 256 public function get_correct_response() { 257 $response = array(); 258 foreach ($this->stemorder as $key => $stemid) { 259 $response[$this->field($key)] = $this->get_right_choice_for($stemid); 260 } 261 return $response; 262 } 263 264 public function prepare_simulated_post_data($simulatedresponse) { 265 $postdata = array(); 266 $stemtostemids = array_flip(clean_param_array($this->stems, PARAM_NOTAGS)); 267 $choicetochoiceno = array_flip($this->choices); 268 $choicenotochoiceselectvalue = array_flip($this->choiceorder); 269 foreach ($simulatedresponse as $stem => $choice) { 270 $choice = clean_param($choice, PARAM_NOTAGS); 271 $stemid = $stemtostemids[$stem]; 272 $shuffledstemno = array_search($stemid, $this->stemorder); 273 if (empty($choice)) { 274 $choiceselectvalue = 0; 275 } else if ($choicetochoiceno[$choice]) { 276 $choiceselectvalue = $choicenotochoiceselectvalue[$choicetochoiceno[$choice]]; 277 } else { 278 throw new coding_exception("Unknown choice {$choice} in matching question - {$this->name}."); 279 } 280 $postdata[$this->field($shuffledstemno)] = $choiceselectvalue; 281 } 282 return $postdata; 283 } 284 285 public function get_student_response_values_for_simulation($postdata) { 286 $simulatedresponse = array(); 287 foreach ($this->stemorder as $shuffledstemno => $stemid) { 288 if (!empty($postdata[$this->field($shuffledstemno)])) { 289 $choiceselectvalue = $postdata[$this->field($shuffledstemno)]; 290 $choiceno = $this->choiceorder[$choiceselectvalue]; 291 $choice = clean_param($this->choices[$choiceno], PARAM_NOTAGS); 292 $stem = clean_param($this->stems[$stemid], PARAM_NOTAGS); 293 $simulatedresponse[$stem] = $choice; 294 } 295 } 296 ksort($simulatedresponse); 297 return $simulatedresponse; 298 } 299 300 public function get_right_choice_for($stemid) { 301 foreach ($this->choiceorder as $choicekey => $choiceid) { 302 if ($this->right[$stemid] == $choiceid) { 303 return $choicekey; 304 } 305 } 306 } 307 308 public function is_complete_response(array $response) { 309 $complete = true; 310 foreach ($this->stemorder as $key => $stemid) { 311 $complete = $complete && !empty($response[$this->field($key)]); 312 } 313 return $complete; 314 } 315 316 public function is_gradable_response(array $response) { 317 foreach ($this->stemorder as $key => $stemid) { 318 if (!empty($response[$this->field($key)])) { 319 return true; 320 } 321 } 322 return false; 323 } 324 325 public function get_validation_error(array $response) { 326 if ($this->is_complete_response($response)) { 327 return ''; 328 } 329 return get_string('pleaseananswerallparts', 'qtype_match'); 330 } 331 332 public function is_same_response(array $prevresponse, array $newresponse) { 333 foreach ($this->stemorder as $key => $notused) { 334 $fieldname = $this->field($key); 335 if (!question_utils::arrays_same_at_key_integer( 336 $prevresponse, $newresponse, $fieldname)) { 337 return false; 338 } 339 } 340 return true; 341 } 342 343 public function grade_response(array $response) { 344 list($right, $total) = $this->get_num_parts_right($response); 345 $fraction = $right / $total; 346 return array($fraction, question_state::graded_state_for_fraction($fraction)); 347 } 348 349 public function compute_final_grade($responses, $totaltries) { 350 $totalstemscore = 0; 351 foreach ($this->stemorder as $key => $stemid) { 352 $fieldname = $this->field($key); 353 354 $lastwrongindex = -1; 355 $finallyright = false; 356 foreach ($responses as $i => $response) { 357 if (!array_key_exists($fieldname, $response) || !$response[$fieldname] || 358 $this->choiceorder[$response[$fieldname]] != $this->right[$stemid]) { 359 $lastwrongindex = $i; 360 $finallyright = false; 361 } else { 362 $finallyright = true; 363 } 364 } 365 366 if ($finallyright) { 367 $totalstemscore += max(0, 1 - ($lastwrongindex + 1) * $this->penalty); 368 } 369 } 370 371 return $totalstemscore / count($this->stemorder); 372 } 373 374 public function get_stem_order() { 375 return $this->stemorder; 376 } 377 378 public function get_choice_order() { 379 return $this->choiceorder; 380 } 381 382 public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) { 383 if ($component == 'qtype_match' && $filearea == 'subquestion') { 384 $subqid = reset($args); // Itemid is sub question id. 385 return array_key_exists($subqid, $this->stems); 386 387 } else if ($component == 'question' && in_array($filearea, 388 array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) { 389 return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args); 390 391 } else if ($component == 'question' && $filearea == 'hint') { 392 return $this->check_hint_file_access($qa, $options, $args); 393 394 } else { 395 return parent::check_file_access($qa, $options, $component, $filearea, 396 $args, $forcedownload); 397 } 398 } 399 400 /** 401 * Return the question settings that define this question as structured data. 402 * 403 * @param question_attempt $qa the current attempt for which we are exporting the settings. 404 * @param question_display_options $options the question display options which say which aspects of the question 405 * should be visible. 406 * @return mixed structure representing the question settings. In web services, this will be JSON-encoded. 407 */ 408 public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) { 409 // This is a partial implementation, returning only the most relevant question settings for now, 410 // ideally, we should return as much as settings as possible (depending on the state and display options). 411 412 return [ 413 'shufflestems' => $this->shufflestems, 414 ]; 415 } 416 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body