MDL-74610 quiz: multiple grades - display grades to student

This commit is contained in:
Tim Hunt 2024-02-16 14:34:07 +00:00
parent ba40067746
commit b4467adf52
5 changed files with 218 additions and 40 deletions

View File

@ -21,7 +21,9 @@ use core\di;
use core\hook;
use mod_quiz\event\quiz_grade_updated;
use mod_quiz\hook\structure_modified;
use mod_quiz\output\grades\grade_out_of;
use question_engine_data_mapper;
use question_usage_by_activity;
use stdClass;
/**
@ -43,6 +45,21 @@ class grade_calculator {
/** @var quiz_settings the quiz for which this instance computes grades. */
protected $quizobj;
/**
* @var stdClass[]|null quiz_grade_items for this quiz indexed by id, sorted by sortorder.
*
* Lazy-loaded when needed. See {@see ensure_grade_items_loaded()}.
*/
protected ?array $gradeitems = null;
/**
* @var ?stdClass[]|null quiz_slot for this quiz. Only ->slot and ->quizgradeitemid fields are used.
*
* This is either set by another class that already has the data, using {@see set_slots()}
* or it is lazy-loaded when needed. See {@see ensure_slots_loaded()}.
*/
protected ?array $slots = null;
/**
* Constructor. Recommended way to get an instance is $quizobj->get_grade_calculator();
*
@ -448,4 +465,94 @@ class grade_calculator {
$transaction->allow_commit();
}
/**
* Ensure the {@see $gradeitems} field is ready to use.
*/
protected function ensure_grade_items_loaded(): void {
global $DB;
if ($this->gradeitems !== null) {
return; // Already done.
}
$this->gradeitems = $DB->get_records('quiz_grade_items',
['quizid' => $this->quizobj->get_quizid()], 'sortorder');
}
/**
* Lets other code pass in the slot information, so it does note have to be re-loaded from the DB.
*
* @param stdClass[] $slots the data from quiz_slots. The only required fields are ->slot and ->quizgradeitemid.
*/
public function set_slots(array $slots): void {
global $CFG;
$this->slots = $slots;
if ($CFG->debugdeveloper) {
foreach ($slots as $slot) {
if (!property_exists($slot, 'slot') || !property_exists($slot, 'quizgradeitemid')) {
debugging('Slot data passed to grade_calculator::set_slots ' .
'must have at least ->slot and ->quizgradeitemid set.', DEBUG_DEVELOPER);
break; // Only necessary to say this once.
}
}
}
}
/**
* Ensure the {@see $gradeitems} field is ready to use.
*/
protected function ensure_slots_loaded(): void {
global $DB;
if ($this->slots !== null) {
return; // Already done.
}
$this->slots = $DB->get_records('quiz_slots', ['quizid' => $this->quizobj->get_quizid()],
'slot', 'slot, id, quizgradeitemid');
}
/**
* Compute the grade and maximum grade for each grade item, for this attempt.
*
* @param question_usage_by_activity $quba usage for the quiz attempt we want to calculate the grades of.
* @return grade_out_of[] the grade for each item where the total grade is not zero.
* ->name will be set to the grade item name. Must be output through {@see format_string()}.
*/
public function compute_grade_item_totals(question_usage_by_activity $quba): array {
$this->ensure_grade_items_loaded();
if (empty($this->gradeitems)) {
// No extra grade items.
return [];
}
$this->ensure_slots_loaded();
// Prepare a place to store the results for each grade-item.
$grades = [];
foreach ($this->gradeitems as $gradeitem) {
$grades[$gradeitem->id] = new grade_out_of(
$this->quizobj->get_quiz(), 0, 0, name: $gradeitem->name);
}
// Add up the scores.
foreach ($this->slots as $slot) {
if (!$slot->quizgradeitemid) {
continue;
}
$grades[$slot->quizgradeitemid]->grade += $quba->get_question_mark($slot->slot);
$grades[$slot->quizgradeitemid]->maxgrade += $quba->get_question_max_mark($slot->slot);
}
// Remove any grade items where the total is 0.
foreach ($grades as $gradeitemid => $grade) {
if ($grade->maxgrade < self::ALMOST_ZERO) {
unset($grades[$gradeitemid]);
}
}
return $grades;
}
}

View File

@ -19,6 +19,7 @@ namespace mod_quiz\output;
use action_link;
use core\output\named_templatable;
use html_writer;
use mod_quiz\grade_calculator;
use mod_quiz\output\grades\grade_out_of;
use mod_quiz\quiz_attempt;
use moodle_url;
@ -166,29 +167,7 @@ class attempt_summary_information implements renderable, named_templatable {
}
// 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) {
$summary->add_item('marks', get_string('marks', 'quiz'),
new grade_out_of($quiz, $attempt->sumgrades, $quiz->sumgrades, grade_out_of::SHORT));
}
// Now the scaled grade.
$summary->add_item('grade', get_string('gradenoun'),
new grade_out_of($quiz, $grade, $quiz->grade,
$quiz->grade == 100 ? grade_out_of::NORMAL : grade_out_of::WITH_PERCENT));
}
}
$grade = $summary->add_attempt_grades_if_appropriate($attemptobj, $options);
// Any additional summary data from the behaviour.
foreach ($attemptobj->get_additional_summary_data($options) as $shortname => $data) {
@ -204,6 +183,58 @@ class attempt_summary_information implements renderable, named_templatable {
return $summary;
}
/**
* Add the grade information to this summary information.
*
* This is a helper used by {@see create_for_attempt()}.
*
* @param quiz_attempt $attemptobj the attempt to summarise.
* @param display_options $options options for what can be seen.
* @return float|null the overall attempt grade, if it exists, else null. Raw value, not formatted.
*/
public function add_attempt_grades_if_appropriate(
quiz_attempt $attemptobj,
display_options $options,
): ?float {
$quiz = $attemptobj->get_quiz();
$grade = quiz_rescale_grade($attemptobj->get_sum_marks(), $quiz, false);
if ($options->marks < question_display_options::MARK_AND_MAX) {
// User can't see grades.
return $grade;
}
if (!quiz_has_grades($quiz) || $attemptobj->get_state() != quiz_attempt::FINISHED) {
// No grades to show.
return $grade;
}
if (is_null($grade)) {
// Attempt needs ot be graded.
$this->add_item('grade', get_string('gradenoun'), quiz_format_grade($quiz, $grade));
return $grade;
}
// Grades for extra grade items, if any.
foreach ($attemptobj->get_grade_item_totals() as $gradeitemid => $gradeoutof) {
$this->add_item('marks' . $gradeitemid, format_string($gradeoutof->name), $gradeoutof);
}
// Show raw marks only if they are different from the grade.
if ($quiz->grade != $quiz->sumgrades) {
$this->add_item('marks', get_string('marks', 'quiz'),
new grade_out_of($quiz, $attemptobj->get_sum_marks(), $quiz->sumgrades, style: grade_out_of::SHORT));
}
// Now the scaled grade.
$this->add_item('grade', get_string('gradenoun'),
new grade_out_of($quiz, $grade, $quiz->grade,
style: abs($quiz->grade - 100) < grade_calculator::ALMOST_ZERO ?
grade_out_of::NORMAL : grade_out_of::WITH_PERCENT));
return $grade;
}
public function export_for_template(renderer_base $output): array {
$templatecontext = [

View File

@ -44,7 +44,8 @@ class grade_out_of implements renderable {
* @param stdClass $quiz Quiz settings.
* @param float $grade the mark to show.
* @param float $maxgrade the total to show it out of.
* @param string $style which format to use, grade_out_of::NORMAL, ::SHORT or ::WITH_PERCENT.
* @param string|null $name optional, a name for what this grade is.
* @param string $style which format to use, grade_out_of::NORMAL (default), ::SHORT or ::WITH_PERCENT.
*/
public function __construct(
@ -57,6 +58,9 @@ class grade_out_of implements renderable {
/** @var float the total the grade is out of. */
public float $maxgrade,
/** @var string|null optional, a name for what this grade is. Must be output via format_string. */
public readonly ?string $name = null,
/** @var string The display style, one of the consts above. */
public readonly string $style = self::NORMAL,

View File

@ -26,6 +26,7 @@ use core\hook;
use Exception;
use html_writer;
use mod_quiz\hook\attempt_state_changed;
use mod_quiz\output\grades\grade_out_of;
use mod_quiz\output\links_to_other_attempts;
use mod_quiz\output\renderer;
use mod_quiz\question\bank\qbank_helper;
@ -77,17 +78,20 @@ class quiz_attempt {
protected $quba;
/**
* @var array of slot information. These objects contain ->slot (int),
* ->requireprevious (bool), ->questionids (int) the original question for random questions,
* @var array of slot information. These objects contain ->id (int), ->slot (int),
* ->requireprevious (bool), ->displaynumber (string) and quizgradeitemid (int) from the DB.
* They do not contain page - get that from {@see get_question_page()} -
* or maxmark - get that from $this->quba. It is augmented with
* ->firstinsection (bool), ->section (stdClass from $this->sections).
* This does not contain page - get that from {@see get_question_page()} -
* or maxmark - get that from $this->quba.
*/
protected $slots;
/** @var array of quiz_sections rows, with a ->lastslot field added. */
protected $sections;
/** @var grade_calculator instance for this quiz. */
protected $gradecalculator;
/** @var array page no => array of slot numbers on the page in order. */
protected $pagelayout;
@ -114,9 +118,11 @@ class quiz_attempt {
public function __construct($attempt, $quiz, $cm, $course, $loadquestions = true) {
$this->attempt = $attempt;
$this->quizobj = new quiz_settings($quiz, $cm, $course);
$this->gradecalculator = $this->quizobj->get_grade_calculator();
if ($loadquestions) {
$this->load_questions();
$this->gradecalculator->set_slots($this->slots);
}
}
@ -181,8 +187,8 @@ class quiz_attempt {
}
$this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid);
$this->slots = $DB->get_records('quiz_slots',
['quizid' => $this->get_quizid()], 'slot', 'slot, id, requireprevious, displaynumber');
$this->slots = $DB->get_records('quiz_slots', ['quizid' => $this->get_quizid()],
'slot', 'slot, id, requireprevious, displaynumber, quizgradeitemid');
$this->sections = array_values($DB->get_records('quiz_sections',
['quizid' => $this->get_quizid()], 'firstslot'));
@ -478,6 +484,16 @@ class quiz_attempt {
return $this->attempt->currentpage;
}
/**
* Compute the grade and maximum grade for each grade item, for this attempt.
*
* @return grade_out_of[] the grade for each item where the total grade is not zero.
* ->name will be set to the grade item name. Must be output through {@see format_string()}.
*/
public function get_grade_item_totals(): array {
return $this->gradecalculator->compute_grade_item_totals($this->quba);
}
/**
* Get the total number of marks that the user had scored on all the questions.
*

View File

@ -18,6 +18,7 @@ namespace mod_quiz;
use core_question\local\bank\question_version_status;
use mod_quiz\output\view_page;
use mod_quiz_generator;
use question_engine;
defined('MOODLE_INTERNAL') || die();
@ -32,6 +33,7 @@ require_once($CFG->dirroot . '/mod/quiz/locallib.php');
* @category test
* @copyright 2014 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \mod_quiz\quiz_attempt
*/
class attempt_test extends \advanced_testcase {
@ -51,14 +53,10 @@ class attempt_test extends \advanced_testcase {
// Make a quiz.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance(['course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout, 'navmethod' => $navmethod]);
'grade' => 100.0, 'sumgrades' => 2, 'navmethod' => $navmethod]);
$quizobj = quiz_settings::create($quiz->id, $user->id);
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
@ -73,11 +71,7 @@ class attempt_test extends \advanced_testcase {
quiz_add_quiz_question($question->id, $quiz, $page);
}
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
$attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null, false, [], [], $user->id);
return quiz_attempt::create($attempt->id);
}
@ -507,4 +501,30 @@ 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);
}
public function test_get_grade_item_totals(): void {
$attemptobj = $this->create_quiz_and_attempt_with_layout('1,2,3,0');
/** @var mod_quiz_generator $quizgenerator */
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
// Set up some section grades.
$listeninggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Listening']);
$readinggrade = $quizgenerator->create_grade_item(['quizid' => $attemptobj->get_quizid(), 'name' => 'Reading']);
$structure = $attemptobj->get_quizobj()->get_structure();
$structure->update_slot_grade_item($structure->get_slot_by_number(1), $listeninggrade->id);
$structure->update_slot_grade_item($structure->get_slot_by_number(2), $listeninggrade->id);
$structure->update_slot_grade_item($structure->get_slot_by_number(3), $readinggrade->id);
// Reload the attempt and verify.
$attemptobj = quiz_attempt::create($attemptobj->get_attemptid());
$grades = $attemptobj->get_grade_item_totals();
// All grades zero because student has not done the quiz yet, but this is a sufficent test.
$this->assertEquals('Listening', $grades[$listeninggrade->id]->name);
$this->assertEquals(0, $grades[$listeninggrade->id]->grade);
$this->assertEquals(2, $grades[$listeninggrade->id]->maxgrade);
$this->assertEquals('Reading', $grades[$readinggrade->id]->name);
$this->assertEquals(0, $grades[$readinggrade->id]->grade);
$this->assertEquals(1, $grades[$readinggrade->id]->maxgrade);
}
}