MDL-80880 quiz: change display of previous attempts summary
[moodle.git] / mod / quiz / classes / output / attempt_summary_information.php
blob6ca0a36d94e87de2eeb3b66dd9c327bb82dced80
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.
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 namespace mod_quiz\output;
19 use action_link;
20 use core\output\named_templatable;
21 use html_writer;
22 use mod_quiz\quiz_attempt;
23 use moodle_url;
24 use mod_quiz\question\display_options;
25 use question_display_options;
26 use renderable;
27 use renderer_base;
28 use stdClass;
29 use user_picture;
31 /**
32 * A summary of a single quiz attempt for rendering.
34 * This is used in places like
35 * - at the top of the review attempt page (review.php)
36 * - at the top of the review single question page (reviewquestion.php)
37 * - on the quiz entry page (view.php).
39 * @package mod_quiz
40 * @copyright 2024 The Open University
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43 class attempt_summary_information implements renderable, named_templatable {
45 /** @var array[] The rows of summary data. {@see add_item()} should make the structure clear. */
46 protected array $summarydata = [];
48 /**
49 * Add an item to the summary.
51 * @param string $shortname unique identifier of this item (not displayed).
52 * @param string|renderable $title the title of this item.
53 * @param string|renderable $content the content of this item.
55 public function add_item(string $shortname, string|renderable $title, string|renderable $content): void {
56 $this->summarydata[$shortname] = [
57 'title' => $title,
58 'content' => $content,
62 /**
63 * Filter the data held, to keep only the information with the given shortnames.
65 * @param array $shortnames items to keep.
67 public function filter_keeping_only(array $shortnames): void {
68 foreach ($this->summarydata as $shortname => $rowdata) {
69 if (!in_array($shortname, $shortnames)) {
70 unset($this->summarydata[$shortname]);
75 /**
76 * To aid conversion of old code. This converts the old array format into an instance of this class.
78 * @param array $items array of $shortname => [$title, $content].
79 * @return static
81 public static function create_from_legacy_array(array $items): static {
82 $summary = new static();
83 foreach ($items as $shortname => $item) {
84 $summary->add_item($shortname, $item['title'], $item['content']);
86 return $summary;
89 /**
90 * Initialise an instance of this class for a particular quiz attempt.
92 * @param quiz_attempt $attemptobj the attempt to summarise.
93 * @param display_options $options options for what can be seen.
94 * @param int|null $pageforlinkingtootherattempts if null, no links to other attempsts will be created.
95 * If specified, the URL of this particular page of the attempt, otherwise
96 * the URL will go to the first page. If -1, deduce $page from $slot.
97 * @param bool|null $showall if true, the URL will be to review the entire attempt on one page,
98 * and $page will be ignored. If null, a sensible default will be chosen.
99 * @return self summary information.
101 public static function create_for_attempt(
102 quiz_attempt $attemptobj,
103 display_options $options,
104 ?int $pageforlinkingtootherattempts = null,
105 ?bool $showall = null,
106 ): static {
107 global $DB, $USER;
108 $summary = new static();
110 // Prepare summary information about the whole attempt.
111 if (!$attemptobj->get_quiz()->showuserpicture && $attemptobj->get_userid() != $USER->id) {
112 // If showuserpicture is true, the picture is shown elsewhere, so don't repeat it.
113 $student = $DB->get_record('user', ['id' => $attemptobj->get_userid()]);
114 $userpicture = new user_picture($student);
115 $userpicture->courseid = $attemptobj->get_courseid();
116 $summary->add_item('user', $userpicture,
117 new action_link(
118 new moodle_url('/user/view.php', ['id' => $student->id, 'course' => $attemptobj->get_courseid()]),
119 fullname($student, true),
124 if ($pageforlinkingtootherattempts !== null && $attemptobj->has_capability('mod/quiz:viewreports')) {
125 $attemptlist = $attemptobj->links_to_other_attempts(
126 $attemptobj->review_url(null, $pageforlinkingtootherattempts, $showall));
127 if ($attemptlist) {
128 $summary->add_item('attemptlist', get_string('attempts', 'quiz'), $attemptlist);
132 // Timing information.
133 $attempt = $attemptobj->get_attempt();
134 $quiz = $attemptobj->get_quiz();
135 $overtime = 0;
137 if ($attempt->state == quiz_attempt::FINISHED) {
138 if ($timetaken = ($attempt->timefinish - $attempt->timestart)) {
139 if ($quiz->timelimit && $timetaken > ($quiz->timelimit + 60)) {
140 $overtime = $timetaken - $quiz->timelimit;
141 $overtime = format_time($overtime);
143 $timetaken = format_time($timetaken);
144 } else {
145 $timetaken = "-";
147 } else {
148 $timetaken = get_string('unfinished', 'quiz');
151 $summary->add_item('startedon', get_string('startedon', 'quiz'), userdate($attempt->timestart));
153 $summary->add_item('state', get_string('attemptstate', 'quiz'),
154 quiz_attempt::state_name($attemptobj->get_attempt()->state));
156 if ($attempt->state == quiz_attempt::FINISHED) {
157 $summary->add_item('completedon', get_string('completedon', 'quiz'),
158 userdate($attempt->timefinish));
159 $summary->add_item('timetaken', get_string('timetaken', 'quiz'), $timetaken);
162 if (!empty($overtime)) {
163 $summary->add_item('overdue', get_string('overdue', 'quiz'), $overtime);
166 // Show marks (if the user is allowed to see marks at the moment).
167 $grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false);
168 if ($options->marks >= question_display_options::MARK_AND_MAX && quiz_has_grades($quiz)) {
170 if ($attempt->state != quiz_attempt::FINISHED) {
171 // Cannot display grade.
173 } else if (is_null($grade)) {
174 $summary->add_item('grade', get_string('gradenoun'),
175 quiz_format_grade($quiz, $grade));
177 } else {
178 // Show raw marks only if they are different from the grade (like on the view page).
179 if ($quiz->grade != $quiz->sumgrades) {
180 $a = new stdClass();
181 $a->grade = quiz_format_grade($quiz, $attempt->sumgrades);
182 $a->maxgrade = quiz_format_grade($quiz, $quiz->sumgrades);
183 $summary->add_item('marks', get_string('marks', 'quiz'),
184 get_string('outofshort', 'quiz', $a));
187 // Now the scaled grade.
188 $a = new stdClass();
189 $a->grade = html_writer::tag('b', quiz_format_grade($quiz, $grade));
190 $a->maxgrade = quiz_format_grade($quiz, $quiz->grade);
191 if ($quiz->grade != 100) {
192 // Show the percentage using the configured number of decimal places,
193 // but without trailing zeroes.
194 $a->percent = html_writer::tag('b', format_float(
195 $attempt->sumgrades * 100 / $quiz->sumgrades,
196 $quiz->decimalpoints, true, true));
197 $formattedgrade = get_string('outofpercent', 'quiz', $a);
198 } else {
199 $formattedgrade = get_string('outof', 'quiz', $a);
201 $summary->add_item('grade', get_string('gradenoun'),
202 $formattedgrade);
206 // Any additional summary data from the behaviour.
207 foreach ($attemptobj->get_additional_summary_data($options) as $shortname => $data) {
208 $summary->add_item($shortname, $data['title'], $data['content']);
211 // Feedback if there is any, and the user is allowed to see it now.
212 $feedback = $attemptobj->get_overall_feedback($grade);
213 if ($options->overallfeedback && $feedback) {
214 $summary->add_item('feedback', get_string('feedback', 'quiz'), $feedback);
217 return $summary;
220 public function export_for_template(renderer_base $output): array {
222 $templatecontext = [
223 'hasitems' => !empty($this->summarydata),
224 'items' => [],
226 foreach ($this->summarydata as $item) {
227 if ($item['title'] instanceof renderable) {
228 $title = $output->render($item['title']);
229 } else {
230 $title = $item['title'];
233 if ($item['content'] instanceof renderable) {
234 $content = $output->render($item['content']);
235 } else {
236 $content = $item['content'];
239 $templatecontext['items'][] = (object) ['title' => $title, 'content' => $content];
242 return $templatecontext;
245 public function get_template_name(\renderer_base $renderer): string {
246 // Only reason we are forced to implement this is that we want the quiz renderer
247 // passed to export_for_template, not a core_renderer.
248 return 'mod_quiz/attempt_summary_information';