diff --git a/mod/quiz/classes/grade_calculator.php b/mod/quiz/classes/grade_calculator.php index a67de2bd8e7..d24267bf2ea 100644 --- a/mod/quiz/classes/grade_calculator.php +++ b/mod/quiz/classes/grade_calculator.php @@ -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; + } } diff --git a/mod/quiz/classes/output/attempt_summary_information.php b/mod/quiz/classes/output/attempt_summary_information.php index 5d807ad9039..f271005ddb2 100644 --- a/mod/quiz/classes/output/attempt_summary_information.php +++ b/mod/quiz/classes/output/attempt_summary_information.php @@ -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 = [ diff --git a/mod/quiz/classes/output/grades/grade_out_of.php b/mod/quiz/classes/output/grades/grade_out_of.php index 332554440c0..8181e50764a 100644 --- a/mod/quiz/classes/output/grades/grade_out_of.php +++ b/mod/quiz/classes/output/grades/grade_out_of.php @@ -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, diff --git a/mod/quiz/classes/quiz_attempt.php b/mod/quiz/classes/quiz_attempt.php index 99b9f2ec4c5..034c3572d20 100644 --- a/mod/quiz/classes/quiz_attempt.php +++ b/mod/quiz/classes/quiz_attempt.php @@ -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. * diff --git a/mod/quiz/tests/attempt_test.php b/mod/quiz/tests/attempt_test.php index 154b288f91f..5980f54163b 100644 --- a/mod/quiz/tests/attempt_test.php +++ b/mod/quiz/tests/attempt_test.php @@ -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); + } }