MDL-57587 question file access: fix regression caused by MDL-53744
[moodle.git] / question / type / match / question.php
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/>.
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  */
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->dirroot . '/question/type/questionbase.php');
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;
40     public $correctfeedback;
41     public $correctfeedbackformat;
42     public $partiallycorrectfeedback;
43     public $partiallycorrectfeedbackformat;
44     public $incorrectfeedback;
45     public $incorrectfeedbackformat;
47     /** @var array of question stems. */
48     public $stems;
49     /** @var array of choices that can be matched to each stem. */
50     public $choices;
51     /** @var array index of the right choice for each stem. */
52     public $right;
54     /** @var array shuffled stem indexes. */
55     protected $stemorder;
56     /** @var array shuffled choice indexes. */
57     protected $choiceorder;
59     public function start_attempt(question_attempt_step $step, $variant) {
60         $this->stemorder = array_keys($this->stems);
61         if ($this->shufflestems) {
62             shuffle($this->stemorder);
63         }
64         $step->set_qt_var('_stemorder', implode(',', $this->stemorder));
66         $choiceorder = array_keys($this->choices);
67         shuffle($choiceorder);
68         $step->set_qt_var('_choiceorder', implode(',', $choiceorder));
69         $this->set_choiceorder($choiceorder);
70     }
72     public function apply_attempt_state(question_attempt_step $step) {
73         $this->stemorder = explode(',', $step->get_qt_var('_stemorder'));
74         $this->set_choiceorder(explode(',', $step->get_qt_var('_choiceorder')));
76         // Add any missing subquestions. Sometimes people edit questions after they
77         // have been attempted which breaks things.
78         foreach ($this->stemorder as $stemid) {
79             if (!isset($this->stems[$stemid])) {
80                 $this->stems[$stemid] = html_writer::span(
81                         get_string('deletedsubquestion', 'qtype_match'), 'notifyproblem');
82                 $this->stemformat[$stemid] = FORMAT_HTML;
83                 $this->right[$stemid] = 0;
84             }
85         }
87         // Add any missing choices. Sometimes people edit questions after they
88         // have been attempted which breaks things.
89         foreach ($this->choiceorder as $choiceid) {
90             if (!isset($this->choices[$choiceid])) {
91                 $this->choices[$choiceid] = get_string('deletedchoice', 'qtype_match');
92             }
93         }
94     }
96     /**
97      * Helper method used by both {@link start_attempt()} and
98      * {@link apply_attempt_state()}.
99      * @param array $choiceorder the choices, in order.
100      */
101     protected function set_choiceorder($choiceorder) {
102         $this->choiceorder = array();
103         foreach ($choiceorder as $key => $choiceid) {
104             $this->choiceorder[$key + 1] = $choiceid;
105         }
106     }
108     public function get_question_summary() {
109         $question = $this->html_to_text($this->questiontext, $this->questiontextformat);
110         $stems = array();
111         foreach ($this->stemorder as $stemid) {
112             $stems[] = $this->html_to_text($this->stems[$stemid], $this->stemformat[$stemid]);
113         }
114         $choices = array();
115         foreach ($this->choiceorder as $choiceid) {
116             $choices[] = $this->choices[$choiceid];
117         }
118         return $question . ' {' . implode('; ', $stems) . '} -> {' .
119                 implode('; ', $choices) . '}';
120     }
122     public function summarise_response(array $response) {
123         $matches = array();
124         foreach ($this->stemorder as $key => $stemid) {
125             if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
126                 $matches[] = $this->html_to_text($this->stems[$stemid],
127                         $this->stemformat[$stemid]) . ' -> ' .
128                         $this->choices[$this->choiceorder[$response[$this->field($key)]]];
129             }
130         }
131         if (empty($matches)) {
132             return null;
133         }
134         return implode('; ', $matches);
135     }
137     public function classify_response(array $response) {
138         $selectedchoicekeys = array();
139         foreach ($this->stemorder as $key => $stemid) {
140             if (array_key_exists($this->field($key), $response) && $response[$this->field($key)]) {
141                 $selectedchoicekeys[$stemid] = $this->choiceorder[$response[$this->field($key)]];
142             } else {
143                 $selectedchoicekeys[$stemid] = 0;
144             }
145         }
147         $parts = array();
148         foreach ($this->stems as $stemid => $stem) {
149             if ($this->right[$stemid] == 0 || !isset($selectedchoicekeys[$stemid])) {
150                 // Choice for a deleted subquestion, ignore. (See apply_attempt_state.)
151                 continue;
152             }
153             $selectedchoicekey = $selectedchoicekeys[$stemid];
154             if (empty($selectedchoicekey)) {
155                 $parts[$stemid] = question_classified_response::no_response();
156                 continue;
157             }
158             $choice = $this->choices[$selectedchoicekey];
159             if ($choice == get_string('deletedchoice', 'qtype_match')) {
160                 // Deleted choice, ignore. (See apply_attempt_state.)
161                 continue;
162             }
163             $parts[$stemid] = new question_classified_response(
164                     $selectedchoicekey, $choice,
165                     ($selectedchoicekey == $this->right[$stemid]) / count($this->stems));
166         }
167         return $parts;
168     }
170     public function clear_wrong_from_response(array $response) {
171         foreach ($this->stemorder as $key => $stemid) {
172             if (!array_key_exists($this->field($key), $response) ||
173                     $response[$this->field($key)] != $this->get_right_choice_for($stemid)) {
174                 $response[$this->field($key)] = 0;
175             }
176         }
177         return $response;
178     }
180     public function get_num_parts_right(array $response) {
181         $numright = 0;
182         foreach ($this->stemorder as $key => $stemid) {
183             $fieldname = $this->field($key);
184             if (!array_key_exists($fieldname, $response)) {
185                 continue;
186             }
188             $choice = $response[$fieldname];
189             if ($choice && $this->choiceorder[$choice] == $this->right[$stemid]) {
190                 $numright += 1;
191             }
192         }
193         return array($numright, count($this->stemorder));
194     }
196     /**
197      * @param int $key stem number
198      * @return string the question-type variable name.
199      */
200     protected function field($key) {
201         return 'sub' . $key;
202     }
204     public function get_expected_data() {
205         $vars = array();
206         foreach ($this->stemorder as $key => $notused) {
207             $vars[$this->field($key)] = PARAM_INT;
208         }
209         return $vars;
210     }
212     public function get_correct_response() {
213         $response = array();
214         foreach ($this->stemorder as $key => $stemid) {
215             $response[$this->field($key)] = $this->get_right_choice_for($stemid);
216         }
217         return $response;
218     }
220     public function prepare_simulated_post_data($simulatedresponse) {
221         $postdata = array();
222         $stemtostemids = array_flip(clean_param_array($this->stems, PARAM_NOTAGS));
223         $choicetochoiceno = array_flip($this->choices);
224         $choicenotochoiceselectvalue = array_flip($this->choiceorder);
225         foreach ($simulatedresponse as $stem => $choice) {
226             $choice = clean_param($choice, PARAM_NOTAGS);
227             $stemid = $stemtostemids[$stem];
228             $shuffledstemno = array_search($stemid, $this->stemorder);
229             if (empty($choice)) {
230                 $choiceselectvalue = 0;
231             } else if ($choicetochoiceno[$choice]) {
232                 $choiceselectvalue = $choicenotochoiceselectvalue[$choicetochoiceno[$choice]];
233             } else {
234                 throw new coding_exception("Unknown choice {$choice} in matching question - {$this->name}.");
235             }
236             $postdata[$this->field($shuffledstemno)] = $choiceselectvalue;
237         }
238         return $postdata;
239     }
241     public function get_student_response_values_for_simulation($postdata) {
242         $simulatedresponse = array();
243         foreach ($this->stemorder as $shuffledstemno => $stemid) {
244             if (!empty($postdata[$this->field($shuffledstemno)])) {
245                 $choiceselectvalue = $postdata[$this->field($shuffledstemno)];
246                 $choiceno = $this->choiceorder[$choiceselectvalue];
247                 $choice = clean_param($this->choices[$choiceno], PARAM_NOTAGS);
248                 $stem = clean_param($this->stems[$stemid], PARAM_NOTAGS);
249                 $simulatedresponse[$stem] = $choice;
250             }
251         }
252         ksort($simulatedresponse);
253         return $simulatedresponse;
254     }
256     public function get_right_choice_for($stemid) {
257         foreach ($this->choiceorder as $choicekey => $choiceid) {
258             if ($this->right[$stemid] == $choiceid) {
259                 return $choicekey;
260             }
261         }
262     }
264     public function is_complete_response(array $response) {
265         $complete = true;
266         foreach ($this->stemorder as $key => $stemid) {
267             $complete = $complete && !empty($response[$this->field($key)]);
268         }
269         return $complete;
270     }
272     public function is_gradable_response(array $response) {
273         foreach ($this->stemorder as $key => $stemid) {
274             if (!empty($response[$this->field($key)])) {
275                 return true;
276             }
277         }
278         return false;
279     }
281     public function get_validation_error(array $response) {
282         if ($this->is_complete_response($response)) {
283             return '';
284         }
285         return get_string('pleaseananswerallparts', 'qtype_match');
286     }
288     public function is_same_response(array $prevresponse, array $newresponse) {
289         foreach ($this->stemorder as $key => $notused) {
290             $fieldname = $this->field($key);
291             if (!question_utils::arrays_same_at_key_integer(
292                     $prevresponse, $newresponse, $fieldname)) {
293                 return false;
294             }
295         }
296         return true;
297     }
299     public function grade_response(array $response) {
300         list($right, $total) = $this->get_num_parts_right($response);
301         $fraction = $right / $total;
302         return array($fraction, question_state::graded_state_for_fraction($fraction));
303     }
305     public function compute_final_grade($responses, $totaltries) {
306         $totalstemscore = 0;
307         foreach ($this->stemorder as $key => $stemid) {
308             $fieldname = $this->field($key);
310             $lastwrongindex = -1;
311             $finallyright = false;
312             foreach ($responses as $i => $response) {
313                 if (!array_key_exists($fieldname, $response) || !$response[$fieldname] ||
314                         $this->choiceorder[$response[$fieldname]] != $this->right[$stemid]) {
315                     $lastwrongindex = $i;
316                     $finallyright = false;
317                 } else {
318                     $finallyright = true;
319                 }
320             }
322             if ($finallyright) {
323                 $totalstemscore += max(0, 1 - ($lastwrongindex + 1) * $this->penalty);
324             }
325         }
327         return $totalstemscore / count($this->stemorder);
328     }
330     public function get_stem_order() {
331         return $this->stemorder;
332     }
334     public function get_choice_order() {
335         return $this->choiceorder;
336     }
338     public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
339         if ($component == 'qtype_match' && $filearea == 'subquestion') {
340             $subqid = reset($args); // Itemid is sub question id.
341             return array_key_exists($subqid, $this->stems);
343         } else if ($component == 'question' && in_array($filearea,
344                 array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
345             return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
347         } else if ($component == 'question' && $filearea == 'hint') {
348             return $this->check_hint_file_access($qa, $options, $args);
350         } else {
351             return parent::check_file_access($qa, $options, $component, $filearea,
352                     $args, $forcedownload);
353         }
354     }