This commit is contained in:
Ilya Tregubov 2024-03-06 13:16:42 +08:00
commit 8e99589f77
19 changed files with 559 additions and 288 deletions

View File

@ -0,0 +1,250 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_quiz\output;
use action_link;
use core\output\named_templatable;
use html_writer;
use mod_quiz\quiz_attempt;
use moodle_url;
use mod_quiz\question\display_options;
use question_display_options;
use renderable;
use renderer_base;
use stdClass;
use user_picture;
/**
* A summary of a single quiz attempt for rendering.
*
* This is used in places like
* - at the top of the review attempt page (review.php)
* - at the top of the review single question page (reviewquestion.php)
* - on the quiz entry page (view.php).
*
* @package mod_quiz
* @copyright 2024 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class attempt_summary_information implements renderable, named_templatable {
/** @var array[] The rows of summary data. {@see add_item()} should make the structure clear. */
protected array $summarydata = [];
/**
* Add an item to the summary.
*
* @param string $shortname unique identifier of this item (not displayed).
* @param string|renderable $title the title of this item.
* @param string|renderable $content the content of this item.
*/
public function add_item(string $shortname, string|renderable $title, string|renderable $content): void {
$this->summarydata[$shortname] = [
'title' => $title,
'content' => $content,
];
}
/**
* Filter the data held, to keep only the information with the given shortnames.
*
* @param array $shortnames items to keep.
*/
public function filter_keeping_only(array $shortnames): void {
foreach ($this->summarydata as $shortname => $rowdata) {
if (!in_array($shortname, $shortnames)) {
unset($this->summarydata[$shortname]);
}
}
}
/**
* To aid conversion of old code. This converts the old array format into an instance of this class.
*
* @param array $items array of $shortname => [$title, $content].
* @return static
*/
public static function create_from_legacy_array(array $items): static {
$summary = new static();
foreach ($items as $shortname => $item) {
$summary->add_item($shortname, $item['title'], $item['content']);
}
return $summary;
}
/**
* Initialise an instance of this class for a particular quiz attempt.
*
* @param quiz_attempt $attemptobj the attempt to summarise.
* @param display_options $options options for what can be seen.
* @param int|null $pageforlinkingtootherattempts if null, no links to other attempsts will be created.
* If specified, the URL of this particular page of the attempt, otherwise
* the URL will go to the first page. If -1, deduce $page from $slot.
* @param bool|null $showall if true, the URL will be to review the entire attempt on one page,
* and $page will be ignored. If null, a sensible default will be chosen.
* @return self summary information.
*/
public static function create_for_attempt(
quiz_attempt $attemptobj,
display_options $options,
?int $pageforlinkingtootherattempts = null,
?bool $showall = null,
): static {
global $DB, $USER;
$summary = new static();
// Prepare summary information about the whole attempt.
if (!$attemptobj->get_quiz()->showuserpicture && $attemptobj->get_userid() != $USER->id) {
// If showuserpicture is true, the picture is shown elsewhere, so don't repeat it.
$student = $DB->get_record('user', ['id' => $attemptobj->get_userid()]);
$userpicture = new user_picture($student);
$userpicture->courseid = $attemptobj->get_courseid();
$summary->add_item('user', $userpicture,
new action_link(
new moodle_url('/user/view.php', ['id' => $student->id, 'course' => $attemptobj->get_courseid()]),
fullname($student, true),
)
);
}
if ($pageforlinkingtootherattempts !== null && $attemptobj->has_capability('mod/quiz:viewreports')) {
$attemptlist = $attemptobj->links_to_other_attempts(
$attemptobj->review_url(null, $pageforlinkingtootherattempts, $showall));
if ($attemptlist) {
$summary->add_item('attemptlist', get_string('attempts', 'quiz'), $attemptlist);
}
}
// Timing information.
$attempt = $attemptobj->get_attempt();
$quiz = $attemptobj->get_quiz();
$overtime = 0;
if ($attempt->state == quiz_attempt::FINISHED) {
if ($timetaken = ($attempt->timefinish - $attempt->timestart)) {
if ($quiz->timelimit && $timetaken > ($quiz->timelimit + 60)) {
$overtime = $timetaken - $quiz->timelimit;
$overtime = format_time($overtime);
}
$timetaken = format_time($timetaken);
} else {
$timetaken = "-";
}
} else {
$timetaken = get_string('unfinished', 'quiz');
}
$summary->add_item('startedon', get_string('startedon', 'quiz'), userdate($attempt->timestart));
$summary->add_item('state', get_string('attemptstate', 'quiz'),
quiz_attempt::state_name($attemptobj->get_attempt()->state));
if ($attempt->state == quiz_attempt::FINISHED) {
$summary->add_item('completedon', get_string('completedon', 'quiz'),
userdate($attempt->timefinish));
$summary->add_item('timetaken', get_string('timetaken', 'quiz'), $timetaken);
}
if (!empty($overtime)) {
$summary->add_item('overdue', get_string('overdue', 'quiz'), $overtime);
}
// Show marks (if the user is allowed to see marks at the moment).
$grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false);
if ($options->marks >= question_display_options::MARK_AND_MAX && quiz_has_grades($quiz)) {
if ($attempt->state != quiz_attempt::FINISHED) {
// Cannot display grade.
} else if (is_null($grade)) {
$summary->add_item('grade', get_string('gradenoun'),
quiz_format_grade($quiz, $grade));
} else {
// Show raw marks only if they are different from the grade (like on the view page).
if ($quiz->grade != $quiz->sumgrades) {
$a = new stdClass();
$a->grade = quiz_format_grade($quiz, $attempt->sumgrades);
$a->maxgrade = quiz_format_grade($quiz, $quiz->sumgrades);
$summary->add_item('marks', get_string('marks', 'quiz'),
get_string('outofshort', 'quiz', $a));
}
// Now the scaled grade.
$a = new stdClass();
$a->grade = html_writer::tag('b', quiz_format_grade($quiz, $grade));
$a->maxgrade = quiz_format_grade($quiz, $quiz->grade);
if ($quiz->grade != 100) {
// Show the percentage using the configured number of decimal places,
// but without trailing zeroes.
$a->percent = html_writer::tag('b', format_float(
$attempt->sumgrades * 100 / $quiz->sumgrades,
$quiz->decimalpoints, true, true));
$formattedgrade = get_string('outofpercent', 'quiz', $a);
} else {
$formattedgrade = get_string('outof', 'quiz', $a);
}
$summary->add_item('grade', get_string('gradenoun'),
$formattedgrade);
}
}
// Any additional summary data from the behaviour.
foreach ($attemptobj->get_additional_summary_data($options) as $shortname => $data) {
$summary->add_item($shortname, $data['title'], $data['content']);
}
// Feedback if there is any, and the user is allowed to see it now.
$feedback = $attemptobj->get_overall_feedback($grade);
if ($options->overallfeedback && $feedback) {
$summary->add_item('feedback', get_string('feedback', 'quiz'), $feedback);
}
return $summary;
}
public function export_for_template(renderer_base $output): array {
$templatecontext = [
'hasitems' => !empty($this->summarydata),
'items' => [],
];
foreach ($this->summarydata as $item) {
if ($item['title'] instanceof renderable) {
$title = $output->render($item['title']);
} else {
$title = $item['title'];
}
if ($item['content'] instanceof renderable) {
$content = $output->render($item['content']);
} else {
$content = $item['content'];
}
$templatecontext['items'][] = (object) ['title' => $title, 'content' => $content];
}
return $templatecontext;
}
public function get_template_name(\renderer_base $renderer): string {
// Only reason we are forced to implement this is that we want the quiz renderer
// passed to export_for_template, not a core_renderer.
return 'mod_quiz/attempt_summary_information';
}
}

View File

@ -0,0 +1,85 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_quiz\output;
use core\output\named_templatable;
use mod_quiz\quiz_attempt;
use renderable;
use renderer_base;
/**
* Display summary information about a list of attempts.
*
* This is used on the front page of the quiz (view.php).
*
* @package mod_quiz
* @copyright 2024 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class list_of_attempts implements renderable, named_templatable {
/** @var int time to consider as now. */
protected int $timenow;
/** @var quiz_attempt[] The list of attempts to summarise. */
protected array $attempts = [];
/**
* Constructor.
*
* @param int $timenow time that is now.
*/
public function __construct(int $timenow) {
$this->timenow = $timenow;
}
/**
* Add an event to the list.
*
* @param quiz_attempt $attemptobj
*/
public function add_attempt(quiz_attempt $attemptobj): void {
$this->attempts[] = $attemptobj;
}
public function export_for_template(renderer_base $output): array {
$templatecontext = [
'hasattempts' => !empty($this->attempts),
'attempts' => [],
];
foreach ($this->attempts as $attemptobj) {
$displayoptions = $attemptobj->get_display_options(true);
$templatecontext['attempts'][] = (object) [
'name' => get_string('attempt', 'mod_quiz', $attemptobj->get_attempt_number()),
'summarydata' => attempt_summary_information::create_for_attempt(
$attemptobj, $displayoptions)->export_for_template($output),
'reviewlink' => $attemptobj->get_access_manager($this->timenow)->make_review_link(
$attemptobj->get_attempt(), $displayoptions, $output),
];
}
return $templatecontext;
}
public function get_template_name(\renderer_base $renderer): string {
// Only reason we are forced to implement this is that we want the quiz renderer
// passed to export_for_template, not a core_renderer.
return 'mod_quiz/list_of_attempts';
}
}

View File

@ -54,15 +54,22 @@ class renderer extends plugin_renderer_base {
* @param bool $showall whether to show entire attempt on one page.
* @param bool $lastpage if true the current page is the last page.
* @param display_options $displayoptions instance of display_options.
* @param array $summarydata contains all table data
* @param attempt_summary_information|array $summarydata summary information about the attempt.
* Passing an array is deprecated.
* @return string HTML to display.
*/
public function review_page(quiz_attempt $attemptobj, $slots, $page, $showall,
$lastpage, display_options $displayoptions, $summarydata) {
if (is_array($summarydata)) {
debugging('Since Moodle 4.4, $summarydata passed to review_page should be a attempt_summary_information.',
DEBUG_DEVELOPER);
$summarydata = $this->filter_review_summary_table($summarydata, $page);
$summarydata = attempt_summary_information::create_from_legacy_array($summarydata);
}
$output = '';
$output .= $this->header();
$output .= $this->review_summary_table($summarydata, $page);
$output .= $this->review_attempt_summary($summarydata, $page);
$output .= $this->review_form($page, $showall, $displayoptions,
$this->questions($attemptobj, true, $slots, $page, $showall, $displayoptions),
$attemptobj);
@ -79,15 +86,21 @@ class renderer extends plugin_renderer_base {
* @param int $slot which question to display.
* @param int $seq which step of the question attempt to show. null = latest.
* @param display_options $displayoptions instance of display_options.
* @param array $summarydata contains all table data
* @param attempt_summary_information|array $summarydata summary information about the attempt.
* Passing an array is deprecated.
* @return string HTML to display.
*/
public function review_question_page(quiz_attempt $attemptobj, $slot, $seq,
display_options $displayoptions, $summarydata) {
if (is_array($summarydata)) {
debugging('Since Moodle 4.4, $summarydata passed to review_question_page should be a attempt_summary_information.',
DEBUG_DEVELOPER);
$summarydata = attempt_summary_information::create_from_legacy_array($summarydata);
}
$output = '';
$output .= $this->header();
$output .= $this->review_summary_table($summarydata, 0);
$output .= html_writer::div($this->render($summarydata), 'mb-3');
if (!is_null($seq)) {
$output .= $attemptobj->render_question_at_step($slot, $seq, true, $this);
@ -118,14 +131,49 @@ class renderer extends plugin_renderer_base {
return $output;
}
/**
* A chance to filter the information before display.
*
* Moodle core uses this to display less infomrmation on pages after the first.
* This is a separate method, becaus it is a useful hook where themes can overrid things.
*
* @param attempt_summary_information $summarydata the data that will be displayed. Modify as desired.
* @param int $page contains the current page number
*/
public function filter_review_attempt_summary(
attempt_summary_information $summarydata,
int $page
): void {
if ($page > 0) {
$summarydata->filter_keeping_only(['user', 'attemptlist']);
}
}
/**
* Outputs the overall summary of the attempt at the top of the review page.
*
* @param attempt_summary_information $summarydata contains row data for table.
* @param int $page contains the current page number
* @return string HTML to display.
*/
public function review_attempt_summary(
attempt_summary_information $summarydata,
int $page
): string {
$this->filter_review_attempt_summary($summarydata, $page);
return html_writer::div($this->render($summarydata), 'mb-3');
}
/**
* Filters the summarydata array.
*
* @param array $summarydata contains row data for table
* @param int $page the current page number
* @return array updated version of the $summarydata array.
* @deprecated since Moodle 4.4. Replaced by filter_review_attempt_summary.
*/
protected function filter_review_summary_table($summarydata, $page) {
debugging('filter_review_summary_table() is deprecated. Replaced by filter_review_attempt_summary().', DEBUG_DEVELOPER);
if ($page == 0) {
return $summarydata;
}
@ -146,39 +194,12 @@ class renderer extends plugin_renderer_base {
* @param array $summarydata contains row data for table
* @param int $page contains the current page number
* @return string HTML to display.
* @deprecated since Moodle 4.4. Replaced by review_attempt_summary.
*/
public function review_summary_table($summarydata, $page) {
debugging('review_summary_table() is deprecated. Please use review_attempt_summary() instead.', DEBUG_DEVELOPER);
$summarydata = $this->filter_review_summary_table($summarydata, $page);
if (empty($summarydata)) {
return '';
}
$output = '';
$output .= html_writer::start_tag('table', [
'class' => 'generaltable generalbox quizreviewsummary']);
$output .= html_writer::start_tag('tbody');
foreach ($summarydata as $rowdata) {
if ($rowdata['title'] instanceof renderable) {
$title = $this->render($rowdata['title']);
} else {
$title = $rowdata['title'];
}
if ($rowdata['content'] instanceof renderable) {
$content = $this->render($rowdata['content']);
} else {
$content = $rowdata['content'];
}
$output .= html_writer::tag('tr',
html_writer::tag('th', $title, ['class' => 'cell', 'scope' => 'row']) .
html_writer::tag('td', $content, ['class' => 'cell'])
);
}
$output .= html_writer::end_tag('tbody');
$output .= html_writer::end_tag('table');
return $output;
$this->render(attempt_summary_information::create_from_legacy_array($summarydata));
}
/**
@ -868,8 +889,8 @@ class renderer extends plugin_renderer_base {
$output .= $this->view_page_tertiary_nav($viewobj);
$output .= $this->view_information($quiz, $cm, $context, $viewobj->infomessages);
$output .= $this->view_table($quiz, $context, $viewobj);
$output .= $this->view_result_info($quiz, $context, $cm, $viewobj);
$output .= $this->render($viewobj->attemptslist);
$output .= $this->box($this->view_page_buttons($viewobj), 'quizattempt');
return $output;
}
@ -1094,8 +1115,10 @@ class renderer extends plugin_renderer_base {
* @param stdClass $quiz the quiz settings.
* @param context_module $context the quiz context.
* @param view_page $viewobj
* @deprecated Since 4.4 please use the {@see list_of_attempts} renderable instead.
*/
public function view_table($quiz, $context, $viewobj) {
debugging('view_table has been deprecated since 4.4 please use the list_of_attempts renderable instead.');
if (!$viewobj->attempts) {
return '';
}

View File

@ -39,6 +39,8 @@ class view_page {
public $attempts;
/** @var quiz_attempt[] $attemptobjs objects corresponding to $attempts. */
public $attemptobjs;
/** @var list_of_attempts list of past attempts for rendering. */
public $attemptslist;
/** @var access_manager $accessmanager contains various access rules. */
public $accessmanager;
/** @var bool $canreviewmine whether the current user has the capability to

View File

@ -593,10 +593,15 @@ class quiz_attempt {
* The values are arrays with two items, title and content. Each of these
* will be either a string, or a renderable.
*
* If this method is called before load_questions() is called, then an empty array is returned.
*
* @param question_display_options $options the display options for this quiz attempt at this time.
* @return array as described above.
*/
public function get_additional_summary_data(question_display_options $options) {
if (!isset($this->quba)) {
return [];
}
return $this->quba->get_summary_information($options);
}
@ -947,8 +952,7 @@ class quiz_attempt {
* state data about this question.
*
* @param int $slot the number used to identify this question within this attempt.
* @return string the formatted grade, to the number of decimal places specified
* by the quiz.
* @return string the name of the question. Must be output through format_string.
*/
public function get_question_name($slot) {
return $this->quba->get_question($slot, false)->name;

View File

@ -23,6 +23,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use mod_quiz\output\attempt_summary_information;
require_once('../../config.php');
require_once('locallib.php');
@ -55,29 +57,20 @@ $output = $PAGE->get_renderer('mod_quiz');
echo $output->header();
// Prepare summary information about this question attempt.
$summarydata = [];
$summary = new attempt_summary_information();
// Student name.
$userpicture = new user_picture($student);
$userpicture->courseid = $attemptobj->get_courseid();
$summarydata['user'] = [
'title' => $userpicture,
'content' => new action_link(new moodle_url('/user/view.php', [
'id' => $student->id, 'course' => $attemptobj->get_courseid()]),
fullname($student, true)),
];
$summary->add_item('user', $userpicture, new action_link(
new moodle_url('/user/view.php', [ 'id' => $student->id, 'course' => $attemptobj->get_courseid()]),
fullname($student, true)));
// Quiz name.
$summarydata['quizname'] = [
'title' => get_string('modulename', 'quiz'),
'content' => format_string($attemptobj->get_quiz_name()),
];
$summary->add_item('quizname', get_string('modulename', 'quiz'), format_string($attemptobj->get_quiz_name()));
// Question name.
$summarydata['questionname'] = [
'title' => get_string('question', 'quiz'),
'content' => $attemptobj->get_question_name($slot),
];
$summary->add_item('questionname', get_string('question', 'quiz'), $attemptobj->get_question_name($slot));
// Process any data that was submitted.
if (data_submitted() && confirm_sesskey()) {
@ -107,7 +100,7 @@ if (data_submitted() && confirm_sesskey()) {
}
// Print quiz information.
echo $output->review_summary_table($summarydata, 0);
echo html_writer::div($output->render($summary), 'mb-3');
// Print the comment form.
echo '<form method="post" class="mform" id="manualgradingform" action="' .

View File

@ -1016,7 +1016,7 @@ $string['subplugintype_quizaccess'] = 'Access rule';
$string['subplugintype_quizaccess_plural'] = 'Access rules';
$string['substitutedby'] = 'will be substituted by';
$string['summaryofattempt'] = 'Summary of attempt';
$string['summaryofattempts'] = 'Summary of your previous attempts';
$string['summaryofattempts'] = 'Your attempts';
$string['temporaryblocked'] = 'You are temporarily not allowed to re-attempt the quiz.<br /> You will be able to take another attempt on:';
$string['theattempt'] = 'The attempt';
$string['theattempt_help'] = 'Whether the student can review the attempt at all.';

View File

@ -664,7 +664,7 @@ function quiz_get_user_grades($quiz, $userid = 0) {
* Round a grade to the correct number of decimal places, and format it for display.
*
* @param stdClass $quiz The quiz table row, only $quiz->decimalpoints is used.
* @param float $grade The grade to round.
* @param float|null $grade The grade to round and display (or null meaning no grade).
* @return string
*/
function quiz_format_grade($quiz, $grade) {

View File

@ -25,6 +25,7 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use mod_quiz\output\attempt_summary_information;
use mod_quiz\output\navigation_panel_review;
use mod_quiz\output\renderer;
use mod_quiz\quiz_attempt;
@ -113,141 +114,7 @@ $PAGE->set_title($attemptobj->review_page_title($page, $showall));
$PAGE->set_heading($attemptobj->get_course()->fullname);
$PAGE->activityheader->disable();
// Summary table start. ============================================================================
// Work out some time-related things.
$attempt = $attemptobj->get_attempt();
$quiz = $attemptobj->get_quiz();
$overtime = 0;
if ($attempt->state == quiz_attempt::FINISHED) {
if ($timetaken = ($attempt->timefinish - $attempt->timestart)) {
if ($quiz->timelimit && $timetaken > ($quiz->timelimit + 60)) {
$overtime = $timetaken - $quiz->timelimit;
$overtime = format_time($overtime);
}
$timetaken = format_time($timetaken);
} else {
$timetaken = "-";
}
} else {
$timetaken = get_string('unfinished', 'quiz');
}
// Prepare summary informat about the whole attempt.
$summarydata = [];
if (!$attemptobj->get_quiz()->showuserpicture && $attemptobj->get_userid() != $USER->id) {
// If showuserpicture is true, the picture is shown elsewhere, so don't repeat it.
$student = $DB->get_record('user', ['id' => $attemptobj->get_userid()]);
$userpicture = new user_picture($student);
$userpicture->courseid = $attemptobj->get_courseid();
$summarydata['user'] = [
'title' => $userpicture,
'content' => new action_link(new moodle_url('/user/view.php', [
'id' => $student->id, 'course' => $attemptobj->get_courseid()]),
fullname($student, true)),
];
}
if ($attemptobj->has_capability('mod/quiz:viewreports')) {
$attemptlist = $attemptobj->links_to_other_attempts($attemptobj->review_url(null, $page,
$showall));
if ($attemptlist) {
$summarydata['attemptlist'] = [
'title' => get_string('attempts', 'quiz'),
'content' => $attemptlist,
];
}
}
// Timing information.
$summarydata['startedon'] = [
'title' => get_string('startedon', 'quiz'),
'content' => userdate($attempt->timestart),
];
$summarydata['state'] = [
'title' => get_string('attemptstate', 'quiz'),
'content' => quiz_attempt::state_name($attempt->state),
];
if ($attempt->state == quiz_attempt::FINISHED) {
$summarydata['completedon'] = [
'title' => get_string('completedon', 'quiz'),
'content' => userdate($attempt->timefinish),
];
$summarydata['timetaken'] = [
'title' => get_string('timetaken', 'quiz'),
'content' => $timetaken,
];
}
if (!empty($overtime)) {
$summarydata['overdue'] = [
'title' => get_string('overdue', 'quiz'),
'content' => $overtime,
];
}
// Show marks (if the user is allowed to see marks at the moment).
$grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false);
if ($options->marks >= question_display_options::MARK_AND_MAX && quiz_has_grades($quiz)) {
if ($attempt->state != quiz_attempt::FINISHED) {
// Cannot display grade.
} else if (is_null($grade)) {
$summarydata['grade'] = [
'title' => get_string('gradenoun'),
'content' => quiz_format_grade($quiz, $grade),
];
} else {
// Show raw marks only if they are different from the grade (like on the view page).
if ($quiz->grade != $quiz->sumgrades) {
$a = new stdClass();
$a->grade = quiz_format_grade($quiz, $attempt->sumgrades);
$a->maxgrade = quiz_format_grade($quiz, $quiz->sumgrades);
$summarydata['marks'] = [
'title' => get_string('marks', 'quiz'),
'content' => get_string('outofshort', 'quiz', $a),
];
}
// Now the scaled grade.
$a = new stdClass();
$a->grade = html_writer::tag('b', quiz_format_grade($quiz, $grade));
$a->maxgrade = quiz_format_grade($quiz, $quiz->grade);
if ($quiz->grade != 100) {
// Show the percentage using the configured number of decimal places,
// but without trailing zeroes.
$a->percent = html_writer::tag('b', format_float(
$attempt->sumgrades * 100 / $quiz->sumgrades,
$quiz->decimalpoints, true, true));
$formattedgrade = get_string('outofpercent', 'quiz', $a);
} else {
$formattedgrade = get_string('outof', 'quiz', $a);
}
$summarydata['grade'] = [
'title' => get_string('gradenoun'),
'content' => $formattedgrade,
];
}
}
// Any additional summary data from the behaviour.
$summarydata = array_merge($summarydata, $attemptobj->get_additional_summary_data($options));
// Feedback if there is any, and the user is allowed to see it now.
$feedback = $attemptobj->get_overall_feedback($grade);
if ($options->overallfeedback && $feedback) {
$summarydata['feedback'] = [
'title' => get_string('feedback', 'quiz'),
'content' => $feedback,
];
}
// Summary table end. ==============================================================================
$summarydata = attempt_summary_information::create_for_attempt($attemptobj, $options, $page, $showall);
if ($showall) {
$slots = $attemptobj->get_slots();

View File

@ -23,6 +23,7 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use mod_quiz\output\attempt_summary_information;
require_once(__DIR__ . '/../../config.php');
require_once('locallib.php');
@ -75,29 +76,22 @@ if ($attemptobj->is_own_attempt()) {
}
// Prepare summary informat about this question attempt.
$summarydata = [];
$summary = new attempt_summary_information();
// Student name.
$userpicture = new user_picture($student);
$userpicture->courseid = $attemptobj->get_courseid();
$summarydata['user'] = [
'title' => $userpicture,
'content' => new action_link(new moodle_url('/user/view.php', [
'id' => $student->id, 'course' => $attemptobj->get_courseid()]),
fullname($student, true)),
];
$summary->add_item('user', $userpicture,
new action_link(new moodle_url('/user/view.php', ['id' => $student->id, 'course' => $attemptobj->get_courseid()]),
fullname($student, true)));
// Quiz name.
$summarydata['quizname'] = [
'title' => get_string('modulename', 'quiz'),
'content' => format_string($attemptobj->get_quiz_name()),
];
$summary->add_item('quizname', get_string('modulename', 'quiz'),
format_string($attemptobj->get_quiz_name()));
// Question name.
$summarydata['questionname'] = [
'title' => get_string('question', 'quiz'),
'content' => $attemptobj->get_question_name($slot),
];
$summary->add_item('questionname', get_string('question', 'quiz'),
format_string($attemptobj->get_question_name($slot)));
// Other attempts at the quiz.
if ($attemptobj->has_capability('mod/quiz:viewreports')) {
@ -105,21 +99,15 @@ if ($attemptobj->has_capability('mod/quiz:viewreports')) {
$otherattemptsurl->param('slot', $attemptobj->get_original_slot($slot));
$attemptlist = $attemptobj->links_to_other_attempts($otherattemptsurl);
if ($attemptlist) {
$summarydata['attemptlist'] = [
'title' => get_string('attempts', 'quiz'),
'content' => $attemptlist,
];
$summary->add_item('attemptlist', get_string('attempts', 'quiz'), $attemptlist);
}
}
// Timestamp of this action.
$timestamp = $attemptobj->get_question_action_time($slot);
if ($timestamp) {
$summarydata['timestamp'] = [
'title' => get_string('completedon', 'quiz'),
'content' => userdate($timestamp),
];
$summary->add_item('timestamp', get_string('completedon', 'quiz'), userdate($timestamp));
}
echo $output->review_question_page($attemptobj, $slot, $seq,
$attemptobj->get_display_options(true), $summarydata);
$attemptobj->get_display_options(true), $summary);

View File

@ -251,24 +251,6 @@
text-align: center;
}
#page-mod-quiz-view #page .quizattemptsummary td p {
margin-top: 0;
}
#page-mod-quiz-view table.quizattemptsummary tr.bestrow td {
border-color: #bce8f1;
background-color: #d9edf7;
}
table.quizattemptsummary .noreviewmessage {
color: gray;
}
#page-mod-quiz-view .generaltable.quizattemptsummary {
margin-left: auto;
margin-right: auto;
}
#page-mod-quiz-view .generalbox#feedback .overriddennotice {
text-align: center;
font-size: 0.7em;

View File

@ -0,0 +1,45 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template mod_quiz/attempt_summary_information
This template renders summary information about a single quiz attempt.
Example context (json):
{
"hasitems": true,
"items": [
{
"title": "<img src='https://qa.moodledemo.net/pluginfile.php/27/user/icon/boost/f2?rev=5522' class='userpicture' width='35' height='35' alt='It is me!'>",
"content": "<a href='https://qa.moodledemo.net/user/view.php?id=4&amp;course=2'>Sam Student</a>"
},
{"title": "State", "content": "Finished"},
{"title": "Started on", "content": "Thursday, 23 November 2023, 9:29 AM"},
{"title": "Completed on", "content": "Thursday, 23 November 2023, 9:32 AM"},
{"title": "Grade", "content": "Not yet graded"}
]
}
}}
{{#hasitems}}
<table class="generaltable generalbox quizreviewsummary mb-0">
<tbody>
{{#items}}
<tr>
<th class="cell" scope="row">{{{title}}}</th>
<td class="cell">{{{content}}}</td>
</tr>
{{/items}}
</tbody>
</table>
{{/hasitems}}

View File

@ -0,0 +1,68 @@
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template mod_quiz/list_of_attempts
This template renders summary information about a list of quiz attempts.
The structure for each attempt should be what is required by mod_quiz/attempt_summary_information.
Example context (json):
{
"hasattempts": true,
"attempts": [
{
"name": "Attempt 1",
"reviewlink": "<a href='https://qa.moodledemo.net/mod/quiz/review.php?attempt=13&cmid=30'>Review</a>",
"summarydata": {
"hasitems": true,
"items": [
{
"title": "<img src='https://qa.moodledemo.net/pluginfile.php/27/user/icon/boost/f2?rev=5522' class='userpicture' width='35' height='35' alt='It is me!'>",
"content": "<a href='https://qa.moodledemo.net/user/view.php?id=4&amp;course=2'>Sam Student</a>"
},
{"title": "State", "content": "Finished"},
{"title": "Started on", "content": "Thursday, 23 November 2023, 9:29 AM"},
{"title": "Completed on", "content": "Thursday, 23 November 2023, 9:32 AM"},
{"title": "Grade", "content": "Not yet graded"}
]
}
}
]
}
}}
{{#hasattempts}}
<h3>{{# str}}summaryofattempts, quiz{{/str}}</h3>
<ul class="list-unstyled row row-cols-1 row-cols-md-2 no-gutters">
{{#attempts}}
<li class="col pl-0 pr-2 mb-2">
<div class="card h-100">
<div class="card-header py-2 border-bottom-0">
<h4 class="card-title my-0">{{name}}</h4>
</div>
{{#summarydata}}
{{> mod_quiz/attempt_summary_information}}
{{/summarydata}}
<div class="card-body py-2">
<div>{{{reviewlink}}}</div>
</div>
</div>
</li>
{{/attempts}}
</ul>
{{/hasattempts}}

View File

@ -507,56 +507,4 @@ class attempt_test extends \advanced_testcase {
$this->expectExceptionObject(new \moodle_exception('questiondraftonly', 'mod_quiz', '', $question->name));
quiz_start_attempt_built_on_last($quba, $newattempt, $attempt);
}
/**
* Starting a new attempt and check the summary previous attempts table.
*
* @covers ::view_table()
*/
public function test_view_table(): void {
global $PAGE;
$this->resetAfterTest();
$timenow = time();
// Create attempt object.
$attempt = $this->create_quiz_and_attempt_with_layout('1,1,0');
// Finish attempt.
$attempt->process_finish($timenow, false);
$quiz = $attempt->get_quiz();
$context = $attempt->get_context();
// Prepare view object.
$viewobj = new view_page();
$viewobj->attemptcolumn = true;
$viewobj->markcolumn = true;
$viewobj->gradecolumn = true;
$viewobj->canreviewmine = true;
$viewobj->mygrade = 0.00;
$viewobj->feedbackcolumn = false;
$viewobj->attempts = $attempt;
$viewobj->attemptobjs[] = new quiz_attempt($attempt->get_attempt(),
$quiz, $attempt->get_cm(), $attempt->get_course(), false);
$viewobj->accessmanager = new access_manager($attempt->get_quizobj(), $timenow,
has_capability('mod/quiz:ignoretimelimits', $context, null, false));
// Render summary previous attempts table.
$renderer = $PAGE->get_renderer('mod_quiz');
$table = $renderer->view_table($quiz, $context, $viewobj);
$captionpattern = '/<caption\b[^>]*>' . get_string('summaryofattempts', 'quiz') . '<\/caption>/';
// Check caption existed.
$this->assertMatchesRegularExpression($captionpattern, $table);
// Check column attempt.
$this->assertMatchesRegularExpression('/<td\b[^>]*>' . $attempt->get_attempt_number() . '<\/td>/', $table);
// Check column state.
$this->assertMatchesRegularExpression('/<td\b[^>]*>' . ucfirst($attempt->get_state()) . '.+?<\/td>/', $table);
// Check column marks.
$this->assertMatchesRegularExpression('/<td\b[^>]* c2.+?' .
quiz_format_grade($quiz, $attempt->get_sum_marks()) .'<\/td>/', $table);
// Check column grades.
$this->assertMatchesRegularExpression('/<td\b[^>]* c2.+?0\.00<\/td>/', $table);
// Check column review.
$this->assertMatchesRegularExpression('/<td\b[^>]*>.+?Review<\/a><\/td>/', $table);
}
}

View File

@ -144,15 +144,19 @@ Feature: Attempt a quiz
And I should see "Once you submit your answers, you wont be able to change them." in the "Submit all your answers and finish?" "dialogue"
And I should see "Questions without a response: 2" in the "Submit all your answers and finish?" "dialogue"
And I click on "Submit all and finish" "button" in the "Submit all your answers and finish?" "dialogue"
And I should see "0.00 out of 100.00" in the "Grade" "table_row"
And I should see "First question"
And I should see "Second question"
And I follow "Show one page at a time"
And I should see "0.00 out of 100.00" in the "Grade" "table_row"
And I should see "First question"
And I should not see "Second question"
And I follow "Next page"
And "Grade" "table_row" should not exist
And I should see "Second question"
And I should not see "First question"
And I follow "Previous page"
And I should see "0.00 out of 100.00" in the "Grade" "table_row"
And I should see "First question"
And I should not see "Second question"

View File

@ -40,7 +40,7 @@ Feature: Preview a quiz as a teacher
Then I should see "25.00 out of 100.00"
And I should see "v1 (latest)" in the "Question 1" "question"
And I follow "Finish review"
And "Review" "link" in the "Preview" "table_row" should be visible
And "Review" "link" in the "Attempt 1" "list_item" should be visible
@javascript
Scenario: Review the quiz attempt with custom decimal separator
@ -53,7 +53,7 @@ Feature: Preview a quiz as a teacher
And I should see "25#00 out of 100#00"
And I should see "Mark 1#00 out of 1#00"
And I follow "Finish review"
And "Review" "link" in the "Preview" "table_row" should be visible
And "Review" "link" in the "Attempt 1" "list_item" should be visible
Scenario: Preview the quiz
Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher"

View File

@ -51,5 +51,7 @@ Feature: Several attempts in a quiz
Scenario: The redo question buttons are visible after 2 attempts are preset for student1.
Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student1"
Then "Re-attempt quiz" "button" should exist
And "1" row "Marks / 2.00" column of "quizattemptsummary" table should contain "1.00"
And "2" row "Marks / 2.00" column of "quizattemptsummary" table should contain "0.00"
And I should see "Finished" in the "Attempt 1" "list_item"
And I should see "1.00/2.00" in the "Attempt 1" "list_item"
And I should see "Finished" in the "Attempt 2" "list_item"
And I should see "0.00/2.00" in the "Attempt 2" "list_item"

View File

@ -11,6 +11,11 @@ This file describes API changes in the quiz code.
unanswered questions.
* quiz_settings::no_review_message now takes a new argument $attemptsubmittime for the time when the quiz attempt was
submitted. It is strongly recommended that you always pass that.
* In the renderer, related to rendering the review page, review_summary_table and filter_review_summary_table have been
replaced by review_attempt_summary and filter_review_attempt_summary. This is to support changing the $summarydata argument
the review_page() method to an instance of a new templateable class attempt_summary_information for displaying this.
The $summarydata argument of review_question_page has also been changed to an attempt_summary_information.
* In the renderer, the view_table has been deprecated. Please use the list_of_attempts renderable instead.
=== 4.3 ===

View File

@ -24,6 +24,7 @@
*/
use mod_quiz\access_manager;
use mod_quiz\output\list_of_attempts;
use mod_quiz\output\renderer;
use mod_quiz\output\view_page;
use mod_quiz\quiz_attempt;
@ -100,6 +101,10 @@ $viewobj->attemptobjs = [];
foreach ($attempts as $attempt) {
$viewobj->attemptobjs[] = new quiz_attempt($attempt, $quiz, $cm, $course, false);
}
$viewobj->attemptslist = new list_of_attempts($timenow);
foreach (array_reverse($viewobj->attemptobjs) as $attemptobj) {
$viewobj->attemptslist->add_attempt($attemptobj);
}
// Work out the final grade, checking whether it was overridden in the gradebook.
if (!$canpreview) {