From 0fd4f29a1980ed26474c5c1df016ef4c8f16386d Mon Sep 17 00:00:00 2001 From: Mark Johnson Date: Thu, 29 Feb 2024 12:17:54 +0000 Subject: [PATCH] MDL-68806 quiz: Retain backwards compatibility in external functions To ensure backwards compatibility, particularly with the mobile app, the mod_quiz_get_user_attempts and mod_quiz_get_attempt_review functions will continue to return only the existing 'inprogress', 'overdue', 'finished' and 'abandoned' states for quiz attempts. 'submitted' attempts will be reported as 'finished', while 'notstarted' attempts will be reported as 'inprogress'. These functions are now deprecated. The new functions mod_quiz_get_user_quiz_attempts and mod_quiz_get_quiz_attempt_review will replace them. They have exactly the same code as the original functions had before these changes, so will return all attempts in their true states. --- mod/quiz/classes/external.php | 341 +++++++++++++- mod/quiz/db/services.php | 24 +- mod/quiz/locallib.php | 7 + mod/quiz/tests/external/external_test.php | 534 +++++++++++++++++++++- mod/quiz/upgrade.txt | 5 +- version.php | 2 +- 6 files changed, 900 insertions(+), 13 deletions(-) diff --git a/mod/quiz/classes/external.php b/mod/quiz/classes/external.php index 3200ccefb448d..53b7ad1a89bdd 100644 --- a/mod/quiz/classes/external.php +++ b/mod/quiz/classes/external.php @@ -362,7 +362,15 @@ public static function view_quiz_returns() { * * @return external_function_parameters * @since Moodle 3.1 + * @deprecated Since Moodle 4.5 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts_parameters', + since: '4.5', + reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_user_attempts_parameters() { return new external_function_parameters ( [ @@ -378,15 +386,27 @@ public static function get_user_attempts_parameters() { /** * Return a list of attempts for the given quiz and user. * + * For backwards compatibility, SUBMITTED attempts will be treated as FINISHED with marks hidden, and NOT_STARTED will be + * treated as IN_PROGRESS. To return all real states, call get_user_quiz_attempts instead. + * * @param int $quizid quiz instance id * @param int $userid user id * @param string $status quiz status: all, finished or unfinished * @param bool $includepreviews whether to include previews or not * @return array of warnings and the list of attempts * @since Moodle 3.1 + * @deprecated Since Moodle 4.5 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts', + since: '4.5', + reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_user_attempts($quizid, $userid = 0, $status = 'finished', $includepreviews = false) { global $USER; + \core\deprecation::emit_deprecation_if_present(__METHOD__); $warnings = []; @@ -426,8 +446,16 @@ public static function get_user_attempts($quizid, $userid = 0, $status = 'finish $attemptresponse = []; foreach ($attempts as $attempt) { $reviewoptions = quiz_get_review_options($quiz, $attempt, $context); - if (!has_capability('mod/quiz:viewreports', $context) && - ($reviewoptions->marks < question_display_options::MARK_AND_MAX || $attempt->state != quiz_attempt::FINISHED)) { + if ( + $attempt->state == quiz_attempt::SUBMITTED || + ( + !has_capability('mod/quiz:viewreports', $context) && + ( + $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])) { @@ -440,6 +468,12 @@ public static function get_user_attempts($quizid, $userid = 0, $status = 'finish ]; } } + if ($attempt->state == quiz_attempt::SUBMITTED) { + $attempt->state = quiz_attempt::FINISHED; // For backwards-compatibility. + } + if ($attempt->state == quiz_attempt::NOT_STARTED) { + $attempt->state = quiz_attempt::IN_PROGRESS; // For backwards-compatibility. + } $attemptresponse[] = $attempt; } $result = []; @@ -497,16 +531,147 @@ private static function attempt_structure() { * * @return external_single_structure * @since Moodle 3.1 + * @deprecated Since Moodle 4.5 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts_returns', + since: '4.5', + reason: 'The old API for fetching attempts doesn\'t return true states for NOT_STARTED and SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_user_attempts_returns() { + $attemptstructure = self::attempt_structure(); + $attemptstructure->keys['state']->desc .= " For backwards compatibility, attempts in 'submitted' state will return 'finished' " . + "and attempts in 'notstarted' state will return 'inprogress'. To get attempts with all real states, call " . + "get_user_quiz_attempts() instead."; return new external_single_structure( [ - 'attempts' => new external_multiple_structure(self::attempt_structure()), + 'attempts' => new external_multiple_structure($attemptstructure), 'warnings' => new external_warnings(), ] ); } + /** + * Mark get_user_attempts as deprecated. + * + * @return bool + */ + public static function get_user_attempts_is_deprecated(): bool { + return true; + } + + /** + * Describes the parameters for get_user_quiz_attempts. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function get_user_quiz_attempts_parameters(): external_function_parameters { + return new external_function_parameters ( + [ + 'quizid' => new external_value(PARAM_INT, 'quiz instance id'), + 'userid' => new external_value(PARAM_INT, 'user id, empty for current user', VALUE_DEFAULT, 0), + 'status' => new external_value(PARAM_ALPHA, 'quiz status: all, finished or unfinished', VALUE_DEFAULT, 'finished'), + 'includepreviews' => new external_value(PARAM_BOOL, 'whether to include previews or not', VALUE_DEFAULT, false), + ], + ); + } + + /** + * Return a list of attempts for the given quiz and user. + * + * @param int $quizid quiz instance id + * @param int $userid user id + * @param string $status quiz status: all, finished or unfinished + * @param bool $includepreviews whether to include previews or not + * @return array of warnings and the list of attempts + * @since Moodle 4.5 + */ + public static function get_user_quiz_attempts( + int $quizid, + int $userid = 0, + string $status = 'finished', + bool $includepreviews = false + ): array { + global $USER; + + $warnings = []; + + $params = [ + 'quizid' => $quizid, + 'userid' => $userid, + 'status' => $status, + 'includepreviews' => $includepreviews, + ]; + $params = self::validate_parameters(self::get_user_quiz_attempts_parameters(), $params); + + [$quiz, $course, $cm, $context] = self::validate_quiz($params['quizid']); + + if (!in_array($params['status'], ['all', 'finished', 'unfinished'])) { + throw new invalid_parameter_exception('Invalid status value'); + } + + // Default value for userid. + if (empty($params['userid'])) { + $params['userid'] = $USER->id; + } + + $user = core_user::get_user($params['userid'], '*', MUST_EXIST); + core_user::require_active_user($user); + + // Extra checks so only users with permissions can view other users attempts. + if ($USER->id != $user->id) { + require_capability('mod/quiz:viewreports', $context); + } + + // 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); + if (!has_capability('mod/quiz:viewreports', $context) && + ($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; + } + $result = []; + $result['attempts'] = $attemptresponse; + $result['warnings'] = $warnings; + return $result; + } + + /** + * Describes the get_user_attempts return value. + * + * @return external_single_structure + * @since Moodle 4.5 + */ + public static function get_user_quiz_attempts_returns(): external_single_structure { + return new external_single_structure( + [ + 'attempts' => new external_multiple_structure(self::attempt_structure()), + 'warnings' => new external_warnings(), + ], + ); + } + /** * Describes the parameters for get_user_best_grade. * @@ -1462,7 +1627,15 @@ protected static function validate_attempt_review($params) { * * @return external_function_parameters * @since Moodle 3.1 + * @deprecated Since Moodle 4.5 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_quiz_attempt_review_parameters', + since: '4.5', + reason: 'The old API for fetching attempt reviews doesn\'t return the true state for SUBMITTED attempts', + mdl: 'MDL-68806' + )] public static function get_attempt_review_parameters() { return new external_function_parameters ( [ @@ -1476,12 +1649,24 @@ public static function get_attempt_review_parameters() { /** * Returns review information for the given finished attempt, can be used by users or teachers. * + * For backwards compatibility, SUBMITTED attempts will be treated as FINISHED, with no grades. + * * @param int $attemptid attempt id * @param int $page page number, empty for all the questions in all the pages * @return array of warnings and the attempt data, feedback and questions * @since Moodle 3.1 + * @deprecated Since Moodle 4.5 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_quiz_attempt_review', + since: '4.5', + reason: 'The old API for fetching attempt reviews doesn\'t return the true state for SUBMITTED attempts', + mdl: 'MDL-68806', + )] public static function get_attempt_review($attemptid, $page = -1) { + global $PAGE; + \core\deprecation::emit_deprecation_if_present(__METHOD__); $warnings = []; @@ -1503,6 +1688,152 @@ public static function get_attempt_review($attemptid, $page = -1) { // trigger a debugging message. $attemptobj->preload_all_attempt_step_users(); + // Prepare the output. + $result = []; + $result['attempt'] = $attemptobj->get_attempt(); + if ($result['attempt']->state == quiz_attempt::SUBMITTED) { + $result['attempt']->state = quiz_attempt::FINISHED; // For backwards compatibility. + } + $result['questions'] = self::get_attempt_questions_data($attemptobj, true, $page, true); + + $result['additionaldata'] = []; + // Summary data (from behaviours). + $summarydata = $attemptobj->get_additional_summary_data($displayoptions); + foreach ($summarydata as $key => $data) { + // This text does not need formatting (no need for external_format_[string|text]). + $result['additionaldata'][] = [ + 'id' => $key, + 'title' => $data['title'], $attemptobj->get_quizobj()->get_context()->id, + 'content' => $data['content'], + ]; + } + + // Feedback if there is any, and the user is allowed to see it now. + $grade = quiz_rescale_grade($attemptobj->get_attempt()->sumgrades, $attemptobj->get_quiz(), false); + + $feedback = $attemptobj->get_overall_feedback($grade); + if ($displayoptions->overallfeedback && $feedback) { + $result['additionaldata'][] = [ + 'id' => 'feedback', + 'title' => get_string('feedback', 'quiz'), + 'content' => $feedback, + ]; + } + + 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; + } + + /** + * Describes the get_attempt_review return value. + * + * @return external_single_structure + * @since Moodle 3.1 + * @deprecated Since Moodle 4.5 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) + */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_quiz_attempt_review_returns', + since: '4.5', + reason: 'The old API for fetching attempt reviews doesn\'t return the true state for SUBMITTED attempts', + mdl: 'MDL-68806', + )] + public static function get_attempt_review_returns() { + $attemptstructure = self::attempt_structure(); + $attemptstructure->keys['state']->desc .= " For backwards compatibility, attempts in 'submitted' state will return " . + "'finished'. To get attempts with real 'submitted' states, call get_quiz_attempt_review() instead."; + return new external_single_structure( + [ + 'grade' => new external_value(PARAM_RAW, 'grade for the quiz (or empty or "notyetgraded")'), + 'attempt' => $attemptstructure, + 'additionaldata' => new external_multiple_structure( + new external_single_structure( + [ + 'id' => new external_value(PARAM_ALPHANUMEXT, 'id of the data'), + 'title' => new external_value(PARAM_TEXT, 'data title'), + 'content' => new external_value(PARAM_RAW, 'data content'), + ] + ) + ), + 'questions' => new external_multiple_structure(self::question_structure()), + 'warnings' => new external_warnings(), + ] + ); + } + + /** + * Mark get_attempt_review deprecated. + * + * @return bool + */ + public static function get_attempt_review_is_deprecated(): bool { + return true; + } + + /** + * Describes the parameters for get_quiz_attempt_review. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function get_quiz_attempt_review_parameters(): external_function_parameters { + return new external_function_parameters ( + [ + 'attemptid' => new external_value(PARAM_INT, 'attempt id'), + 'page' => new external_value(PARAM_INT, 'page number, empty for all the questions in all the pages', + VALUE_DEFAULT, -1), + ] + ); + } + + /** + * Returns review information for the given finished attempt, can be used by users or teachers. + * + * For backwards compatibility, SUBMITTED attempts will be treated as FINISHED, with no grades. + * + * @param int $attemptid attempt id + * @param int $page page number, empty for all the questions in all the pages + * @return array of warnings and the attempt data, feedback and questions + * @since Moodle 4.5 + */ + public static function get_quiz_attempt_review(int $attemptid, int $page = -1): array { + $warnings = []; + + $params = [ + 'attemptid' => $attemptid, + 'page' => $page, + ]; + $params = self::validate_parameters(self::get_quiz_attempt_review_parameters(), $params); + + [$attemptobj, $displayoptions] = self::validate_attempt_review($params); + + if ($params['page'] !== -1) { + $page = $attemptobj->force_page_number_into_range($params['page']); + } else { + $page = 'all'; + } + + // Make sure all users associated to the attempt steps are loaded. Otherwise, this will + // trigger a debugging message. + $attemptobj->preload_all_attempt_step_users(); + // Prepare the output. $result = []; $result['attempt'] = $attemptobj->get_attempt(); @@ -1557,9 +1888,9 @@ public static function get_attempt_review($attemptid, $page = -1) { * Describes the get_attempt_review return value. * * @return external_single_structure - * @since Moodle 3.1 + * @since Moodle 4.5 */ - public static function get_attempt_review_returns() { + public static function get_quiz_attempt_review_returns(): external_single_structure { return new external_single_structure( [ 'grade' => new external_value(PARAM_RAW, 'grade for the quiz (or empty or "notyetgraded")'), diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index 568570b9e0c8a..391db91f3aba7 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -50,12 +50,22 @@ 'mod_quiz_get_user_attempts' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_attempts', - 'description' => 'Return a list of attempts for the given quiz and user.', + 'description' => 'Return a list of attempts for the given quiz and user. ' . + '(Deprecated in favour of mod_quiz_get_user_quiz_attempts).', 'type' => 'read', 'capabilities' => 'mod/quiz:view', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], + 'mod_quiz_get_user_quiz_attempts' => [ + 'classname' => 'mod_quiz_external', + 'methodname' => 'get_user_quiz_attempts', + 'description' => 'Return a list of attempts for the given quiz and user.', + 'type' => 'read', + 'capabilities' => 'mod/quiz:view', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + 'mod_quiz_get_user_best_grade' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_user_best_grade', @@ -123,12 +133,22 @@ 'mod_quiz_get_attempt_review' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'get_attempt_review', - 'description' => 'Returns review information for the given finished attempt, can be used by users or teachers.', + 'description' => 'Returns review information for the given finished attempt, can be used by users or teachers. ' . + '(Deprecated in favour of mod_quiz_get_quiz_attempt_review)', 'type' => 'read', 'capabilities' => 'mod/quiz:reviewmyattempts', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] ], + 'mod_quiz_get_quiz_attempt_review' => [ + 'classname' => 'mod_quiz_external', + 'methodname' => 'get_quiz_attempt_review', + 'description' => 'Returns review information for the given finished attempt, can be used by users or teachers.', + 'type' => 'read', + 'capabilities' => 'mod/quiz:reviewmyattempts', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + 'mod_quiz_view_attempt' => [ 'classname' => 'mod_quiz_external', 'methodname' => 'view_attempt', diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 8141663c66ab7..520f4b8d9223b 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -346,6 +346,10 @@ function quiz_start_attempt_built_on_last($quba, $attempt, $lastattempt) { /** * The save started question usage and quiz attempt in db and log the started attempt. * + * If the attempt already exists in the database with the NOT_STARTED state, it will be transitioned + * to IN_PROGRESS and the timestart updated. If it does not already exist, a new record will be created + * already in the IN_PROGRESS state. + * * @param quiz_settings $quizobj * @param question_usage_by_activity $quba * @param stdClass $attempt @@ -416,6 +420,9 @@ function quiz_attempt_save_started( /** * The save started question usage and quiz attempt in db. * + * This saves an attempt in the NOT_STARTED state, and is designed for use when pre-creating attempts + * ahead of the quiz start time to spread out the processing load. + * * @param question_usage_by_activity $quba * @param stdClass $attempt * @return stdClass attempt object with uniqueid and id set. diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index 6fa1bbc5268b3..789f949946cc5 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -428,6 +428,11 @@ public function test_view_quiz() { } + /** + * Test get_user_attempts + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + */ public function test_get_user_attempts(): void { // Create a quiz with one attempt finished. @@ -435,6 +440,7 @@ public function test_get_user_attempts(): void { $this->setUser($this->student); $result = mod_quiz_external::get_user_attempts($quiz->id); + $this->assertDebuggingCalled(); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); $this->assertCount(1, $result['attempts']); @@ -444,24 +450,31 @@ public function test_get_user_attempts(): void { $this->assertEquals(1, $result['attempts'][0]['attempt']); $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // Test filters. Only finished. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'finished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. All attempts. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); // Test filters. Unfinished. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(0, $result['attempts']); @@ -475,40 +488,55 @@ public function test_get_user_attempts(): void { quiz_attempt_save_started($quizobj, $quba, $attempt); // Test filters. All attempts. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'all', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(2, $result['attempts']); // Test filters. Unfinished. + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, 0, 'unfinished', false); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); // Test manager can see user attempts. $this->setUser($this->teacher); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all'); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(2, $result['attempts']); $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); // Invalid parameters. try { + $this->resetDebugging(); mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER'); $this->fail('Exception expected due to missing capability.'); } catch (\invalid_parameter_exception $e) { + $this->assertDebuggingCalled(); $this->assertEquals('invalidparameter', $e->errorcode); } } + /** + * Test get_user_attempts with extra grades + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + */ public function test_get_user_attempts_with_extra_grades(): void { global $DB; @@ -524,8 +552,10 @@ public function test_get_user_attempts_with_extra_grades(): void { $structure->update_slot_grade_item($structure->get_slot_by_number(2), $readinggrade->id); $this->setUser($this->student); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -536,8 +566,10 @@ public function test_get_user_attempts_with_extra_grades(): void { // Now change the review options, so marks are not displayed, and check the result. $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $quiz->id]); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -546,6 +578,8 @@ public function test_get_user_attempts_with_extra_grades(): void { /** * Test get_user_attempts with marks hidden + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. */ public function test_get_user_attempts_with_marks_hidden() { // Create quiz with one attempt finished and hide the mark. @@ -555,8 +589,10 @@ public function test_get_user_attempts_with_marks_hidden() { // Student cannot see the grades. $this->setUser($this->student); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -568,8 +604,10 @@ public function test_get_user_attempts_with_marks_hidden() { // Test manager can see user grades. $this->setUser($this->teacher); + $this->resetDebugging(); $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_user_attempts_returns(), $result); + $this->assertDebuggingCalled(); $this->assertCount(1, $result['attempts']); $this->assertEquals($attempt->id, $result['attempts'][0]['id']); @@ -580,6 +618,295 @@ public function test_get_user_attempts_with_marks_hidden() { $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); } + /** + * Test get_user_attempts when the attempt is in 'submitted' state. + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + * @covers \mod_quiz_external::get_user_attempts + */ + public function test_get_user_attempts_submitted(): void { + + // Create a quiz with one attempt. + [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true); + // Submit the attempt but do not finish it. + // Process some responses from the student. + $tosubmit = [1 => ['answer' => '3.14']]; + $attemptobj->process_submitted_actions(time(), false, $tosubmit); + $attemptobj->process_submit(time(), false); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_attempts($quiz->id); + $this->assertDebuggingCalled(); + $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->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); // No grades. + $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); // State is returned as finished. + } + + /** + * Test get_user_attempts when the attempt is in 'notstarted' state. + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + * @covers \mod_quiz_external::get_user_attempts + */ + public function test_get_user_attempts_notstarted(): void { + // Create a quiz. + [$quiz, , $quizobj, , ] = $this->create_quiz_with_questions(); + // Create an attempt but do not start it. + // Now, do one attempt. + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + quiz_attempt_save_not_started($quba, $attempt); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_attempts($quiz->id, $this->student->id, 'all'); + $this->assertDebuggingCalled(); + $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->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::IN_PROGRESS, $result['attempts'][0]['state']); // State is returned as in progress. + } + + /** + * Test get_quiz_user_attempts + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts(): void { + + // Create a quiz with one attempt finished. + [$quiz, , $quizobj, $attempt, ] = $this->create_quiz_with_questions(true, true); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::FINISHED, $result['attempts'][0]['state']); + + // Test filters. Only finished. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'finished', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + + // Test filters. All attempts. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'all', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + + // Test filters. Unfinished. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'unfinished', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(0, $result['attempts']); + + // Start a new attempt, but not finish it. + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id); + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Test filters. All attempts. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'all', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(2, $result['attempts']); + + // Test filters. Unfinished. + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, 0, 'unfinished', false); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + + // Test manager can see user attempts. + $this->setUser($this->teacher); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'all'); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(2, $result['attempts']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + + // Invalid parameters. + try { + mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'INVALID_PARAMETER'); + $this->fail('Exception expected due to missing capability.'); + } catch (\invalid_parameter_exception $e) { + $this->assertEquals('invalidparameter', $e->errorcode); + } + } + + /** + * Test get_user_quiz_attempts with extra grades + */ + public function test_get_user_quiz_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_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_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_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_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_quiz_attempts with marks hidden + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts_with_marks_hidden() { + // Create quiz with one attempt finished and hide the mark. + [$quiz, , , $attempt, ] = $this->create_quiz_with_questions( + true, true, 'deferredfeedback', false, + ['marksduring' => 0, 'marksimmediately' => 0, 'marksopen' => 0, 'marksclosed' => 0]); + + // Student cannot see the grades. + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertEquals(null, $result['attempts'][0]['sumgrades']); + + // Test manager can see user grades. + $this->setUser($this->teacher); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertEquals(1.0, $result['attempts'][0]['sumgrades']); + } + + /** + * Test get_user_quiz_attempts when the attempt is in 'submitted' state. + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts_submitted(): void { + + // Create a quiz with one attempt. + [$quiz, , , $attempt, $attemptobj] = $this->create_quiz_with_questions(true); + // Submit the attempt but do not finish it. + // Process some responses from the student. + $tosubmit = [1 => ['answer' => '3.14']]; + $attemptobj->process_submitted_actions(time(), false, $tosubmit); + $attemptobj->process_submit(time(), false); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); // No grades. + $this->assertEquals(quiz_attempt::SUBMITTED, $result['attempts'][0]['state']); + } + + /** + * Test get_user_quiz_attempts when the attempt is in 'notstarted' state. + * + * @covers \mod_quiz_external::get_user_quiz_attempts + */ + public function test_get_user_quiz_attempts_notstarted(): void { + // Create a quiz. + [$quiz, , $quizobj, , ] = $this->create_quiz_with_questions(); + // Create an attempt but do not start it. + // Now, do one attempt. + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); + quiz_attempt_save_not_started($quba, $attempt); + + $this->setUser($this->student); + $result = mod_quiz_external::get_user_quiz_attempts($quiz->id, $this->student->id, 'all'); + $result = external_api::clean_returnvalue(mod_quiz_external::get_user_quiz_attempts_returns(), $result); + + $this->assertCount(1, $result['attempts']); + $this->assertEquals($attempt->id, $result['attempts'][0]['id']); + $this->assertEquals($quiz->id, $result['attempts'][0]['quiz']); + $this->assertEquals($this->student->id, $result['attempts'][0]['userid']); + $this->assertEquals(1, $result['attempts'][0]['attempt']); + $this->assertArrayHasKey('sumgrades', $result['attempts'][0]); + $this->assertNull($result['attempts'][0]['sumgrades']); + $this->assertEquals(quiz_attempt::NOT_STARTED, $result['attempts'][0]['state']); + } + /** * Test get_user_best_grade */ @@ -1245,8 +1572,8 @@ public function test_get_attempt_data() { $attemptobj->process_grade_submission(time()); // Now we should receive the question state. - $result = mod_quiz_external::get_attempt_review($attempt->id, 1); - $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $result = mod_quiz_external::get_quiz_attempt_review($attempt->id, 1); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_attempt_review_returns(), $result); $this->assertEquals('notanswered', $result['questions'][0]['stateclass']); $this->assertEquals('gaveup', $result['questions'][0]['state']); @@ -1709,6 +2036,8 @@ public function test_validate_attempt_review() { /** * Test get_attempt_review + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. */ public function test_get_attempt_review(): void { global $DB; @@ -1733,6 +2062,7 @@ public function test_get_attempt_review(): void { $result = mod_quiz_external::get_attempt_review($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $this->assertDebuggingCalled(); // Two questions, one completed and correct, the other gave up. $this->assertEquals(50, $result['grade']); @@ -1746,8 +2076,10 @@ public function test_get_attempt_review(): void { $this->assertEquals(2, $result['questions'][1]['slot']); // Only first page. + $this->resetDebugging(); $result = mod_quiz_external::get_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $this->assertDebuggingCalled(); $this->assertEquals(50, $result['grade']); $this->assertEquals(1, $result['attempt']['attempt']); @@ -1764,7 +2096,10 @@ public function test_get_attempt_review(): void { } /** - * Test get_attempt_review + * Test get_attempt_review for an attempt in 'submitted' state. + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + * @covers \mod_quiz_external::get_attempt_review */ public function test_get_attempt_review_with_extra_grades(): void { global $DB; @@ -1781,8 +2116,10 @@ public function test_get_attempt_review_with_extra_grades(): void { $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->resetDebugging(); $result = mod_quiz_external::get_attempt_review($attempt->id); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $this->assertDebuggingCalled(); // Two questions, one completed and correct, the other gave up. $this->assertEquals(50, $result['grade']); @@ -1796,8 +2133,10 @@ public function test_get_attempt_review_with_extra_grades(): void { $this->assertEquals(2, $result['questions'][1]['slot']); // Only first page. + $this->resetDebugging(); $result = mod_quiz_external::get_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $this->assertDebuggingCalled(); $this->assertEquals(50, $result['grade']); $this->assertEquals(1, $result['attempt']['attempt']); @@ -1813,8 +2152,10 @@ public function test_get_attempt_review_with_extra_grades(): void { // Now change the review options, so marks are not displayed, and check the result. $DB->set_field('quiz', 'reviewmarks', 0, ['id' => $attemptobj->get_quizid()]); + $this->resetDebugging(); $result = mod_quiz_external::get_attempt_review($attempt->id, 0); $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $this->assertDebuggingCalled(); $this->assertEquals(1, $result['attempt']['attempt']); $this->assertEquals('finished', $result['attempt']['state']); @@ -1822,6 +2163,193 @@ public function test_get_attempt_review_with_extra_grades(): void { $this->assertArrayNotHasKey('gradeitemmarks', $result['attempt']); } + /** + * Test get_attempt_review for an attempt in 'submitted' state. + * + * @todo Remove in Moodle 6.0 as part of MDL-80956 final deprecations. + * @covers \mod_quiz_external::get_attempt_review + */ + public function test_get_attempt_review_submitted(): void { + // Create a new quiz with two questions and one attempt submitted. + [, , , $attempt, $attemptobj, ] = $this->create_quiz_with_questions(true); + // Submit the attempt but do not finish it. + // Process some responses from the student. + $tosubmit = [1 => ['answer' => '3.14']]; + $attemptobj->process_submitted_actions(time(), false, $tosubmit); + $attemptobj->process_submit(time(), false); + + $result = mod_quiz_external::get_attempt_review($attempt->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_review_returns(), $result); + $this->assertDebuggingCalled(); + + // Two questions, one completed, one not. + $this->assertNull($result['grade']); + $this->assertEquals(1, $result['attempt']['attempt']); + $this->assertEquals('finished', $result['attempt']['state']); // State is finished, although it's really submitted. + $this->assertNull($result['attempt']['sumgrades']); + $this->assertCount(2, $result['questions']); + $this->assertEquals('complete', $result['questions'][0]['state']); + $this->assertEquals(1, $result['questions'][0]['slot']); + $this->assertEquals('todo', $result['questions'][1]['state']); + $this->assertEquals(2, $result['questions'][1]['slot']); + + $this->assertCount(0, $result['additionaldata']); + } + + /** + * Test get_attempt_review + * + * @covers \mod_quiz_external::get_quiz_attempt_review + */ + public function test_get_quiz_attempt_review(): void { + global $DB; + + // Create a new quiz with two questions and one attempt finished. + [$quiz, , , $attempt, , ] = $this->create_quiz_with_questions(true, true); + + // Add feedback to the quiz. + $feedback = new \stdClass(); + $feedback->quizid = $quiz->id; + $feedback->feedbacktext = 'Feedback text 1'; + $feedback->feedbacktextformat = 1; + $feedback->mingrade = 49; + $feedback->maxgrade = 100; + $feedback->id = $DB->insert_record('quiz_feedback', $feedback); + + $feedback->feedbacktext = 'Feedback text 2'; + $feedback->feedbacktextformat = 1; + $feedback->mingrade = 30; + $feedback->maxgrade = 48; + $feedback->id = $DB->insert_record('quiz_feedback', $feedback); + + $result = mod_quiz_external::get_quiz_attempt_review($attempt->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_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']); + + $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']); + + // Only first page. + $result = mod_quiz_external::get_quiz_attempt_review($attempt->id, 0); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_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_quiz_attempt_review with extra grades + */ + public function test_get_quiz_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_quiz_attempt_review($attempt->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_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_quiz_attempt_review($attempt->id, 0); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_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']); + + // 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_quiz_attempt_review($attempt->id, 0); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_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']); + } + + /** + * Test get_quiz_attempt_review for an attempt in 'submitted' state. + * + * @covers \mod_quiz_external::get_quiz_attempt_review + */ + public function test_get_quiz_attempt_review_submitted(): void { + // Create a new quiz with two questions and one attempt submitted. + [, , , $attempt, $attemptobj, ] = $this->create_quiz_with_questions(true); + // Submit the attempt but do not finish it. + // Process some responses from the student. + $tosubmit = [1 => ['answer' => '3.14']]; + $attemptobj->process_submitted_actions(time(), false, $tosubmit); + $attemptobj->process_submit(time(), false); + + $result = mod_quiz_external::get_quiz_attempt_review($attempt->id); + $result = external_api::clean_returnvalue(mod_quiz_external::get_quiz_attempt_review_returns(), $result); + + // Two questions, one completed, one not. + $this->assertNull($result['grade']); + $this->assertEquals(1, $result['attempt']['attempt']); + $this->assertEquals('submitted', $result['attempt']['state']); + $this->assertNull($result['attempt']['sumgrades']); + $this->assertCount(2, $result['questions']); + $this->assertEquals('complete', $result['questions'][0]['state']); + $this->assertEquals(1, $result['questions'][0]['slot']); + $this->assertEquals('todo', $result['questions'][1]['state']); + $this->assertEquals(2, $result['questions'][1]['slot']); + + $this->assertCount(0, $result['additionaldata']); + } + /** * Test test_view_attempt */ diff --git a/mod/quiz/upgrade.txt b/mod/quiz/upgrade.txt index 381d589c44ebe..4e0712936873d 100644 --- a/mod/quiz/upgrade.txt +++ b/mod/quiz/upgrade.txt @@ -14,12 +14,12 @@ This file describes API changes in the quiz code. is read by the \mod_quiz\task\precreate_attempts task to identify quizzes due for pre-creation. * Submitting a quiz attempt will now queue an instance of \mod_quiz\task\grade_submissions to call quiz_attempt::process_grade_submission asyncronously. +* quiz_attempt_save_started Now takes an additional $timenow parameter, to specify the timestart of the attempt. This was previously + set in quiz_create_attempt, but is now set in quiz_attempt_save_started and quiz_attempt_save_not_started. * External functions used by the app to get quiz attempts, mod_quiz_get_user_attempts and mod_quiz_review_attempt, have been modified to return SUBMITTED attempts as FINISHED, and NOT_STARTED attempts as IN_PROGRESS, to retain backwards-compatibility with the app. These functions have been deprecated in favour of mod_quiz_get_user_quiz_attempts and mod_quiz_review_quiz_attempt, which will return attempts in their true states. -* quiz_attempt_save_started Now takes an additional $timenow parameter, to specify the timestart of the attempt. This was previously - set in quiz_create_attempt, but is now set in quiz_attempt_save_started and quiz_attempt_save_not_started. === 4.4 === * A quiz_structure_modified callback has been added for quiz_ plugins, called from @@ -48,6 +48,7 @@ This file describes API changes in the quiz code. * The quiz_delete_overrides and quiz_delete_all_overrides functions are now deprecated. Please instead use: - override_manager::delete_override_by_id - override_manager::delete_overrides + - override_manager::delete_all_overrides * There is a new renderable grade_out_of to help with display a nicely formatted "42.00 out of 100.00" with a few variants. * The quiz now supports computing multiple total grades for each attempt. To support this there is a new database table quiz_grade_items and a new column quizgradeitemid in quiz_slots. There are new methods in structure grade_calculator diff --git a/version.php b/version.php index e53c7fe0bcce3..241e40bd26097 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2024051600.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2024051600.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.5dev (Build: 20240516)'; // Human-friendly version name