From 8d5d4f5314e0d33a1bbdafd3da0c4dc42de11a18 Mon Sep 17 00:00:00 2001 From: Mark Johnson Date: Mon, 12 Aug 2024 11:28:18 +0100 Subject: [PATCH] MDL-68806 quiz: Backwards compatibility for mod_quiz_get_user_attempts --- .upgradenotes/MDL-68806-2024081214032268.yml | 24 ++ mod/quiz/classes/external.php | 173 +++++++++- mod/quiz/db/services.php | 12 +- mod/quiz/locallib.php | 7 + mod/quiz/tests/external/external_test.php | 320 +++++++++++++++++++ 5 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 .upgradenotes/MDL-68806-2024081214032268.yml diff --git a/.upgradenotes/MDL-68806-2024081214032268.yml b/.upgradenotes/MDL-68806-2024081214032268.yml new file mode 100644 index 00000000000..90d2725e052 --- /dev/null +++ b/.upgradenotes/MDL-68806-2024081214032268.yml @@ -0,0 +1,24 @@ +issueNumber: MDL-68806 +notes: + mod_quiz: + - message: >+ + The webservice function `mod_quiz_get_user_attempts` is now deprecated + in favour of `mod_quiz_get_user_quiz_attempts`. + + + With the introduction of the new NOT_STARTED quiz attempt state, + `mod_quiz_get_user_attempts` has been modified to not return NOT_STARTED + attempts, allowing clients such as the mobile app to continue working + without modifications. + + + `mod_quiz_get_user_quiz_attempts` will return attempts in all states, as + `mod_quiz_get_user_attempts` did before. Once clients are updated to + handle NOT_STARTED attempts, they can migrate to use this function. + + + A minor modification to `mod_quiz_start_attempt` has been made to allow + it to transparently start an existing attempt that is in the NOT_STARTED + state, rather than creating a new one. + + type: deprecated diff --git a/mod/quiz/classes/external.php b/mod/quiz/classes/external.php index 3d78bb774df..3098b701075 100644 --- a/mod/quiz/classes/external.php +++ b/mod/quiz/classes/external.php @@ -363,7 +363,15 @@ class mod_quiz_external extends external_api { * * @return external_function_parameters * @since Moodle 3.1 + * @deprecated Since Moodle 5.0 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts_parameters', + since: '5.0', + 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 @@ class mod_quiz_external extends external_api { /** * 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 not + * be returned. 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 5.0 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts', + since: '5.0', + 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,9 +446,20 @@ class mod_quiz_external extends external_api { array_column($attempts, 'uniqueid')); $attemptresponse = []; foreach ($attempts as $attempt) { + if ($attempt->state == quiz_attempt::NOT_STARTED) { + continue; // For backwards compatibility, do not return Not Started attempts. + } $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 +472,9 @@ class mod_quiz_external extends external_api { ]; } } + if ($attempt->state == quiz_attempt::SUBMITTED) { + $attempt->state = quiz_attempt::FINISHED; // For backwards-compatibility. + } $attemptresponse[] = $attempt; } $result = []; @@ -498,13 +532,144 @@ class mod_quiz_external extends external_api { * * @return external_single_structure * @since Moodle 3.1 + * @deprecated Since Moodle 5.0 MDL-68806. + * @todo Final deprecation in Moodle 6.0 (MDL-80956) */ + #[\core\attribute\deprecated( + 'mod_quiz_external::get_user_quiz_attempts_returns', + since: '5.0', + 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($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(), - ] + ], ); } @@ -812,7 +977,7 @@ class mod_quiz_external extends external_api { $accessmanager->notify_preflight_check_passed($currentattemptid); } - if ($currentattemptid) { + if ($currentattemptid && $lastattempt->state !== quiz_attempt::NOT_STARTED) { if ($lastattempt->state == quiz_attempt::OVERDUE) { throw new moodle_exception('stateoverdue', 'quiz', $quizobj->view_url()); } else { diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index 568570b9e0c..00ae4d4b851 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -50,12 +50,22 @@ $functions = [ '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', diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 38d57e47a0e..d6464655742 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) { /** * Create or update the quiz attempt record, and the question usage. * + * 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( /** * Create the quiz attempt record, and the question usage. * + * 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 de4a7f016c7..151f8f11ecd 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -429,6 +429,11 @@ final class external_test extends externallib_advanced_testcase { } + /** + * 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. @@ -436,6 +441,7 @@ final class external_test extends externallib_advanced_testcase { $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']); @@ -445,24 +451,31 @@ final class external_test extends externallib_advanced_testcase { $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']); @@ -476,40 +489,55 @@ final class external_test extends externallib_advanced_testcase { 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; @@ -525,8 +553,10 @@ final class external_test extends externallib_advanced_testcase { $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']); @@ -537,8 +567,10 @@ final class external_test extends externallib_advanced_testcase { // 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']); @@ -547,6 +579,8 @@ final class external_test extends externallib_advanced_testcase { /** * 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(): void { // Create quiz with one attempt finished and hide the mark. @@ -556,8 +590,10 @@ final class external_test extends externallib_advanced_testcase { // 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']); @@ -569,8 +605,10 @@ final class external_test extends externallib_advanced_testcase { // 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']); @@ -581,6 +619,288 @@ final class external_test extends externallib_advanced_testcase { $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. The attempt should not be returned. + * + * @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(0, $result['attempts']); + } + + /** + * 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 */