MDL-57587 question file access: fix regression caused by MDL-53744
[moodle.git] / question / type / multichoice / 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  * Multiple choice question definition classes.
19  *
20  * @package    qtype
21  * @subpackage multichoice
22  * @copyright  2009 The Open University
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->dirroot . '/question/type/questionbase.php');
31 /**
32  * Base class for multiple choice questions. The parts that are common to
33  * single select and multiple select.
34  *
35  * @copyright  2009 The Open University
36  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 abstract class qtype_multichoice_base extends question_graded_automatically {
39     const LAYOUT_DROPDOWN = 0;
40     const LAYOUT_VERTICAL = 1;
41     const LAYOUT_HORIZONTAL = 2;
43     public $answers;
45     public $shuffleanswers;
46     public $answernumbering;
47     public $layout = self::LAYOUT_VERTICAL;
49     public $correctfeedback;
50     public $correctfeedbackformat;
51     public $partiallycorrectfeedback;
52     public $partiallycorrectfeedbackformat;
53     public $incorrectfeedback;
54     public $incorrectfeedbackformat;
56     protected $order = null;
58     public function start_attempt(question_attempt_step $step, $variant) {
59         $this->order = array_keys($this->answers);
60         if ($this->shuffleanswers) {
61             shuffle($this->order);
62         }
63         $step->set_qt_var('_order', implode(',', $this->order));
64     }
66     public function apply_attempt_state(question_attempt_step $step) {
67         $this->order = explode(',', $step->get_qt_var('_order'));
69         // Add any missing answers. Sometimes people edit questions after they
70         // have been attempted which breaks things.
71         foreach ($this->order as $ansid) {
72             if (isset($this->answers[$ansid])) {
73                 continue;
74             }
75             $a = new stdClass();
76             $a->id = 0;
77             $a->answer = html_writer::span(get_string('deletedchoice', 'qtype_multichoice'),
78                     'notifyproblem');
79             $a->answerformat = FORMAT_HTML;
80             $a->fraction = 0;
81             $a->feedback = '';
82             $a->feedbackformat = FORMAT_HTML;
83             $this->answers[$ansid] = $this->qtype->make_answer($a);
84             $this->answers[$ansid]->answerformat = FORMAT_HTML;
85         }
86     }
88     public function get_question_summary() {
89         $question = $this->html_to_text($this->questiontext, $this->questiontextformat);
90         $choices = array();
91         foreach ($this->order as $ansid) {
92             $choices[] = $this->html_to_text($this->answers[$ansid]->answer,
93                     $this->answers[$ansid]->answerformat);
94         }
95         return $question . ': ' . implode('; ', $choices);
96     }
98     public function get_order(question_attempt $qa) {
99         $this->init_order($qa);
100         return $this->order;
101     }
103     protected function init_order(question_attempt $qa) {
104         if (is_null($this->order)) {
105             $this->order = explode(',', $qa->get_step(0)->get_qt_var('_order'));
106         }
107     }
109     public abstract function get_response(question_attempt $qa);
111     public abstract function is_choice_selected($response, $value);
113     public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
114         if ($component == 'question' && in_array($filearea,
115                 array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'))) {
116             return $this->check_combined_feedback_file_access($qa, $options, $filearea, $args);
118         } else if ($component == 'question' && $filearea == 'answer') {
119             $answerid = reset($args); // Itemid is answer id.
120             return  in_array($answerid, $this->order);
122         } else if ($component == 'question' && $filearea == 'answerfeedback') {
123             $answerid = reset($args); // Itemid is answer id.
124             $response = $this->get_response($qa);
125             $isselected = false;
126             foreach ($this->order as $value => $ansid) {
127                 if ($ansid == $answerid) {
128                     $isselected = $this->is_choice_selected($response, $value);
129                     break;
130                 }
131             }
132             // Param $options->suppresschoicefeedback is a hack specific to the
133             // oumultiresponse question type. It would be good to refactor to
134             // avoid refering to it here.
135             return $options->feedback && empty($options->suppresschoicefeedback) &&
136                     $isselected;
138         } else if ($component == 'question' && $filearea == 'hint') {
139             return $this->check_hint_file_access($qa, $options, $args);
141         } else {
142             return parent::check_file_access($qa, $options, $component, $filearea,
143                     $args, $forcedownload);
144         }
145     }
149 /**
150  * Represents a multiple choice question where only one choice should be selected.
151  *
152  * @copyright  2009 The Open University
153  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
154  */
155 class qtype_multichoice_single_question extends qtype_multichoice_base {
156     public function get_renderer(moodle_page $page) {
157         return $page->get_renderer('qtype_multichoice', 'single');
158     }
160     public function get_min_fraction() {
161         $minfraction = 0;
162         foreach ($this->answers as $ans) {
163             $minfraction = min($minfraction, $ans->fraction);
164         }
165         return $minfraction;
166     }
168     /**
169      * Return an array of the question type variables that could be submitted
170      * as part of a question of this type, with their types, so they can be
171      * properly cleaned.
172      * @return array variable name => PARAM_... constant.
173      */
174     public function get_expected_data() {
175         return array('answer' => PARAM_INT);
176     }
178     public function summarise_response(array $response) {
179         if (!array_key_exists('answer', $response) ||
180                 !array_key_exists($response['answer'], $this->order)) {
181             return null;
182         }
183         $ansid = $this->order[$response['answer']];
184         return $this->html_to_text($this->answers[$ansid]->answer,
185                 $this->answers[$ansid]->answerformat);
186     }
188     public function classify_response(array $response) {
189         if (!array_key_exists('answer', $response) ||
190                 !array_key_exists($response['answer'], $this->order)) {
191             return array($this->id => question_classified_response::no_response());
192         }
193         $choiceid = $this->order[$response['answer']];
194         $ans = $this->answers[$choiceid];
195         return array($this->id => new question_classified_response($choiceid,
196                 $this->html_to_text($ans->answer, $ans->answerformat), $ans->fraction));
197     }
199     public function get_correct_response() {
200         foreach ($this->order as $key => $answerid) {
201             if (question_state::graded_state_for_fraction(
202                     $this->answers[$answerid]->fraction)->is_correct()) {
203                 return array('answer' => $key);
204             }
205         }
206         return array();
207     }
209     public function prepare_simulated_post_data($simulatedresponse) {
210         $ansid = 0;
211         foreach ($this->answers as $answer) {
212             if (clean_param($answer->answer, PARAM_NOTAGS) == $simulatedresponse['answer']) {
213                 $ansid = $answer->id;
214             }
215         }
216         if ($ansid) {
217             return array('answer' => array_search($ansid, $this->order));
218         } else {
219             return array();
220         }
221     }
223     public function get_student_response_values_for_simulation($postdata) {
224         if (!isset($postdata['answer'])) {
225             return array();
226         } else {
227             $answer = $this->answers[$this->order[$postdata['answer']]];
228             return array('answer' => clean_param($answer->answer, PARAM_NOTAGS));
229         }
230     }
232     public function is_same_response(array $prevresponse, array $newresponse) {
233         return question_utils::arrays_same_at_key($prevresponse, $newresponse, 'answer');
234     }
236     public function is_complete_response(array $response) {
237         return array_key_exists('answer', $response) && $response['answer'] !== '';
238     }
240     public function is_gradable_response(array $response) {
241         return $this->is_complete_response($response);
242     }
244     public function grade_response(array $response) {
245         if (array_key_exists('answer', $response) &&
246                 array_key_exists($response['answer'], $this->order)) {
247             $fraction = $this->answers[$this->order[$response['answer']]]->fraction;
248         } else {
249             $fraction = 0;
250         }
251         return array($fraction, question_state::graded_state_for_fraction($fraction));
252     }
254     public function get_validation_error(array $response) {
255         if ($this->is_gradable_response($response)) {
256             return '';
257         }
258         return get_string('pleaseselectananswer', 'qtype_multichoice');
259     }
261     public function get_response(question_attempt $qa) {
262         return $qa->get_last_qt_var('answer', -1);
263     }
265     public function is_choice_selected($response, $value) {
266         return (string) $response === (string) $value;
267     }
271 /**
272  * Represents a multiple choice question where multiple choices can be selected.
273  *
274  * @copyright  2009 The Open University
275  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
276  */
277 class qtype_multichoice_multi_question extends qtype_multichoice_base {
278     public function get_renderer(moodle_page $page) {
279         return $page->get_renderer('qtype_multichoice', 'multi');
280     }
282     public function get_min_fraction() {
283         return 0;
284     }
286     public function clear_wrong_from_response(array $response) {
287         foreach ($this->order as $key => $ans) {
288             if (array_key_exists($this->field($key), $response) &&
289                     question_state::graded_state_for_fraction(
290                     $this->answers[$ans]->fraction)->is_incorrect()) {
291                 $response[$this->field($key)] = 0;
292             }
293         }
294         return $response;
295     }
297     public function get_num_parts_right(array $response) {
298         $numright = 0;
299         foreach ($this->order as $key => $ans) {
300             $fieldname = $this->field($key);
301             if (!array_key_exists($fieldname, $response) || !$response[$fieldname]) {
302                 continue;
303             }
305             if (!question_state::graded_state_for_fraction(
306                     $this->answers[$ans]->fraction)->is_incorrect()) {
307                 $numright += 1;
308             }
309         }
310         return array($numright, count($this->order));
311     }
313     /**
314      * @param int $key choice number
315      * @return string the question-type variable name.
316      */
317     protected function field($key) {
318         return 'choice' . $key;
319     }
321     public function get_expected_data() {
322         $expected = array();
323         foreach ($this->order as $key => $notused) {
324             $expected[$this->field($key)] = PARAM_BOOL;
325         }
326         return $expected;
327     }
329     public function summarise_response(array $response) {
330         $selectedchoices = array();
331         foreach ($this->order as $key => $ans) {
332             $fieldname = $this->field($key);
333             if (array_key_exists($fieldname, $response) && $response[$fieldname]) {
334                 $selectedchoices[] = $this->html_to_text($this->answers[$ans]->answer,
335                         $this->answers[$ans]->answerformat);
336             }
337         }
338         if (empty($selectedchoices)) {
339             return null;
340         }
341         return implode('; ', $selectedchoices);
342     }
344     public function classify_response(array $response) {
345         $selectedchoices = array();
346         foreach ($this->order as $key => $ansid) {
347             $fieldname = $this->field($key);
348             if (array_key_exists($fieldname, $response) && $response[$fieldname]) {
349                 $selectedchoices[$ansid] = 1;
350             }
351         }
352         $choices = array();
353         foreach ($this->answers as $ansid => $ans) {
354             if (isset($selectedchoices[$ansid])) {
355                 $choices[$ansid] = new question_classified_response($ansid,
356                         $this->html_to_text($ans->answer, $ans->answerformat), $ans->fraction);
357             }
358         }
359         return $choices;
360     }
362     public function get_correct_response() {
363         $response = array();
364         foreach ($this->order as $key => $ans) {
365             if (!question_state::graded_state_for_fraction(
366                     $this->answers[$ans]->fraction)->is_incorrect()) {
367                 $response[$this->field($key)] = 1;
368             }
369         }
370         return $response;
371     }
373     public function prepare_simulated_post_data($simulatedresponse) {
374         $postdata = array();
375         foreach ($simulatedresponse as $ans => $checked) {
376             foreach ($this->answers as $ansid => $answer) {
377                 if (clean_param($answer->answer, PARAM_NOTAGS) == $ans) {
378                     $fieldno = array_search($ansid, $this->order);
379                     $postdata[$this->field($fieldno)] = $checked;
380                     break;
381                 }
382             }
383         }
384         return $postdata;
385     }
387     public function get_student_response_values_for_simulation($postdata) {
388         $simulatedresponse = array();
389         foreach ($this->order as $fieldno => $ansid) {
390             if (isset($postdata[$this->field($fieldno)])) {
391                 $checked = $postdata[$this->field($fieldno)];
392                 $simulatedresponse[clean_param($this->answers[$ansid]->answer, PARAM_NOTAGS)] = $checked;
393             }
394         }
395         ksort($simulatedresponse);
396         return $simulatedresponse;
397     }
399     public function is_same_response(array $prevresponse, array $newresponse) {
400         foreach ($this->order as $key => $notused) {
401             $fieldname = $this->field($key);
402             if (!question_utils::arrays_same_at_key_integer($prevresponse, $newresponse, $fieldname)) {
403                 return false;
404             }
405         }
406         return true;
407     }
409     public function is_complete_response(array $response) {
410         foreach ($this->order as $key => $notused) {
411             if (!empty($response[$this->field($key)])) {
412                 return true;
413             }
414         }
415         return false;
416     }
418     public function is_gradable_response(array $response) {
419         return $this->is_complete_response($response);
420     }
422     /**
423      * @param array $response responses, as returned by
424      *      {@link question_attempt_step::get_qt_data()}.
425      * @return int the number of choices that were selected. in this response.
426      */
427     public function get_num_selected_choices(array $response) {
428         $numselected = 0;
429         foreach ($response as $key => $value) {
430             // Response keys starting with _ are internal values like _order, so ignore them.
431             if (!empty($value) && $key[0] != '_') {
432                 $numselected += 1;
433             }
434         }
435         return $numselected;
436     }
438     /**
439      * @return int the number of choices that are correct.
440      */
441     public function get_num_correct_choices() {
442         $numcorrect = 0;
443         foreach ($this->answers as $ans) {
444             if (!question_state::graded_state_for_fraction($ans->fraction)->is_incorrect()) {
445                 $numcorrect += 1;
446             }
447         }
448         return $numcorrect;
449     }
451     public function grade_response(array $response) {
452         $fraction = 0;
453         foreach ($this->order as $key => $ansid) {
454             if (!empty($response[$this->field($key)])) {
455                 $fraction += $this->answers[$ansid]->fraction;
456             }
457         }
458         $fraction = min(max(0, $fraction), 1.0);
459         return array($fraction, question_state::graded_state_for_fraction($fraction));
460     }
462     public function get_validation_error(array $response) {
463         if ($this->is_gradable_response($response)) {
464             return '';
465         }
466         return get_string('pleaseselectatleastoneanswer', 'qtype_multichoice');
467     }
469     /**
470      * Disable those hint settings that we don't want when the student has selected
471      * more choices than the number of right choices. This avoids giving the game away.
472      * @param question_hint_with_parts $hint a hint.
473      */
474     protected function disable_hint_settings_when_too_many_selected(
475             question_hint_with_parts $hint) {
476         $hint->clearwrong = false;
477     }
479     public function get_hint($hintnumber, question_attempt $qa) {
480         $hint = parent::get_hint($hintnumber, $qa);
481         if (is_null($hint)) {
482             return $hint;
483         }
485         if ($this->get_num_selected_choices($qa->get_last_qt_data()) >
486                 $this->get_num_correct_choices()) {
487             $hint = clone($hint);
488             $this->disable_hint_settings_when_too_many_selected($hint);
489         }
490         return $hint;
491     }
493     public function get_response(question_attempt $qa) {
494         return $qa->get_last_qt_data();
495     }
497     public function is_choice_selected($response, $value) {
498         return !empty($response['choice' . $value]);
499     }