From 0b7805199f7a2d4257231b1e7bf132cc2b3bbbe9 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Mon, 18 Mar 2024 17:04:45 +0000 Subject: [PATCH] MDL-74610 quiz: add extra grades to web services the mobile app uses --- mod/quiz/classes/external.php | 38 +++++++- mod/quiz/tests/external/external_test.php | 109 +++++++++++++++++++--- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/mod/quiz/classes/external.php b/mod/quiz/classes/external.php index e0120d8ea5d..dd4a95eca80 100644 --- a/mod/quiz/classes/external.php +++ b/mod/quiz/classes/external.php @@ -412,6 +412,9 @@ class mod_quiz_external extends external_api { // Update quiz with override information. $quiz = quiz_update_effective_access($quiz, $params['userid']); $attempts = quiz_get_user_attempts($quiz->id, $user->id, $params['status'], $params['includepreviews']); + $quizobj = new quiz_settings($quiz, $cm, $course); + $gradeitemmarks = $quizobj->get_grade_calculator()->compute_grade_item_totals_for_attempts( + array_column($attempts, 'uniqueid')); $attemptresponse = []; foreach ($attempts as $attempt) { $reviewoptions = quiz_get_review_options($quiz, $attempt, $context); @@ -419,6 +422,15 @@ class mod_quiz_external extends external_api { ($reviewoptions->marks < question_display_options::MARK_AND_MAX || $attempt->state != quiz_attempt::FINISHED)) { // Blank the mark if the teacher does not allow it. $attempt->sumgrades = null; + } else if (isset($gradeitemmarks[$attempt->uniqueid])) { + $attempt->gradeitemmarks = []; + foreach ($gradeitemmarks[$attempt->uniqueid] as $gradeitem) { + $attempt->gradeitemmarks[] = [ + 'name' => \core_external\util::format_string($gradeitem->name, $context), + 'grade' => $gradeitem->grade, + 'maxgrade' => $gradeitem->maxgrade, + ]; + } } $attemptresponse[] = $attempt; } @@ -459,6 +471,13 @@ class mod_quiz_external extends external_api { 'timecheckstate' => new external_value(PARAM_INT, 'Next time quiz cron should check attempt for state changes. NULL means never check.', VALUE_OPTIONAL), 'sumgrades' => new external_value(PARAM_FLOAT, 'Total marks for this attempt.', VALUE_OPTIONAL), + 'gradeitemmarks' => new external_multiple_structure( + new external_single_structure([ + 'name' => new external_value(PARAM_RAW, 'The name of this grade item.'), + 'grade' => new external_value(PARAM_FLOAT, 'The grade this attempt earned for this item.'), + 'maxgrade' => new external_value(PARAM_FLOAT, 'The total this grade is out of.'), + ], 'The grade for each grade item.'), + 'If the quiz has additional grades set up, the mark for each grade for this attempt.', VALUE_OPTIONAL), 'gradednotificationsenttime' => new external_value(PARAM_INT, 'Time when the student was notified that manual grading of their attempt was complete.', VALUE_OPTIONAL), ] @@ -1455,7 +1474,6 @@ class mod_quiz_external extends external_api { * @since Moodle 3.1 */ public static function get_attempt_review($attemptid, $page = -1) { - global $PAGE; $warnings = []; @@ -1465,7 +1483,7 @@ class mod_quiz_external extends external_api { ]; $params = self::validate_parameters(self::get_attempt_review_parameters(), $params); - list($attemptobj, $displayoptions) = self::validate_attempt_review($params); + [$attemptobj, $displayoptions] = self::validate_attempt_review($params); if ($params['page'] !== -1) { $page = $attemptobj->force_page_number_into_range($params['page']); @@ -1502,6 +1520,22 @@ class mod_quiz_external extends external_api { ]; } + if (!has_capability('mod/quiz:viewreports', $attemptobj->get_context()) && + ($displayoptions->marks < question_display_options::MARK_AND_MAX || + $attemptobj->get_attempt()->state != quiz_attempt::FINISHED)) { + // Blank the mark if the teacher does not allow it. + $result['attempt']->sumgrades = null; + } else { + $result['attempt']->gradeitemmarks = []; + foreach ($attemptobj->get_grade_item_totals() as $gradeitem) { + $result['attempt']->gradeitemmarks[] = [ + 'name' => \core_external\util::format_string($gradeitem->name, $attemptobj->get_context()), + 'grade' => $gradeitem->grade, + 'maxgrade' => $gradeitem->maxgrade, + ]; + } + } + $result['grade'] = $grade; $result['warnings'] = $warnings; return $result; diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index 0822c78906f..0f871ee658d 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -84,6 +84,7 @@ class testable_mod_quiz_external extends mod_quiz_external { * @copyright 2016 Juan Leyva * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since Moodle 3.1 + * @covers \mod_quiz_external */ class external_test extends externallib_advanced_testcase { @@ -423,13 +424,10 @@ class external_test extends externallib_advanced_testcase { } - /** - * Test get_user_attempts - */ - public function test_get_user_attempts() { + public function test_get_user_attempts(): void { // Create a quiz with one attempt finished. - list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true); + [$quiz, $context, $quizobj, $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); $this->setUser($this->student); $result = mod_quiz_external::get_user_attempts($quiz->id); @@ -507,6 +505,41 @@ class external_test extends externallib_advanced_testcase { } } + public function test_get_user_attempts_with_extra_grades(): void { + global $DB; + + // Create a quiz with one attempt finished. + [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); + + // Add some extra grade items. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + $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), $readinggrade->id); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + + // Verify additional grades. + $this->assertEquals(['name' => 'Listening', 'grade' => 1, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][0]); + $this->assertEquals(['name' => 'Reading', 'grade' => 0, 'maxgrade' => 1], $result['attempts'][0]['gradeitemmarks'][1]); + + // Now change the review options, so marks are not displayed, and check the result. + $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $quiz->id]); + $result = mod_quiz_external::get_user_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertArrayNotHasKey('gradeitemmarks', $result['attempts'][0]); + } + /** * Test get_user_attempts with marks hidden */ @@ -1664,15 +1697,14 @@ class external_test extends externallib_advanced_testcase { } } - /** * Test get_attempt_review */ - public function test_get_attempt_review() { + public function test_get_attempt_review(): void { global $DB; // Create a new quiz with two questions and one attempt finished. - list($quiz, $context, $quizobj, $attempt, $attemptobj, $quba) = $this->create_quiz_with_questions(true, true); + [$quiz, , , $attempt] = $this->create_quiz_with_questions(true, true); // Add feedback to the quiz. $feedback = new \stdClass(); @@ -1703,10 +1735,55 @@ class external_test extends externallib_advanced_testcase { $this->assertEquals('gaveup', $result['questions'][1]['state']); $this->assertEquals(2, $result['questions'][1]['slot']); + // Only first page. + $result = mod_quiz_external::get_attempt_review($attempt->id, 0); + $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + + $this->assertEquals(50, $result['grade']); + $this->assertEquals(1, $result['attempt']['attempt']); + $this->assertEquals('finished', $result['attempt']['state']); + $this->assertEquals(1, $result['attempt']['sumgrades']); + $this->assertCount(1, $result['questions']); + $this->assertEquals('gradedright', $result['questions'][0]['state']); + $this->assertEquals(1, $result['questions'][0]['slot']); + $this->assertCount(1, $result['additionaldata']); $this->assertEquals('feedback', $result['additionaldata'][0]['id']); $this->assertEquals('Feedback', $result['additionaldata'][0]['title']); $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']); + } + + /** + * Test get_attempt_review + */ + public function test_get_attempt_review_with_extra_grades(): void { + global $DB; + + // Create a new quiz with two questions and one attempt finished. + $this->setUser($this->student); + [, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true, true); + + // Add some extra grade items. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + $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), $readinggrade->id); + + $result = mod_quiz_external::get_attempt_review($attempt->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + + // Two questions, one completed and correct, the other gave up. + $this->assertEquals(50, $result['grade']); + $this->assertEquals(1, $result['attempt']['attempt']); + $this->assertEquals('finished', $result['attempt']['state']); + $this->assertEquals(1, $result['attempt']['sumgrades']); + $this->assertCount(2, $result['questions']); + $this->assertEquals('gradedright', $result['questions'][0]['state']); + $this->assertEquals(1, $result['questions'][0]['slot']); + $this->assertEquals('gaveup', $result['questions'][1]['state']); + $this->assertEquals(2, $result['questions'][1]['slot']); // Only first page. $result = mod_quiz_external::get_attempt_review($attempt->id, 0); @@ -1720,11 +1797,19 @@ class external_test extends externallib_advanced_testcase { $this->assertEquals('gradedright', $result['questions'][0]['state']); $this->assertEquals(1, $result['questions'][0]['slot']); - $this->assertCount(1, $result['additionaldata']); - $this->assertEquals('feedback', $result['additionaldata'][0]['id']); - $this->assertEquals('Feedback', $result['additionaldata'][0]['title']); - $this->assertEquals('Feedback text 1', $result['additionaldata'][0]['content']); + // Verify additional grades. + $this->assertEquals(['name' => 'Listening', 'grade' => 1, 'maxgrade' => 1], $result['attempt']['gradeitemmarks'][0]); + $this->assertEquals(['name' => 'Reading', 'grade' => 0, 'maxgrade' => 1], $result['attempt']['gradeitemmarks'][1]); + // Now change the review options, so marks are not displayed, and check the result. + $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $attemptobj->get_quizid()]); + $result = mod_quiz_external::get_attempt_review($attempt->id, 0); + $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + + $this->assertEquals(1, $result['attempt']['attempt']); + $this->assertEquals('finished', $result['attempt']['state']); + $this->assertNull($result['attempt']['sumgrades']); + $this->assertArrayNotHasKey('gradeitemmarks', $result['attempt']); } /**