Merge branch 'MDL-70119-310' of git://github.com/ferranrecio/moodle into MOODLE_310_S...
[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         $subquestions = array();
49         foreach ($question->textfragments as $i => $fragment) {
50             if ($i > 0) {
51                 $index = $question->places[$i];
52                 $token = 'qtypemultianswer' . $i . 'marker';
53                 $token = '<span class="nolink">' . $token . '</span>';
54                 $output .= $token;
55                 $subquestions[$token] = $this->subquestion($qa, $options, $index,
56                         $question->subquestions[$index]);
57             }
58             $output .= $fragment;
59         }
60         $output = $question->format_text($output, $question->questiontextformat,
61                 $qa, 'question', 'questiontext', $question->id);
62         $output = str_replace(array_keys($subquestions), array_values($subquestions), $output);
64         if ($qa->get_state() == question_state::$invalid) {
65             $output .= html_writer::nonempty_tag('div',
66                     $question->get_validation_error($qa->get_last_qt_data()),
67                     array('class' => 'validationerror'));
68         }
70         $this->page->requires->js_init_call('M.qtype_multianswer.init',
71                 array('#' . $qa->get_outer_question_div_unique_id()), false, array(
72                     'name'     => 'qtype_multianswer',
73                     'fullpath' => '/question/type/multianswer/module.js',
74                     'requires' => array('base', 'node', 'event', 'overlay'),
75                 ));
77         return $output;
78     }
80     public function subquestion(question_attempt $qa,
81             question_display_options $options, $index, question_graded_automatically $subq) {
83         $subtype = $subq->qtype->name();
84         if ($subtype == 'numerical' || $subtype == 'shortanswer') {
85             $subrenderer = 'textfield';
86         } else if ($subtype == 'multichoice') {
87             if ($subq instanceof qtype_multichoice_multi_question) {
88                 if ($subq->layout == qtype_multichoice_base::LAYOUT_VERTICAL) {
89                     $subrenderer = 'multiresponse_vertical';
90                 } else {
91                     $subrenderer = 'multiresponse_horizontal';
92                 }
93             } else {
94                 if ($subq->layout == qtype_multichoice_base::LAYOUT_DROPDOWN) {
95                     $subrenderer = 'multichoice_inline';
96                 } else if ($subq->layout == qtype_multichoice_base::LAYOUT_HORIZONTAL) {
97                     $subrenderer = 'multichoice_horizontal';
98                 } else {
99                     $subrenderer = 'multichoice_vertical';
100                 }
101             }
102         } else {
103             throw new coding_exception('Unexpected subquestion type.', $subq);
104         }
105         $renderer = $this->page->get_renderer('qtype_multianswer', $subrenderer);
106         return $renderer->subquestion($qa, $options, $index, $subq);
107     }
109     public function correct_response(question_attempt $qa) {
110         return '';
111     }
115 /**
116  * Subclass for generating the bits of output specific to shortanswer
117  * subquestions.
118  *
119  * @copyright 2011 The Open University
120  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
121  */
122 abstract class qtype_multianswer_subq_renderer_base extends qtype_renderer {
124     abstract public function subquestion(question_attempt $qa,
125             question_display_options $options, $index,
126             question_graded_automatically $subq);
128     /**
129      * Render the feedback pop-up contents.
130      *
131      * @param question_graded_automatically $subq the subquestion.
132      * @param float $fraction the mark the student got. null if this subq was not answered.
133      * @param string $feedbacktext the feedback text, already processed with format_text etc.
134      * @param string $rightanswer the right answer, already processed with format_text etc.
135      * @param question_display_options $options the display options.
136      * @return string the HTML for the feedback popup.
137      */
138     protected function feedback_popup(question_graded_automatically $subq,
139             $fraction, $feedbacktext, $rightanswer, question_display_options $options) {
141         $feedback = array();
142         if ($options->correctness) {
143             if (is_null($fraction)) {
144                 $state = question_state::$gaveup;
145             } else {
146                 $state = question_state::graded_state_for_fraction($fraction);
147             }
148             $feedback[] = $state->default_string(true);
149         }
151         if ($options->feedback && $feedbacktext) {
152             $feedback[] = $feedbacktext;
153         }
155         if ($options->rightanswer) {
156             $feedback[] = get_string('correctansweris', 'qtype_shortanswer', $rightanswer);
157         }
159         $subfraction = '';
160         if ($options->marks >= question_display_options::MARK_AND_MAX && $subq->maxmark > 0
161                 && (!is_null($fraction) || $feedback)) {
162             $a = new stdClass();
163             $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
164             $a->max = format_float($subq->maxmark, $options->markdp);
165             $feedback[] = get_string('markoutofmax', 'question', $a);
166         }
168         if (!$feedback) {
169             return '';
170         }
172         return html_writer::tag('span', implode('<br />', $feedback),
173                 array('class' => 'feedbackspan accesshide'));
174     }
178 /**
179  * Subclass for generating the bits of output specific to shortanswer
180  * subquestions.
181  *
182  * @copyright 2011 The Open University
183  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
184  */
185 class qtype_multianswer_textfield_renderer extends qtype_multianswer_subq_renderer_base {
187     public function subquestion(question_attempt $qa, question_display_options $options,
188             $index, question_graded_automatically $subq) {
190         $fieldprefix = 'sub' . $index . '_';
191         $fieldname = $fieldprefix . 'answer';
193         $response = $qa->get_last_qt_var($fieldname);
194         if ($subq->qtype->name() == 'shortanswer') {
195             $matchinganswer = $subq->get_matching_answer(array('answer' => $response));
196         } else if ($subq->qtype->name() == 'numerical') {
197             list($value, $unit, $multiplier) = $subq->ap->apply_units($response, '');
198             $matchinganswer = $subq->get_matching_answer($value, 1);
199         } else {
200             $matchinganswer = $subq->get_matching_answer($response);
201         }
203         if (!$matchinganswer) {
204             if (is_null($response) || $response === '') {
205                 $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
206             } else {
207                 $matchinganswer = new question_answer(0, '', 0.0, '', FORMAT_HTML);
208             }
209         }
211         // Work out a good input field size.
212         $size = max(1, core_text::strlen(trim($response)) + 1);
213         foreach ($subq->answers as $ans) {
214             $size = max($size, core_text::strlen(trim($ans->answer)));
215         }
216         $size = min(60, round($size + rand(0, $size * 0.15)));
217         // The rand bit is to make guessing harder.
219         $inputattributes = array(
220             'type' => 'text',
221             'name' => $qa->get_qt_field_name($fieldname),
222             'value' => $response,
223             'id' => $qa->get_qt_field_name($fieldname),
224             'size' => $size,
225             'class' => 'form-control mb-1',
226         );
227         if ($options->readonly) {
228             $inputattributes['readonly'] = 'readonly';
229         }
231         $feedbackimg = '';
232         if ($options->correctness) {
233             $inputattributes['class'] .= ' ' . $this->feedback_class($matchinganswer->fraction);
234             $feedbackimg = $this->feedback_image($matchinganswer->fraction);
235         }
237         if ($subq->qtype->name() == 'shortanswer') {
238             $correctanswer = $subq->get_matching_answer($subq->get_correct_response());
239         } else {
240             $correctanswer = $subq->get_correct_answer();
241         }
243         $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction,
244                 $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat,
245                         $qa, 'question', 'answerfeedback', $matchinganswer->id),
246                 s($correctanswer->answer), $options);
248         $output = html_writer::start_tag('span', array('class' => 'subquestion form-inline d-inline'));
249         $output .= html_writer::tag('label', get_string('answer'),
250                 array('class' => 'subq accesshide', 'for' => $inputattributes['id']));
251         $output .= html_writer::empty_tag('input', $inputattributes);
252         $output .= $feedbackimg;
253         $output .= $feedbackpopup;
254         $output .= html_writer::end_tag('span');
256         return $output;
257     }
261 /**
262  * Render an embedded multiple-choice question that is displayed as a select menu.
263  *
264  * @copyright  2011 The Open University
265  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
266  */
267 class qtype_multianswer_multichoice_inline_renderer
268         extends qtype_multianswer_subq_renderer_base {
270     public function subquestion(question_attempt $qa, question_display_options $options,
271             $index, question_graded_automatically $subq) {
273         $fieldprefix = 'sub' . $index . '_';
274         $fieldname = $fieldprefix . 'answer';
276         $response = $qa->get_last_qt_var($fieldname);
277         $choices = array();
278         $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
279         $rightanswer = null;
280         foreach ($subq->get_order($qa) as $value => $ansid) {
281             $ans = $subq->answers[$ansid];
282             $choices[$value] = $subq->format_text($ans->answer, $ans->answerformat,
283                     $qa, 'question', 'answer', $ansid);
284             if ($subq->is_choice_selected($response, $value)) {
285                 $matchinganswer = $ans;
286             }
287         }
289         $inputattributes = array(
290             'id' => $qa->get_qt_field_name($fieldname),
291         );
292         if ($options->readonly) {
293             $inputattributes['disabled'] = 'disabled';
294         }
296         $feedbackimg = '';
297         if ($options->correctness) {
298             $inputattributes['class'] = $this->feedback_class($matchinganswer->fraction);
299             $feedbackimg = $this->feedback_image($matchinganswer->fraction);
300         }
301         $select = html_writer::select($choices, $qa->get_qt_field_name($fieldname),
302                 $response, array('' => ''), $inputattributes);
304         $order = $subq->get_order($qa);
305         $correctresponses = $subq->get_correct_response();
306         $rightanswer = $subq->answers[$order[reset($correctresponses)]];
307         if (!$matchinganswer) {
308             $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML);
309         }
310         $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction,
311                 $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat,
312                         $qa, 'question', 'answerfeedback', $matchinganswer->id),
313                 $subq->format_text($rightanswer->answer, $rightanswer->answerformat,
314                         $qa, 'question', 'answer', $rightanswer->id), $options);
316         $output = html_writer::start_tag('span', array('class' => 'subquestion'));
317         $output .= html_writer::tag('label', get_string('answer'),
318                 array('class' => 'subq accesshide', 'for' => $inputattributes['id']));
319         $output .= $select;
320         $output .= $feedbackimg;
321         $output .= $feedbackpopup;
322         $output .= html_writer::end_tag('span');
324         return $output;
325     }
329 /**
330  * Render an embedded multiple-choice question vertically, like for a normal
331  * multiple-choice question.
332  *
333  * @copyright  2010 Pierre Pichet
334  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
335  */
336 class qtype_multianswer_multichoice_vertical_renderer extends qtype_multianswer_subq_renderer_base {
338     public function subquestion(question_attempt $qa, question_display_options $options,
339             $index, question_graded_automatically $subq) {
341         $fieldprefix = 'sub' . $index . '_';
342         $fieldname = $fieldprefix . 'answer';
343         $response = $qa->get_last_qt_var($fieldname);
345         $inputattributes = array(
346             'type' => 'radio',
347             'name' => $qa->get_qt_field_name($fieldname),
348         );
349         if ($options->readonly) {
350             $inputattributes['disabled'] = 'disabled';
351         }
353         $result = $this->all_choices_wrapper_start();
354         $fraction = null;
355         foreach ($subq->get_order($qa) as $value => $ansid) {
356             $ans = $subq->answers[$ansid];
358             $inputattributes['value'] = $value;
359             $inputattributes['id'] = $inputattributes['name'] . $value;
361             $isselected = $subq->is_choice_selected($response, $value);
362             if ($isselected) {
363                 $inputattributes['checked'] = 'checked';
364                 $fraction = $ans->fraction;
365             } else {
366                 unset($inputattributes['checked']);
367             }
369             $class = 'r' . ($value % 2);
370             if ($options->correctness && $isselected) {
371                 $feedbackimg = $this->feedback_image($ans->fraction);
372                 $class .= ' ' . $this->feedback_class($ans->fraction);
373             } else {
374                 $feedbackimg = '';
375             }
377             $result .= $this->choice_wrapper_start($class);
378             $result .= html_writer::empty_tag('input', $inputattributes);
379             $result .= html_writer::tag('label', $subq->format_text($ans->answer,
380                     $ans->answerformat, $qa, 'question', 'answer', $ansid),
381                     array('for' => $inputattributes['id']));
382             $result .= $feedbackimg;
384             if ($options->feedback && $isselected && trim($ans->feedback)) {
385                 $result .= html_writer::tag('div',
386                         $subq->format_text($ans->feedback, $ans->feedbackformat,
387                                 $qa, 'question', 'answerfeedback', $ansid),
388                         array('class' => 'specificfeedback'));
389             }
391             $result .= $this->choice_wrapper_end();
392         }
394         $result .= $this->all_choices_wrapper_end();
396         $feedback = array();
397         if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX &&
398                 $subq->maxmark > 0) {
399             $a = new stdClass();
400             $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
401             $a->max = format_float($subq->maxmark, $options->markdp);
403             $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a));
404         }
406         if ($options->rightanswer) {
407             foreach ($subq->answers as $ans) {
408                 if (question_state::graded_state_for_fraction($ans->fraction) ==
409                         question_state::$gradedright) {
410                     $feedback[] = get_string('correctansweris', 'qtype_multichoice',
411                             $subq->format_text($ans->answer, $ans->answerformat,
412                                     $qa, 'question', 'answer', $ansid));
413                     break;
414                 }
415             }
416         }
418         $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome'));
420         return $result;
421     }
423     /**
424      * @param string $class class attribute value.
425      * @return string HTML to go before each choice.
426      */
427     protected function choice_wrapper_start($class) {
428         return html_writer::start_tag('div', array('class' => $class));
429     }
431     /**
432      * @return string HTML to go after each choice.
433      */
434     protected function choice_wrapper_end() {
435         return html_writer::end_tag('div');
436     }
438     /**
439      * @return string HTML to go before all the choices.
440      */
441     protected function all_choices_wrapper_start() {
442         return html_writer::start_tag('div', array('class' => 'answer'));
443     }
445     /**
446      * @return string HTML to go after all the choices.
447      */
448     protected function all_choices_wrapper_end() {
449         return html_writer::end_tag('div');
450     }
454 /**
455  * Render an embedded multiple-choice question vertically, like for a normal
456  * multiple-choice question.
457  *
458  * @copyright  2010 Pierre Pichet
459  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
460  */
461 class qtype_multianswer_multichoice_horizontal_renderer
462         extends qtype_multianswer_multichoice_vertical_renderer {
464     protected function choice_wrapper_start($class) {
465         return html_writer::start_tag('td', array('class' => $class));
466     }
468     protected function choice_wrapper_end() {
469         return html_writer::end_tag('td');
470     }
472     protected function all_choices_wrapper_start() {
473         return html_writer::start_tag('table', array('class' => 'answer')) .
474                 html_writer::start_tag('tbody') . html_writer::start_tag('tr');
475     }
477     protected function all_choices_wrapper_end() {
478         return html_writer::end_tag('tr') . html_writer::end_tag('tbody') .
479                 html_writer::end_tag('table');
480     }
483 /**
484  * Class qtype_multianswer_multiresponse_renderer
485  *
486  * @copyright  2016 Davo Smith, Synergy Learning
487  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
488  */
489 class qtype_multianswer_multiresponse_vertical_renderer extends qtype_multianswer_subq_renderer_base {
491     /**
492      * Output the content of the subquestion.
493      *
494      * @param question_attempt $qa
495      * @param question_display_options $options
496      * @param int $index
497      * @param question_graded_automatically $subq
498      * @return string
499      */
500     public function subquestion(question_attempt $qa, question_display_options $options,
501                                 $index, question_graded_automatically $subq) {
503         if (!$subq instanceof qtype_multichoice_multi_question) {
504             throw new coding_exception('Expecting subquestion of type qtype_multichoice_multi_question');
505         }
507         $fieldprefix = 'sub' . $index . '_';
508         $fieldname = $fieldprefix . 'choice';
510         // Extract the responses that related to this question + strip off the prefix.
511         $fieldprefixlen = strlen($fieldprefix);
512         $response = [];
513         foreach ($qa->get_last_qt_data() as $name => $val) {
514             if (substr($name, 0, $fieldprefixlen) == $fieldprefix) {
515                 $name = substr($name, $fieldprefixlen);
516                 $response[$name] = $val;
517             }
518         }
520         $basename = $qa->get_qt_field_name($fieldname);
521         $inputattributes = array(
522             'type' => 'checkbox',
523             'value' => 1,
524         );
525         if ($options->readonly) {
526             $inputattributes['disabled'] = 'disabled';
527         }
529         $result = $this->all_choices_wrapper_start();
531         // Calculate the total score (as we need to know if choices should be marked as 'correct' or 'partial').
532         $fraction = 0;
533         foreach ($subq->get_order($qa) as $value => $ansid) {
534             $ans = $subq->answers[$ansid];
535             if ($subq->is_choice_selected($response, $value)) {
536                 $fraction += $ans->fraction;
537             }
538         }
539         // Display 'correct' answers as correct, if we are at 100%, otherwise mark them as 'partial'.
540         $answerfraction = ($fraction > 0.999) ? 1.0 : 0.5;
542         foreach ($subq->get_order($qa) as $value => $ansid) {
543             $ans = $subq->answers[$ansid];
545             $name = $basename.$value;
546             $inputattributes['name'] = $name;
547             $inputattributes['id'] = $name;
549             $isselected = $subq->is_choice_selected($response, $value);
550             if ($isselected) {
551                 $inputattributes['checked'] = 'checked';
552             } else {
553                 unset($inputattributes['checked']);
554             }
556             $class = 'r' . ($value % 2);
557             if ($options->correctness && $isselected) {
558                 $thisfrac = ($ans->fraction > 0) ? $answerfraction : 0;
559                 $feedbackimg = $this->feedback_image($thisfrac);
560                 $class .= ' ' . $this->feedback_class($thisfrac);
561             } else {
562                 $feedbackimg = '';
563             }
565             $result .= $this->choice_wrapper_start($class);
566             $result .= html_writer::empty_tag('input', $inputattributes);
567             $result .= html_writer::tag('label', $subq->format_text($ans->answer,
568                                                                     $ans->answerformat, $qa, 'question', 'answer', $ansid),
569                                         array('for' => $inputattributes['id']));
570             $result .= $feedbackimg;
572             if ($options->feedback && $isselected && trim($ans->feedback)) {
573                 $result .= html_writer::tag('div',
574                                             $subq->format_text($ans->feedback, $ans->feedbackformat,
575                                                                $qa, 'question', 'answerfeedback', $ansid),
576                                             array('class' => 'specificfeedback'));
577             }
579             $result .= $this->choice_wrapper_end();
580         }
582         $result .= $this->all_choices_wrapper_end();
584         $feedback = array();
585         if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX &&
586             $subq->maxmark > 0) {
587             $a = new stdClass();
588             $a->mark = format_float($fraction * $subq->maxmark, $options->markdp);
589             $a->max = format_float($subq->maxmark, $options->markdp);
591             $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a));
592         }
594         if ($options->rightanswer) {
595             $correct = [];
596             foreach ($subq->answers as $ans) {
597                 if (question_state::graded_state_for_fraction($ans->fraction) != question_state::$gradedwrong) {
598                     $correct[] = $subq->format_text($ans->answer, $ans->answerformat, $qa, 'question', 'answer', $ans->id);
599                 }
600             }
601             $correct = '<ul><li>'.implode('</li><li>', $correct).'</li></ul>';
602             $feedback[] = get_string('correctansweris', 'qtype_multichoice', $correct);
603         }
605         $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome'));
607         return $result;
608     }
610     /**
611      * @param string $class class attribute value.
612      * @return string HTML to go before each choice.
613      */
614     protected function choice_wrapper_start($class) {
615         return html_writer::start_tag('div', array('class' => $class));
616     }
618     /**
619      * @return string HTML to go after each choice.
620      */
621     protected function choice_wrapper_end() {
622         return html_writer::end_tag('div');
623     }
625     /**
626      * @return string HTML to go before all the choices.
627      */
628     protected function all_choices_wrapper_start() {
629         return html_writer::start_tag('div', array('class' => 'answer'));
630     }
632     /**
633      * @return string HTML to go after all the choices.
634      */
635     protected function all_choices_wrapper_end() {
636         return html_writer::end_tag('div');
637     }
640 /**
641  * Render an embedded multiple-response question horizontally.
642  *
643  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
644  */
645 class qtype_multianswer_multiresponse_horizontal_renderer
646     extends qtype_multianswer_multiresponse_vertical_renderer {
648     protected function choice_wrapper_start($class) {
649         return html_writer::start_tag('td', array('class' => $class));
650     }
652     protected function choice_wrapper_end() {
653         return html_writer::end_tag('td');
654     }
656     protected function all_choices_wrapper_start() {
657         return html_writer::start_tag('table', array('class' => 'answer')) .
658         html_writer::start_tag('tbody') . html_writer::start_tag('tr');
659     }
661     protected function all_choices_wrapper_end() {
662         return html_writer::end_tag('tr') . html_writer::end_tag('tbody') .
663         html_writer::end_tag('table');
664     }