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
26defined('MOODLE_INTERNAL') || die();
27
28require_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 */
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;
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 get_question_summary() {
111        $question = $this->html_to_text($this->questiontext, $this->questiontextformat);
112        $stems = array();
113        foreach ($this->stemorder as $stemid) {
114            $stems[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]);
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)]) {
128                $matches[] = $this->html_to_text($this->stems[$stemid],
129                        $this->stemformat[$stemid]) . ' -> ' .
130                        $this->choices[$this->choiceorder[$response[$this->field($key)]]];
131            }
132        }
133        if (empty($matches)) {
134            return null;
135        }
136        return implode('; ', $matches);
137    }
138
139    public function classify_response(array $response) {
140        $selectedchoicekeys = array();
141        foreach ($this->stemorder as $key => $stemid) {
142            if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
143                $selectedchoicekeys[$stemid] = $this->choiceorder[$response[$this->field($key)]];
144            } else {
145                $selectedchoicekeys[$stemid] = 0;
146            }
147        }
148
149        $parts = array();
150        foreach ($this->stems as $stemid => $stem) {
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)) {
157                $parts[$stemid] = question_classified_response::no_response();
158                continue;
159            }
160            $choice = $this->choices[$selectedchoicekey];
161            if ($choice == get_string('deletedchoice', 'qtype_match')) {
162                // Deleted choice, ignore. (See apply_attempt_state.)
163                continue;
164            }
165            $parts[$stemid] = new question_classified_response(
166                    $selectedchoicekey, $choice,
167                    ($selectedchoicekey == $this->right[$stemid]) / count($this->stems));
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    /**
199     * @param int $key stem number
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) {
209            $vars[$this->field($key)] = PARAM_INT;
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
222    public function prepare_simulated_post_data($simulatedresponse) {
223        $postdata = array();
224        $stemtostemids = array_flip(clean_param_array($this->stems, PARAM_NOTAGS));
225        $choicetochoiceno = array_flip($this->choices);
226        $choicenotochoiceselectvalue = array_flip($this->choiceorder);
227        foreach ($simulatedresponse as $stem => $choice) {
228            $choice = clean_param($choice, PARAM_NOTAGS);
229            $stemid = $stemtostemids[$stem];
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 {
236                throw new coding_exception("Unknown choice {$choice} in matching question - {$this->name}.");
237            }
238            $postdata[$this->field($shuffledstemno)] = $choiceselectvalue;
239        }
240        return $postdata;
241    }
242
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
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);
293            if (!question_utils::arrays_same_at_key_integer(
294                    $prevresponse, $newresponse, $fieldname)) {
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') {
342            $subqid = reset($args); // Itemid is sub question id.
343            return array_key_exists($subqid, $this->stems);
344
345        } else if ($component == 'question' && in_array($filearea,
346                array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
347            return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
348
349        } else if ($component == 'question' && $filearea == 'hint') {
350            return $this->check_hint_file_access($qa, $options, $args);
351
352        } else {
353            return parent::check_file_access($qa, $options, $component, $filearea,
354                    $args, $forcedownload);
355        }
356    }
357}
358