diff --git a/.upgradenotes/MDL-68806-2024060613552522.yml b/.upgradenotes/MDL-68806-2024060613552522.yml new file mode 100644 index 000000000000..53e7e0dbfb91 --- /dev/null +++ b/.upgradenotes/MDL-68806-2024060613552522.yml @@ -0,0 +1,12 @@ +issueNumber: MDL-68806 +notes: + mod_quiz: + - message: > + 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. + type: deprecated diff --git a/mod/quiz/classes/external.php b/mod/quiz/classes/external.php index 3d78bb774df9..538e93906d13 100644 --- a/mod/quiz/classes/external.php +++ b/mod/quiz/classes/external.php @@ -363,7 +363,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 ( [ @@ -379,15 +387,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 = []; @@ -427,8 +447,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])) { @@ -441,6 +469,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 = []; @@ -498,16 +532,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. * @@ -1463,7 +1628,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 ( [ @@ -1477,12 +1650,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 = []; @@ -1504,6 +1689,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(); @@ -1558,9 +1889,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 568570b9e0c8..391db91f3aba 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 8141663c66ab..520f4b8d9223 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 6fa1bbc5268b..24974bac31f2 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(): void { + // 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/version.php b/version.php index f39926c6a18f..40bae707e99b 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2024053000.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2024053000.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.5dev (Build: 20240530)'; // Human-friendly version name