MDL-49041 qtype_multianswer: don't reveal marks on partial responses
[moodle.git] / question / type / multianswer / renderer.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  * Multianswer question renderer classes.
19  * Handle shortanswer, numerical and various multichoice subquestions
20  *
21  * @package    qtype
22  * @subpackage multianswer
23  * @copyright  2010 Pierre Pichet
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
28 require_once($CFG->dirroot . '/question/type/shortanswer/renderer.php');
31 /**
32  * Base class for generating the bits of output common to multianswer
33  * (Cloze) questions.
34  * This render the main question text and transfer to the subquestions
35  * the task of display their input elements and status
36  * feedback, grade, correct answer(s)
37  *
38  * @copyright 2010 Pierre Pichet
39  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40  */
41 class qtype_multianswer_renderer extends qtype_renderer {
43     public function formulation_and_controls(question_attempt $qa,
44             question_display_options $options) {
45         $question = $qa->get_question();
47         $output = '';
48         foreach ($question->textfragments as $i => $fragment) {
49             if ($i > 0) {
50                 $index = $question->places[$i];
51                 $output .= $this->subquestion($qa, $options, $index,
52                         $question->subquestions[$index]);
53             }
54             $output .= $question->format_text($fragment, $question->questiontextformat,
55                     $qa, 'question', 'questiontext', $question->id);
56         }
58         if ($qa->get_state() == question_state::$invalid) {
59             $output .= html_writer::nonempty_tag('div',
60                     $question->get_validation_error($qa->get_last_qt_data()),
61                     array('class' => 'validationerror'));
62         }
64         $this->page->requires->js_init_call('M.qtype_multianswer.init',
65                 array('#q' . $qa->get_slot()), false, array(
66                     'name'     => 'qtype_multianswer',
67                     'fullpath' => '/question/type/multianswer/module.js',
68                     'requires' => array('base', 'node', 'event', 'overlay'),
69                 ));
71         return $output;
72     }
74     public function subquestion(question_attempt $qa,
75             question_display_options $options, $index, question_graded_automatically $subq) {
77         $subtype = $subq->qtype->name();
78         if ($subtype == 'numerical' || $subtype == 'shortanswer') {
79             $subrenderer = 'textfield';
80         } else if ($subtype == 'multichoice') {
81             if ($subq->layout == qtype_multichoice_base::LAYOUT_DROPDOWN) {
82                 $subrenderer = 'multichoice_inline';
83             } else if ($subq->layout == qtype_multichoice_base::LAYOUT_HORIZONTAL) {
84                 $subrenderer = 'multichoice_horizontal';
85             } else {
86                 $subrenderer = 'multichoice_vertical';
87             }
88         } else {
89             throw new coding_exception('Unexpected subquestion type.', $subq);
90         }
91         $renderer = $this->page->get_renderer('qtype_multianswer', $subrenderer);
92         return $renderer->subquestion($qa, $options, $index, $subq);
93     }
95     public function correct_response(question_attempt $qa) {
96         return '';
97     }
98 }
101 /**
102  * Subclass for generating the bits of output specific to shortanswer
103  * subquestions.
104  *
105  * @copyright 2011 The Open University
106  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
107  */
108 abstract class qtype_multianswer_subq_renderer_base extends qtype_renderer {
110     abstract public function subquestion(question_attempt $qa,
111             question_display_options $options, $index,
112             question_graded_automatically $subq);
114     /**
115      * Render the feedback pop-up contents.
116      *
117      * @param question_graded_automatically $subq the subquestion.
118      * @param float $fraction the mark the student got. null if this subq was not answered.
119      * @param string $feedbacktext the feedback text, already processed with format_text etc.
120      * @param string $rightanswer the right answer, already processed with format_text etc.
121      * @param question_display_options $options the display options.
122      * @return string the HTML for the feedback popup.
123      */
124     protected function feedback_popup(question_graded_automatically $subq,
125             $fraction, $feedbacktext, $rightanswer, question_display_options $options) {
127         $feedback = array();
128         if ($options->correctness) {
129             if (is_null($fraction)) {
130                 $state = question_state::$gaveup;
131             } else {
132                 $state = question_state::graded_state_for_fraction($fraction);
133             }
134             $feedback[] = $state->default_string(true);
135         }
137         if ($options->feedback && $feedbacktext) {
138             $feedback[] = $feedbacktext;
139         }
141         if ($options->rightanswer) {
142             $feedback[] = get_string('correctansweris', 'qtype_shortanswer', $rightanswer);
143         }
145         $subfraction = '';
146         if ($options->marks >= question_display_options::MARK_AND_MAX && $subq->maxmark > 0
147                 && (!is_null($fraction) || $feedback)) {
148             $a = new stdClass();
149             $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
150             $a->max =  format_float($subq->maxmark, $options->markdp);
151             $feedback[] = get_string('markoutofmax', 'question', $a);
152         }
154         if (!$feedback) {
155             return '';
156         }
158         return html_writer::tag('span', implode('<br />', $feedback),
159                 array('class' => 'feedbackspan accesshide'));
160     }
164 /**
165  * Subclass for generating the bits of output specific to shortanswer
166  * subquestions.
167  *
168  * @copyright 2011 The Open University
169  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
170  */
171 class qtype_multianswer_textfield_renderer extends qtype_multianswer_subq_renderer_base {
173     public function subquestion(question_attempt $qa, question_display_options $options,
174             $index, question_graded_automatically $subq) {
176         $fieldprefix = 'sub' . $index . '_';
177         $fieldname = $fieldprefix . 'answer';
179         $response = $qa->get_last_qt_var($fieldname);
180         if ($subq->qtype->name() == 'shortanswer') {
181             $matchinganswer = $subq->get_matching_answer(array('answer' => $response));
182         } else if ($subq->qtype->name() == 'numerical') {
183             list($value, $unit, $multiplier) = $subq->ap->apply_units($response, '');
184             $matchinganswer = $subq->get_matching_answer($value, 1);
185         } else {
186             $matchinganswer = $subq->get_matching_answer($response);
187         }
189         if (!$matchinganswer) {
190             if (is_null($response) || $response === '') {
191                 $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
192             } else {
193                 $matchinganswer = new question_answer(0, '', 0.0, '', FORMAT_HTML);
194             }
195         }
197         // Work out a good input field size.
198         $size = max(1, core_text::strlen(trim($response)) + 1);
199         foreach ($subq->answers as $ans) {
200             $size = max($size, core_text::strlen(trim($ans->answer)));
201         }
202         $size = min(60, round($size + rand(0, $size*0.15)));
203         // The rand bit is to make guessing harder.
205         $inputattributes = array(
206             'type' => 'text',
207             'name' => $qa->get_qt_field_name($fieldname),
208             'value' => $response,
209             'id' => $qa->get_qt_field_name($fieldname),
210             'size' => $size,
211         );
212         if ($options->readonly) {
213             $inputattributes['readonly'] = 'readonly';
214         }
216         $feedbackimg = '';
217         if ($options->correctness) {
218             $inputattributes['class'] = $this->feedback_class($matchinganswer->fraction);
219             $feedbackimg = $this->feedback_image($matchinganswer->fraction);
220         }
222         if ($subq->qtype->name() == 'shortanswer') {
223             $correctanswer = $subq->get_matching_answer($subq->get_correct_response());
224         } else {
225             $correctanswer = $subq->get_correct_answer();
226         }
228         $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction,
229                 $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat,
230                         $qa, 'question', 'answerfeedback', $matchinganswer->id),
231                 s($correctanswer->answer), $options);
233         $output = html_writer::start_tag('span', array('class' => 'subquestion'));
234         $output .= html_writer::tag('label', get_string('answer'),
235                 array('class' => 'subq accesshide', 'for' => $inputattributes['id']));
236         $output .= html_writer::empty_tag('input', $inputattributes);
237         $output .= $feedbackimg;
238         $output .= $feedbackpopup;
239         $output .= html_writer::end_tag('span');
241         return $output;
242     }
246 /**
247  * Render an embedded multiple-choice question that is displayed as a select menu.
248  *
249  * @copyright  2011 The Open University
250  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
251  */
252 class qtype_multianswer_multichoice_inline_renderer
253         extends qtype_multianswer_subq_renderer_base {
255     public function subquestion(question_attempt $qa, question_display_options $options,
256             $index, question_graded_automatically $subq) {
258         $fieldprefix = 'sub' . $index . '_';
259         $fieldname = $fieldprefix . 'answer';
261         $response = $qa->get_last_qt_var($fieldname);
262         $choices = array();
263         $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
264         $rightanswer = null;
265         foreach ($subq->get_order($qa) as $value => $ansid) {
266             $ans = $subq->answers[$ansid];
267             $choices[$value] = $subq->format_text($ans->answer, $ans->answerformat,
268                     $qa, 'question', 'answer', $ansid);
269             if ($subq->is_choice_selected($response, $value)) {
270                 $matchinganswer = $ans;
271             }
272         }
274         $inputattributes = array(
275             'id' => $qa->get_qt_field_name($fieldname),
276         );
277         if ($options->readonly) {
278             $inputattributes['disabled'] = 'disabled';
279         }
281         $feedbackimg = '';
282         if ($options->correctness) {
283             $inputattributes['class'] = $this->feedback_class($matchinganswer->fraction);
284             $feedbackimg = $this->feedback_image($matchinganswer->fraction);
285         }
286         $select = html_writer::select($choices, $qa->get_qt_field_name($fieldname),
287                 $response, array('' => ''), $inputattributes);
289         $order = $subq->get_order($qa);
290         $correctresponses = $subq->get_correct_response();
291         $rightanswer = $subq->answers[$order[reset($correctresponses)]];
292         if (!$matchinganswer) {
293             $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
294         }
295         $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction,
296                 $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat,
297                         $qa, 'question', 'answerfeedback', $matchinganswer->id),
298                 $subq->format_text($rightanswer->answer, $rightanswer->answerformat,
299                         $qa, 'question', 'answer', $rightanswer->id), $options);
301         $output = html_writer::start_tag('span', array('class' => 'subquestion'));
302         $output .= html_writer::tag('label', get_string('answer'),
303                 array('class' => 'subq accesshide', 'for' => $inputattributes['id']));
304         $output .= $select;
305         $output .= $feedbackimg;
306         $output .= $feedbackpopup;
307         $output .= html_writer::end_tag('span');
309         return $output;
310     }
314 /**
315  * Render an embedded multiple-choice question vertically, like for a normal
316  * multiple-choice question.
317  *
318  * @copyright  2010 Pierre Pichet
319  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
320  */
321 class qtype_multianswer_multichoice_vertical_renderer extends qtype_multianswer_subq_renderer_base {
323     public function subquestion(question_attempt $qa, question_display_options $options,
324             $index, question_graded_automatically $subq) {
326         $fieldprefix = 'sub' . $index . '_';
327         $fieldname = $fieldprefix . 'answer';
328         $response = $qa->get_last_qt_var($fieldname);
330         $inputattributes = array(
331             'type' => 'radio',
332             'name' => $qa->get_qt_field_name($fieldname),
333         );
334         if ($options->readonly) {
335             $inputattributes['disabled'] = 'disabled';
336         }
338         $result = $this->all_choices_wrapper_start();
339         $fraction = null;
340         foreach ($subq->get_order($qa) as $value => $ansid) {
341             $ans = $subq->answers[$ansid];
343             $inputattributes['value'] = $value;
344             $inputattributes['id'] = $inputattributes['name'] . $value;
346             $isselected = $subq->is_choice_selected($response, $value);
347             if ($isselected) {
348                 $inputattributes['checked'] = 'checked';
349                 $fraction = $ans->fraction;
350             } else {
351                 unset($inputattributes['checked']);
352             }
354             $class = 'r' . ($value % 2);
355             if ($options->correctness && $isselected) {
356                 $feedbackimg = $this->feedback_image($ans->fraction);
357                 $class .= ' ' . $this->feedback_class($ans->fraction);
358             } else {
359                 $feedbackimg = '';
360             }
362             $result .= $this->choice_wrapper_start($class);
363             $result .= html_writer::empty_tag('input', $inputattributes);
364             $result .= html_writer::tag('label', $subq->format_text($ans->answer,
365                     $ans->answerformat, $qa, 'question', 'answer', $ansid),
366                     array('for' => $inputattributes['id']));
367             $result .= $feedbackimg;
369             if ($options->feedback && $isselected && trim($ans->feedback)) {
370                 $result .= html_writer::tag('div',
371                         $subq->format_text($ans->feedback, $ans->feedbackformat,
372                                 $qa, 'question', 'answerfeedback', $ansid),
373                         array('class' => 'specificfeedback'));
374             }
376             $result .= $this->choice_wrapper_end();
377         }
379         $result .= $this->all_choices_wrapper_end();
381         $feedback = array();
382         if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX &&
383                 $subq->maxmark > 0) {
384             $a = new stdClass();
385             $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
386             $a->max =  format_float($subq->maxmark, $options->markdp);
388             $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a));
389         }
391         if ($options->rightanswer) {
392             foreach ($subq->answers as $ans) {
393                 if (question_state::graded_state_for_fraction($ans->fraction) ==
394                         question_state::$gradedright) {
395                     $feedback[] = get_string('correctansweris', 'qtype_multichoice',
396                             $subq->format_text($ans->answer, $ans->answerformat,
397                                     $qa, 'question', 'answer', $ansid));
398                     break;
399                 }
400             }
401         }
403         $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome'));
405         return $result;
406     }
408     /**
409      * @param string $class class attribute value.
410      * @return string HTML to go before each choice.
411      */
412     protected function choice_wrapper_start($class) {
413         return html_writer::start_tag('div', array('class' => $class));
414     }
416     /**
417      * @return string HTML to go after each choice.
418      */
419     protected function choice_wrapper_end() {
420         return html_writer::end_tag('div');
421     }
423     /**
424      * @return string HTML to go before all the choices.
425      */
426     protected function all_choices_wrapper_start() {
427         return html_writer::start_tag('div', array('class' => 'answer'));
428     }
430     /**
431      * @return string HTML to go after all the choices.
432      */
433     protected function all_choices_wrapper_end() {
434         return html_writer::end_tag('div');
435     }
439 /**
440  * Render an embedded multiple-choice question vertically, like for a normal
441  * multiple-choice question.
442  *
443  * @copyright  2010 Pierre Pichet
444  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
445  */
446 class qtype_multianswer_multichoice_horizontal_renderer
447         extends qtype_multianswer_multichoice_vertical_renderer {
449     protected function choice_wrapper_start($class) {
450         return html_writer::start_tag('td', array('class' => $class));
451     }
453     protected function choice_wrapper_end() {
454         return html_writer::end_tag('td');
455     }
457     protected function all_choices_wrapper_start() {
458         return html_writer::start_tag('table', array('class' => 'answer')) .
459                 html_writer::start_tag('tbody') . html_writer::start_tag('tr');
460     }
462     protected function all_choices_wrapper_end() {
463         return html_writer::end_tag('tr') . html_writer::end_tag('tbody') .
464                 html_writer::end_tag('table');
465     }