Merge branch 'MDL-67513-m310' of https://github.com/NeillM/moodle into MOODLE_310_STABLE
[moodle.git] / question / type / match / question.php
CommitLineData
93cadb1e 1<?php
93cadb1e
TH
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
93cadb1e 17/**
d3603157 18 * Matching question definition class.
93cadb1e 19 *
9887aaeb
TH
20 * @package qtype_match
21 * @copyright 2009 The Open University
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
93cadb1e
TH
23 */
24
25
a17b297d
TH
26defined('MOODLE_INTERNAL') || die();
27
97562c4d 28require_once($CFG->dirroot . '/question/type/questionbase.php');
a17b297d 29
93cadb1e
TH
30/**
31 * Represents a matching question.
32 *
9887aaeb
TH
33 * @copyright 2009 The Open University
34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
93cadb1e
TH
35 */
36class 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;
1c2ed7c5 41 public $correctfeedbackformat;
93cadb1e 42 public $partiallycorrectfeedback;
1c2ed7c5 43 public $partiallycorrectfeedbackformat;
93cadb1e 44 public $incorrectfeedback;
1c2ed7c5 45 public $incorrectfeedbackformat;
93cadb1e
TH
46
47 /** @var array of question stems. */
48 public $stems;
b060e749
TH
49 /** @var int[] FORMAT_... type for each stem. */
50 public $stemformat;
93cadb1e
TH
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
1da821bb 61 public function start_attempt(question_attempt_step $step, $variant) {
ef31a283
TH
62 $this->stemorder = array_keys($this->stems);
63 if ($this->shufflestems) {
64 shuffle($this->stemorder);
93cadb1e 65 }
ef31a283
TH
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')));
ce956618
TH
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 }
ef31a283
TH
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) {
93cadb1e 104 $this->choiceorder = array();
ce956618
TH
105 foreach ($choiceorder as $key => $choiceid) {
106 $this->choiceorder[$key + 1] = $choiceid;
93cadb1e
TH
107 }
108 }
109
110 public function get_question_summary() {
22cebed5 111 $question = $this->html_to_text($this->questiontext, $this->questiontextformat);
93cadb1e
TH
112 $stems = array();
113 foreach ($this->stemorder as $stemid) {
22cebed5 114 $stems[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]);
93cadb1e
TH
115 }
116 $choices = array();
117 foreach ($this->choiceorder as $choiceid) {
118 $choices[] = $this->choices[$choiceid];
119 }
120 return $question . ' {' . implode('; ', $stems) . '} -> {' .
121 implode('; ', $choices) . '}';
122 }
123
124 public function summarise_response(array $response) {
125 $matches = array();
126 foreach ($this->stemorder as $key => $stemid) {
127 if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
3758786f
TH
128 $matches[] = $this->html_to_text($this->stems[$stemid],
129 $this->stemformat[$stemid]) . ' -> ' .
130 $this->choices[$this->choiceorder[$response[$this->field($key)]]];
93cadb1e
TH
131 }
132 }
133 if (empty($matches)) {
134 return null;
135 }
136 return implode('; ', $matches);
137 }
138
139 public function classify_response(array $response) {
53eee12a 140 $selectedchoicekeys = array();
93cadb1e
TH
141 foreach ($this->stemorder as $key => $stemid) {
142 if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
53eee12a 143 $selectedchoicekeys[$stemid] = $this->choiceorder[$response[$this->field($key)]];
93cadb1e 144 } else {
53eee12a 145 $selectedchoicekeys[$stemid] = 0;
93cadb1e
TH
146 }
147 }
148
149 $parts = array();
150 foreach ($this->stems as $stemid => $stem) {
53eee12a
TH
151 if ($this->right[$stemid] == 0 || !isset($selectedchoicekeys[$stemid])) {
152 // Choice for a deleted subquestion, ignore. (See apply_attempt_state.)
153 continue;
154 }
155 $selectedchoicekey = $selectedchoicekeys[$stemid];
156 if (empty($selectedchoicekey)) {
93cadb1e
TH
157 $parts[$stemid] = question_classified_response::no_response();
158 continue;
159 }
53eee12a
TH
160 $choice = $this->choices[$selectedchoicekey];
161 if ($choice == get_string('deletedchoice', 'qtype_match')) {
162 // Deleted choice, ignore. (See apply_attempt_state.)
163 continue;
164 }
93cadb1e 165 $parts[$stemid] = new question_classified_response(
53eee12a
TH
166 $selectedchoicekey, $choice,
167 ($selectedchoicekey == $this->right[$stemid]) / count($this->stems));
93cadb1e
TH
168 }
169 return $parts;
170 }
171
172 public function clear_wrong_from_response(array $response) {
173 foreach ($this->stemorder as $key => $stemid) {
174 if (!array_key_exists($this->field($key), $response) ||
175 $response[$this->field($key)] != $this->get_right_choice_for($stemid)) {
176 $response[$this->field($key)] = 0;
177 }
178 }
179 return $response;
180 }
181
182 public function get_num_parts_right(array $response) {
183 $numright = 0;
184 foreach ($this->stemorder as $key => $stemid) {
185 $fieldname = $this->field($key);
186 if (!array_key_exists($fieldname, $response)) {
187 continue;
188 }
189
190 $choice = $response[$fieldname];
191 if ($choice && $this->choiceorder[$choice] == $this->right[$stemid]) {
192 $numright += 1;
193 }
194 }
195 return array($numright, count($this->stemorder));
196 }
197
198 /**
f7970e3c 199 * @param int $key stem number
93cadb1e
TH
200 * @return string the question-type variable name.
201 */
202 protected function field($key) {
203 return 'sub' . $key;
204 }
205
206 public function get_expected_data() {
207 $vars = array();
208 foreach ($this->stemorder as $key => $notused) {
1e12c120 209 $vars[$this->field($key)] = PARAM_INT;
93cadb1e
TH
210 }
211 return $vars;
212 }
213
214 public function get_correct_response() {
215 $response = array();
216 foreach ($this->stemorder as $key => $stemid) {
217 $response[$this->field($key)] = $this->get_right_choice_for($stemid);
218 }
219 return $response;
220 }
221
388f0473
JP
222 public function prepare_simulated_post_data($simulatedresponse) {
223 $postdata = array();
58794ac9 224 $stemtostemids = array_flip(clean_param_array($this->stems, PARAM_NOTAGS));
388f0473
JP
225 $choicetochoiceno = array_flip($this->choices);
226 $choicenotochoiceselectvalue = array_flip($this->choiceorder);
58794ac9
JP
227 foreach ($simulatedresponse as $stem => $choice) {
228 $choice = clean_param($choice, PARAM_NOTAGS);
229 $stemid = $stemtostemids[$stem];
f8f37f1e
JP
230 $shuffledstemno = array_search($stemid, $this->stemorder);
231 if (empty($choice)) {
232 $choiceselectvalue = 0;
233 } else if ($choicetochoiceno[$choice]) {
234 $choiceselectvalue = $choicenotochoiceselectvalue[$choicetochoiceno[$choice]];
235 } else {
f4fe3968 236 throw new coding_exception("Unknown choice {$choice} in matching question - {$this->name}.");
f8f37f1e
JP
237 }
238 $postdata[$this->field($shuffledstemno)] = $choiceselectvalue;
388f0473
JP
239 }
240 return $postdata;
241 }
242
58794ac9
JP
243 public function get_student_response_values_for_simulation($postdata) {
244 $simulatedresponse = array();
245 foreach ($this->stemorder as $shuffledstemno => $stemid) {
246 if (!empty($postdata[$this->field($shuffledstemno)])) {
247 $choiceselectvalue = $postdata[$this->field($shuffledstemno)];
248 $choiceno = $this->choiceorder[$choiceselectvalue];
249 $choice = clean_param($this->choices[$choiceno], PARAM_NOTAGS);
250 $stem = clean_param($this->stems[$stemid], PARAM_NOTAGS);
251 $simulatedresponse[$stem] = $choice;
252 }
253 }
254 ksort($simulatedresponse);
255 return $simulatedresponse;
256 }
257
93cadb1e
TH
258 public function get_right_choice_for($stemid) {
259 foreach ($this->choiceorder as $choicekey => $choiceid) {
260 if ($this->right[$stemid] == $choiceid) {
261 return $choicekey;
262 }
263 }
264 }
265
266 public function is_complete_response(array $response) {
267 $complete = true;
268 foreach ($this->stemorder as $key => $stemid) {
269 $complete = $complete && !empty($response[$this->field($key)]);
270 }
271 return $complete;
272 }
273
274 public function is_gradable_response(array $response) {
275 foreach ($this->stemorder as $key => $stemid) {
276 if (!empty($response[$this->field($key)])) {
277 return true;
278 }
279 }
280 return false;
281 }
282
283 public function get_validation_error(array $response) {
284 if ($this->is_complete_response($response)) {
285 return '';
286 }
287 return get_string('pleaseananswerallparts', 'qtype_match');
288 }
289
290 public function is_same_response(array $prevresponse, array $newresponse) {
291 foreach ($this->stemorder as $key => $notused) {
292 $fieldname = $this->field($key);
3758786f
TH
293 if (!question_utils::arrays_same_at_key_integer(
294 $prevresponse, $newresponse, $fieldname)) {
93cadb1e
TH
295 return false;
296 }
297 }
298 return true;
299 }
300
301 public function grade_response(array $response) {
302 list($right, $total) = $this->get_num_parts_right($response);
303 $fraction = $right / $total;
304 return array($fraction, question_state::graded_state_for_fraction($fraction));
305 }
306
307 public function compute_final_grade($responses, $totaltries) {
308 $totalstemscore = 0;
309 foreach ($this->stemorder as $key => $stemid) {
310 $fieldname = $this->field($key);
311
312 $lastwrongindex = -1;
313 $finallyright = false;
314 foreach ($responses as $i => $response) {
315 if (!array_key_exists($fieldname, $response) || !$response[$fieldname] ||
316 $this->choiceorder[$response[$fieldname]] != $this->right[$stemid]) {
317 $lastwrongindex = $i;
318 $finallyright = false;
319 } else {
320 $finallyright = true;
321 }
322 }
323
324 if ($finallyright) {
325 $totalstemscore += max(0, 1 - ($lastwrongindex + 1) * $this->penalty);
326 }
327 }
328
329 return $totalstemscore / count($this->stemorder);
330 }
331
332 public function get_stem_order() {
333 return $this->stemorder;
334 }
335
336 public function get_choice_order() {
337 return $this->choiceorder;
338 }
339
340 public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
341 if ($component == 'qtype_match' && $filearea == 'subquestion') {
9887aaeb 342 $subqid = reset($args); // Itemid is sub question id.
93cadb1e
TH
343 return array_key_exists($subqid, $this->stems);
344
345 } else if ($component == 'question' && in_array($filearea,
346 array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
c28bfbef 347 return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
93cadb1e
TH
348
349 } else if ($component == 'question' && $filearea == 'hint') {
350 return $this->check_hint_file_access($qa, $options, $args);
351
352 } else {
3758786f
TH
353 return parent::check_file_access($qa, $options, $component, $filearea,
354 $args, $forcedownload);
93cadb1e
TH
355 }
356 }
d0b4b6fa
JL
357
358 /**
359 * Return the question settings that define this question as structured data.
360 *
361 * @param question_attempt $qa the current attempt for which we are exporting the settings.
362 * @param question_display_options $options the question display options which say which aspects of the question
363 * should be visible.
364 * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
365 */
366 public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
367 // This is a partial implementation, returning only the most relevant question settings for now,
368 // ideally, we should return as much as settings as possible (depending on the state and display options).
369
370 return [
371 'shufflestems' => $this->shufflestems,
372 ];
373 }
93cadb1e 374}