mirror of
https://github.com/moodle/moodle.git
synced 2025-04-15 21:45:37 +02:00
MDL-74610 quiz: multiple grades - display grades to student
This commit is contained in:
parent
ba40067746
commit
b4467adf52
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 = [
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user