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 * Renderers for outputting parts of the question engine.
19 *
20 * @package    moodlecore
21 * @subpackage questionengine
22 * @copyright  2009 The Open University
23 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26
27defined('MOODLE_INTERNAL') || die();
28
29
30/**
31 * This renderer controls the overall output of questions. It works with a
32 * {@link qbehaviour_renderer} and a {@link qtype_renderer} to output the
33 * type-specific bits. The main entry point is the {@link question()} method.
34 *
35 * @copyright  2009 The Open University
36 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 */
38class core_question_renderer extends plugin_renderer_base {
39    public function get_page() {
40        return $this->page;
41    }
42
43    /**
44     * Render an icon, optionally with the word 'Preview' beside it, to preview
45     * a given question.
46     * @param int $questionid the id of the question to be previewed.
47     * @param context $context the context in which the preview is happening.
48     *      Must be a course or category context.
49     * @param bool $showlabel if true, show the word 'Preview' after the icon.
50     *      If false, just show the icon.
51     */
52    public function question_preview_link($questionid, context $context, $showlabel) {
53        if ($showlabel) {
54            $alt = '';
55            $label = get_string('preview');
56            $attributes = array();
57        } else {
58            $alt = get_string('preview');
59            $label = '';
60            $attributes = array('title' => $alt);
61        }
62
63        $image = $this->pix_icon('t/preview', $alt, '', array('class' => 'iconsmall'));
64        $link = question_preview_url($questionid, null, null, null, null, $context);
65        $action = new popup_action('click', $link, 'questionpreview',
66                question_preview_popup_params());
67
68        return $this->action_link($link, $image . $label, $action, $attributes);
69    }
70
71    /**
72     * Generate the display of a question in a particular state, and with certain
73     * display options. Normally you do not call this method directly. Intsead
74     * you call {@link question_usage_by_activity::render_question()} which will
75     * call this method with appropriate arguments.
76     *
77     * @param question_attempt $qa the question attempt to display.
78     * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
79     *      specific parts.
80     * @param qtype_renderer $qtoutput the renderer to output the question type
81     *      specific parts.
82     * @param question_display_options $options controls what should and should not be displayed.
83     * @param string|null $number The question number to display. 'i' is a special
84     *      value that gets displayed as Information. Null means no number is displayed.
85     * @return string HTML representation of the question.
86     */
87    public function question(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
88            qtype_renderer $qtoutput, question_display_options $options, $number) {
89
90        $output = '';
91        $output .= html_writer::start_tag('div', array(
92            'id' => $qa->get_outer_question_div_unique_id(),
93            'class' => implode(' ', array(
94                'que',
95                $qa->get_question(false)->get_type_name(),
96                $qa->get_behaviour_name(),
97                $qa->get_state_class($options->correctness && $qa->has_marks()),
98            ))
99        ));
100
101        $output .= html_writer::tag('div',
102                $this->info($qa, $behaviouroutput, $qtoutput, $options, $number),
103                array('class' => 'info'));
104
105        $output .= html_writer::start_tag('div', array('class' => 'content'));
106
107        $output .= html_writer::tag('div',
108                $this->add_part_heading($qtoutput->formulation_heading(),
109                    $this->formulation($qa, $behaviouroutput, $qtoutput, $options)),
110                array('class' => 'formulation clearfix'));
111        $output .= html_writer::nonempty_tag('div',
112                $this->add_part_heading(get_string('feedback', 'question'),
113                    $this->outcome($qa, $behaviouroutput, $qtoutput, $options)),
114                array('class' => 'outcome clearfix'));
115        $output .= html_writer::nonempty_tag('div',
116                $this->add_part_heading(get_string('comments', 'question'),
117                    $this->manual_comment($qa, $behaviouroutput, $qtoutput, $options)),
118                array('class' => 'comment clearfix'));
119        $output .= html_writer::nonempty_tag('div',
120                $this->response_history($qa, $behaviouroutput, $qtoutput, $options),
121                array('class' => 'history clearfix border p-2'));
122
123        $output .= html_writer::end_tag('div');
124        $output .= html_writer::end_tag('div');
125        return $output;
126    }
127
128    /**
129     * Generate the information bit of the question display that contains the
130     * metadata like the question number, current state, and mark.
131     * @param question_attempt $qa the question attempt to display.
132     * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
133     *      specific parts.
134     * @param qtype_renderer $qtoutput the renderer to output the question type
135     *      specific parts.
136     * @param question_display_options $options controls what should and should not be displayed.
137     * @param string|null $number The question number to display. 'i' is a special
138     *      value that gets displayed as Information. Null means no number is displayed.
139     * @return HTML fragment.
140     */
141    protected function info(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
142            qtype_renderer $qtoutput, question_display_options $options, $number) {
143        $output = '';
144        $output .= $this->number($number);
145        $output .= $this->status($qa, $behaviouroutput, $options);
146        $output .= $this->mark_summary($qa, $behaviouroutput, $options);
147        $output .= $this->question_flag($qa, $options->flags);
148        $output .= $this->edit_question_link($qa, $options);
149        return $output;
150    }
151
152    /**
153     * Generate the display of the question number.
154     * @param string|null $number The question number to display. 'i' is a special
155     *      value that gets displayed as Information. Null means no number is displayed.
156     * @return HTML fragment.
157     */
158    protected function number($number) {
159        if (trim($number) === '') {
160            return '';
161        }
162        $numbertext = '';
163        if (trim($number) === 'i') {
164            $numbertext = get_string('information', 'question');
165        } else {
166            $numbertext = get_string('questionx', 'question',
167                    html_writer::tag('span', $number, array('class' => 'qno')));
168        }
169        return html_writer::tag('h3', $numbertext, array('class' => 'no'));
170    }
171
172    /**
173     * Add an invisible heading like 'question text', 'feebdack' at the top of
174     * a section's contents, but only if the section has some content.
175     * @param string $heading the heading to add.
176     * @param string $content the content of the section.
177     * @return string HTML fragment with the heading added.
178     */
179    protected function add_part_heading($heading, $content) {
180        if ($content) {
181            $content = html_writer::tag('h4', $heading, array('class' => 'accesshide')) . $content;
182        }
183        return $content;
184    }
185
186    /**
187     * Generate the display of the status line that gives the current state of
188     * the question.
189     * @param question_attempt $qa the question attempt to display.
190     * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
191     *      specific parts.
192     * @param question_display_options $options controls what should and should not be displayed.
193     * @return HTML fragment.
194     */
195    protected function status(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
196            question_display_options $options) {
197        return html_writer::tag('div', $qa->get_state_string($options->correctness),
198                array('class' => 'state'));
199    }
200
201    /**
202     * Generate the display of the marks for this question.
203     * @param question_attempt $qa the question attempt to display.
204     * @param qbehaviour_renderer $behaviouroutput the behaviour renderer, which can generate a custom display.
205     * @param question_display_options $options controls what should and should not be displayed.
206     * @return HTML fragment.
207     */
208    protected function mark_summary(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) {
209        return html_writer::nonempty_tag('div',
210                $behaviouroutput->mark_summary($qa, $this, $options),
211                array('class' => 'grade'));
212    }
213
214    /**
215     * Generate the display of the marks for this question.
216     * @param question_attempt $qa the question attempt to display.
217     * @param question_display_options $options controls what should and should not be displayed.
218     * @return HTML fragment.
219     */
220    public function standard_mark_summary(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) {
221        if (!$options->marks) {
222            return '';
223
224        } else if ($qa->get_max_mark() == 0) {
225            return get_string('notgraded', 'question');
226
227        } else if ($options->marks == question_display_options::MAX_ONLY ||
228                is_null($qa->get_fraction())) {
229            return $behaviouroutput->marked_out_of_max($qa, $this, $options);
230
231        } else {
232            return $behaviouroutput->mark_out_of_max($qa, $this, $options);
233        }
234    }
235
236    /**
237     * Generate the display of the available marks for this question.
238     * @param question_attempt $qa the question attempt to display.
239     * @param question_display_options $options controls what should and should not be displayed.
240     * @return HTML fragment.
241     */
242    public function standard_marked_out_of_max(question_attempt $qa, question_display_options $options) {
243        return get_string('markedoutofmax', 'question', $qa->format_max_mark($options->markdp));
244    }
245
246    /**
247     * Generate the display of the marks for this question out of the available marks.
248     * @param question_attempt $qa the question attempt to display.
249     * @param question_display_options $options controls what should and should not be displayed.
250     * @return HTML fragment.
251     */
252    public function standard_mark_out_of_max(question_attempt $qa, question_display_options $options) {
253        $a = new stdClass();
254        $a->mark = $qa->format_mark($options->markdp);
255        $a->max = $qa->format_max_mark($options->markdp);
256        return get_string('markoutofmax', 'question', $a);
257    }
258
259    /**
260     * Render the question flag, assuming $flagsoption allows it.
261     *
262     * @param question_attempt $qa the question attempt to display.
263     * @param int $flagsoption the option that says whether flags should be displayed.
264     */
265    protected function question_flag(question_attempt $qa, $flagsoption) {
266        global $CFG;
267
268        $divattributes = array('class' => 'questionflag');
269
270        switch ($flagsoption) {
271            case question_display_options::VISIBLE:
272                $flagcontent = $this->get_flag_html($qa->is_flagged());
273                break;
274
275            case question_display_options::EDITABLE:
276                $id = $qa->get_flag_field_name();
277                // The checkbox id must be different from any element name, because
278                // of a stupid IE bug:
279                // http://www.456bereastreet.com/archive/200802/beware_of_id_and_name_attribute_mixups_when_using_getelementbyid_in_internet_explorer/
280                $checkboxattributes = array(
281                    'type' => 'checkbox',
282                    'id' => $id . 'checkbox',
283                    'name' => $id,
284                    'value' => 1,
285                );
286                if ($qa->is_flagged()) {
287                    $checkboxattributes['checked'] = 'checked';
288                }
289                $postdata = question_flags::get_postdata($qa);
290
291                $flagcontent = html_writer::empty_tag('input',
292                                array('type' => 'hidden', 'name' => $id, 'value' => 0)) .
293                        html_writer::empty_tag('input', $checkboxattributes) .
294                        html_writer::empty_tag('input',
295                                array('type' => 'hidden', 'value' => $postdata, 'class' => 'questionflagpostdata')) .
296                        html_writer::tag('label', $this->get_flag_html($qa->is_flagged(), $id . 'img'),
297                                array('id' => $id . 'label', 'for' => $id . 'checkbox')) . "\n";
298
299                $divattributes = array(
300                    'class' => 'questionflag editable',
301                    'aria-atomic' => 'true',
302                    'aria-relevant' => 'text',
303                    'aria-live' => 'assertive',
304                );
305
306                break;
307
308            default:
309                $flagcontent = '';
310        }
311
312        return html_writer::nonempty_tag('div', $flagcontent, $divattributes);
313    }
314
315    /**
316     * Work out the actual img tag needed for the flag
317     *
318     * @param bool $flagged whether the question is currently flagged.
319     * @param string $id an id to be added as an attribute to the img (optional).
320     * @return string the img tag.
321     */
322    protected function get_flag_html($flagged, $id = '') {
323        if ($flagged) {
324            $icon = 'i/flagged';
325            $alt = get_string('flagged', 'question');
326            $label = get_string('clickunflag', 'question');
327        } else {
328            $icon = 'i/unflagged';
329            $alt = get_string('notflagged', 'question');
330            $label = get_string('clickflag', 'question');
331        }
332        $attributes = array(
333            'src' => $this->image_url($icon),
334            'alt' => $alt,
335            'class' => 'questionflagimage',
336        );
337        if ($id) {
338            $attributes['id'] = $id;
339        }
340        $img = html_writer::empty_tag('img', $attributes);
341        $img .= html_writer::span($label);
342
343        return $img;
344    }
345
346    protected function edit_question_link(question_attempt $qa,
347            question_display_options $options) {
348        global $CFG;
349
350        if (empty($options->editquestionparams)) {
351            return '';
352        }
353
354        $params = $options->editquestionparams;
355        if ($params['returnurl'] instanceof moodle_url) {
356            $params['returnurl'] = $params['returnurl']->out_as_local_url(false);
357        }
358        $params['id'] = $qa->get_question_id();
359        $editurl = new moodle_url('/question/question.php', $params);
360
361        return html_writer::tag('div', html_writer::link(
362                $editurl, $this->pix_icon('t/edit', get_string('edit'), '', array('class' => 'iconsmall')) .
363                get_string('editquestion', 'question')),
364                array('class' => 'editquestion'));
365    }
366
367    /**
368     * Generate the display of the formulation part of the question. This is the
369     * area that contains the quetsion text, and the controls for students to
370     * input their answers. Some question types also embed feedback, for
371     * example ticks and crosses, in this area.
372     *
373     * @param question_attempt $qa the question attempt to display.
374     * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
375     *      specific parts.
376     * @param qtype_renderer $qtoutput the renderer to output the question type
377     *      specific parts.
378     * @param question_display_options $options controls what should and should not be displayed.
379     * @return HTML fragment.
380     */
381    protected function formulation(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
382            qtype_renderer $qtoutput, question_display_options $options) {
383        $output = '';
384        $output .= html_writer::empty_tag('input', array(
385                'type' => 'hidden',
386                'name' => $qa->get_control_field_name('sequencecheck'),
387                'value' => $qa->get_sequence_check_count()));
388        $output .= $qtoutput->formulation_and_controls($qa, $options);
389        if ($options->clearwrong) {
390            $output .= $qtoutput->clear_wrong($qa);
391        }
392        $output .= html_writer::nonempty_tag('div',
393                $behaviouroutput->controls($qa, $options), array('class' => 'im-controls'));
394        return $output;
395    }
396
397    /**
398     * Generate the display of the outcome part of the question. This is the
399     * area that contains the various forms of feedback.
400     *
401     * @param question_attempt $qa the question attempt to display.
402     * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
403     *      specific parts.
404     * @param qtype_renderer $qtoutput the renderer to output the question type
405     *      specific parts.
406     * @param question_display_options $options controls what should and should not be displayed.
407     * @return HTML fragment.
408     */
409    protected function outcome(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
410            qtype_renderer $qtoutput, question_display_options $options) {
411        $output = '';
412        $output .= html_writer::nonempty_tag('div',
413                $qtoutput->feedback($qa, $options), array('class' => 'feedback'));
414        $output .= html_writer::nonempty_tag('div',
415                $behaviouroutput->feedback($qa, $options), array('class' => 'im-feedback'));
416        $output .= html_writer::nonempty_tag('div',
417                $options->extrainfocontent, array('class' => 'extra-feedback'));
418        return $output;
419    }
420
421    protected function manual_comment(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
422            qtype_renderer $qtoutput, question_display_options $options) {
423        return $qtoutput->manual_comment($qa, $options) .
424                $behaviouroutput->manual_comment($qa, $options);
425    }
426
427    /**
428     * Generate the display of the response history part of the question. This
429     * is the table showing all the steps the question has been through.
430     *
431     * @param question_attempt $qa the question attempt to display.
432     * @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
433     *      specific parts.
434     * @param qtype_renderer $qtoutput the renderer to output the question type
435     *      specific parts.
436     * @param question_display_options $options controls what should and should not be displayed.
437     * @return HTML fragment.
438     */
439    protected function response_history(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
440            qtype_renderer $qtoutput, question_display_options $options) {
441
442        if (!$options->history) {
443            return '';
444        }
445
446        $table = new html_table();
447        $table->head  = array (
448            get_string('step', 'question'),
449            get_string('time'),
450            get_string('action', 'question'),
451            get_string('state', 'question'),
452        );
453        if ($options->marks >= question_display_options::MARK_AND_MAX) {
454            $table->head[] = get_string('marks', 'question');
455        }
456
457        foreach ($qa->get_full_step_iterator() as $i => $step) {
458            $stepno = $i + 1;
459
460            $rowclass = '';
461            if ($stepno == $qa->get_num_steps()) {
462                $rowclass = 'current';
463            } else if (!empty($options->questionreviewlink)) {
464                $url = new moodle_url($options->questionreviewlink,
465                        array('slot' => $qa->get_slot(), 'step' => $i));
466                $stepno = $this->output->action_link($url, $stepno,
467                        new popup_action('click', $url, 'reviewquestion',
468                                array('width' => 450, 'height' => 650)),
469                        array('title' => get_string('reviewresponse', 'question')));
470            }
471
472            $restrictedqa = new question_attempt_with_restricted_history($qa, $i, null);
473
474            $row = [$stepno,
475                    userdate($step->get_timecreated(), get_string('strftimedatetimeshort')),
476                    s($qa->summarise_action($step)) . $this->action_author($step, $options),
477                    $restrictedqa->get_state_string($options->correctness)];
478
479            if ($options->marks >= question_display_options::MARK_AND_MAX) {
480                $row[] = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
481            }
482
483            $table->rowclasses[] = $rowclass;
484            $table->data[] = $row;
485        }
486
487        return html_writer::tag('h4', get_string('responsehistory', 'question'),
488                        array('class' => 'responsehistoryheader')) .
489                $options->extrahistorycontent .
490                html_writer::tag('div', html_writer::table($table, true),
491                        array('class' => 'responsehistoryheader'));
492    }
493
494    /**
495     * Action author's profile link.
496     *
497     * @param question_attempt_step $step The step.
498     * @param question_display_options $options The display options.
499     * @return string The link to user's profile.
500     */
501    protected function action_author(question_attempt_step $step, question_display_options $options): string {
502        if ($options->userinfoinhistory && $step->get_user_id() != $options->userinfoinhistory) {
503            return html_writer::link(
504                    new moodle_url('/user/view.php', ['id' => $step->get_user_id(), 'course' => $this->page->course->id]),
505                    $step->get_user_fullname(), ['class' => 'd-table-cell']);
506        } else {
507            return '';
508        }
509    }
510}
511