From 68dcb5d736cb42db74845309708b475fe002b5d1 Mon Sep 17 00:00:00 2001 From: Matthew Hilton Date: Mon, 5 Feb 2024 10:08:17 +1000 Subject: [PATCH] MDL-80300 mod_quiz: Add quiz override webservices --- .../classes/external/delete_overrides.php | 77 ++++++ mod/quiz/classes/external/get_overrides.php | 82 +++++++ mod/quiz/classes/external/save_overrides.php | 90 +++++++ mod/quiz/db/services.php | 24 ++ mod/quiz/tests/external/override_test.php | 223 ++++++++++++++++++ 5 files changed, 496 insertions(+) create mode 100644 mod/quiz/classes/external/delete_overrides.php create mode 100644 mod/quiz/classes/external/get_overrides.php create mode 100644 mod/quiz/classes/external/save_overrides.php create mode 100644 mod/quiz/tests/external/override_test.php diff --git a/mod/quiz/classes/external/delete_overrides.php b/mod/quiz/classes/external/delete_overrides.php new file mode 100644 index 00000000000..17ba19f9910 --- /dev/null +++ b/mod/quiz/classes/external/delete_overrides.php @@ -0,0 +1,77 @@ +. + +namespace mod_quiz\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_quiz\quiz_settings; + +/** + * Webservice for deleting quiz overrides. + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_overrides extends external_api { + /** + * Defines parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + // This must be nested in a single structure, because the ids structure does not play nicely at the top level. + 'data' => new external_single_structure([ + 'quizid' => new external_value(PARAM_INT, "ID of quiz to delete overrides in"), + 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of override to delete')), + ]), + ]); + } + + /** + * Executes webservice function, deleting given overrides. + * + * @param array $params array of override parameters + * @return array with ids key, which contains the ids of the overrides successfully deleted. + */ + public static function execute($params): array { + $params = self::validate_parameters(self::execute_parameters(), ['data' => $params])['data']; + + $quizsettings = quiz_settings::create($params['quizid']); + $manager = $quizsettings->get_override_manager(); + self::validate_context($manager->context); + $manager->require_manage_capability(); + $manager->delete_overrides_by_id($params['ids']); + + return ['ids' => $params['ids']]; + } + + /** + * Defines return type + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of deleted override')), + ]); + } +} diff --git a/mod/quiz/classes/external/get_overrides.php b/mod/quiz/classes/external/get_overrides.php new file mode 100644 index 00000000000..373106f928b --- /dev/null +++ b/mod/quiz/classes/external/get_overrides.php @@ -0,0 +1,82 @@ +. + +namespace mod_quiz\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_quiz\quiz_settings; + +/** + * Webservice for searching overrides. + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class get_overrides extends external_api { + /** + * Defines parameters for getting quiz overrides. + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'quizid' => new external_value(PARAM_INT, 'ID of quiz to get overrides for'), + ]); + } + + /** + * Executes webservice function, returning quiz overrides. + * + * @param int $quizid + * @return array with overrides key which contains the overrides for the given quiz. + */ + public static function execute($quizid): array { + $params = self::validate_parameters(self::execute_parameters(), ['quizid' => $quizid]); + $manager = quiz_settings::create($params['quizid'])->get_override_manager(); + self::validate_context($manager->context); + $manager->require_read_capability(); + $overrides = $manager->get_all_overrides(); + return ['overrides' => $overrides]; + } + + /** + * Defines return type + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + $overridedatastructure = new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'Override ID'), + 'quiz' => new external_value(PARAM_INT, 'Quiz ID'), + 'userid' => new external_value(PARAM_INT, 'User ID', VALUE_DEFAULT, null), + 'groupid' => new external_value(PARAM_INT, 'Group ID', VALUE_DEFAULT, null), + 'timeopen' => new external_value(PARAM_INT, 'Override time open value', VALUE_DEFAULT, null), + 'timeclose' => new external_value(PARAM_INT, 'Override time close value', VALUE_DEFAULT, null), + 'timelimit' => new external_value(PARAM_INT, 'Override time limit value', VALUE_DEFAULT, null), + 'attempts' => new external_value(PARAM_INT, 'Override attempts value', VALUE_DEFAULT, null), + 'password' => new external_value(PARAM_TEXT, 'Override password', VALUE_DEFAULT, null), + ]); + + return new external_single_structure([ + 'overrides' => new external_multiple_structure($overridedatastructure), + ]); + } +} diff --git a/mod/quiz/classes/external/save_overrides.php b/mod/quiz/classes/external/save_overrides.php new file mode 100644 index 00000000000..40c5029d7ba --- /dev/null +++ b/mod/quiz/classes/external/save_overrides.php @@ -0,0 +1,90 @@ +. + +namespace mod_quiz\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_quiz\quiz_settings; + +/** + * Webservice for upserting quiz overrides. + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class save_overrides extends external_api { + /** + * Defines parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + $overridestructure = new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'ID of existing override (if updating)', VALUE_DEFAULT, null), + 'groupid' => new external_value(PARAM_INT, 'ID of group', VALUE_DEFAULT, null), + 'userid' => new external_value(PARAM_INT, 'ID of user', VALUE_DEFAULT, null), + 'timeopen' => new external_value(PARAM_INT, 'Quiz override opening timestamp', VALUE_DEFAULT, null), + 'timeclose' => new external_value(PARAM_INT, 'Quiz override closing timestamp', VALUE_OPTIONAL, null), + 'timelimit' => new external_value(PARAM_INT, 'Quiz override time limit', VALUE_DEFAULT, null), + 'attempts' => new external_value(PARAM_INT, 'Quiz override attempt count', VALUE_DEFAULT, null), + 'password' => new external_value(PARAM_TEXT, 'Quiz override password', VALUE_DEFAULT, null), + ]); + + return new external_function_parameters([ + // This must be nested in a single structure, because the overrides structure does not play nicely at the top level. + 'data' => new external_single_structure([ + 'quizid' => new external_value(PARAM_INT, 'ID of quiz to save overrides to'), + 'overrides' => new external_multiple_structure($overridestructure), + ]), + ]); + } + + /** + * Executes webservice function, saving the requested overrides. + * + * @param array $data array with quizid key and overrides key containing list of overrides to save. + * @return array with ids key which contains ids of created/updated overrides. + */ + public static function execute($data): array { + $params = self::validate_parameters(self::execute_parameters(), ['data' => $data])['data']; + + $quizsettings = quiz_settings::create($params['quizid']); + $manager = $quizsettings->get_override_manager(); + self::validate_context($manager->context); + $manager->require_manage_capability(); + + // Iterate over and save all overrides. + $ids = array_map(fn($override) => $manager->save_override($override), $params['overrides']); + + return ['ids' => $ids]; + } + + /** + * Defines return type + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of created/updated override')), + ]); + } +} diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index 2d6545bce49..bf366fdb3de 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -231,4 +231,28 @@ $functions = [ 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], + + 'mod_quiz_save_overrides' => [ + 'classname' => 'mod_quiz\external\save_overrides', + 'description' => 'Update or insert quiz overrides', + 'type' => 'write', + 'capabilities' => 'mod/quiz:manageoverrides', + 'ajax' => true, + ], + + 'mod_quiz_delete_overrides' => [ + 'classname' => 'mod_quiz\external\delete_overrides', + 'description' => 'Delete quiz overrides', + 'type' => 'write', + 'capabilities' => 'mod/quiz:manageoverrides', + 'ajax' => true, + ], + + 'mod_quiz_get_overrides' => [ + 'classname' => 'mod_quiz\external\get_overrides', + 'description' => 'Get quiz overrides', + 'type' => 'read', + 'capabilities' => 'mod/quiz:manageoverrides', + 'ajax' => true, + ], ]; diff --git a/mod/quiz/tests/external/override_test.php b/mod/quiz/tests/external/override_test.php new file mode 100644 index 00000000000..4cd6b332945 --- /dev/null +++ b/mod/quiz/tests/external/override_test.php @@ -0,0 +1,223 @@ +. + +namespace mod_quiz\external; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../../../../webservice/tests/helpers.php'); + +/** + * Tests for override webservices + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_quiz\external\get_overrides + * @covers \mod_quiz\external\save_overrides + * @covers \mod_quiz\external\delete_overrides + */ +final class override_test extends \externallib_advanced_testcase { + /** + * Creates a quiz for testing. + * + * @return object $quiz + */ + private function create_quiz(): object { + $course = $this->getDataGenerator()->create_course(); + return $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + } + + /** + * Provides values to test_get_overrides + * + * @return array + */ + public static function get_override_provider(): array { + return [ + 'quiz that exists' => [ + 'quizid' => ':quizid', + ], + 'quiz that does not exist' => [ + 'quizid' => -1, + 'expectedexception' => \dml_missing_record_exception::class, + ], + ]; + } + + /** + * Tests get_overrides + * + * @param int|string $quizid + * @param string $expectedexception + * @dataProvider get_override_provider + */ + public function test_get_overrides(int|string $quizid, string $expectedexception = ''): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + + // Create an override. + $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id]); + + // Replace placeholders. + if ($quizid == ":quizid") { + $quizid = $quiz->id; + } + + if (!empty($expectedexception)) { + $this->expectException($expectedexception); + } + + $result = get_overrides::execute($quizid); + $this->assertNotEmpty($result); + } + + /** + * Provides values to test_save_overrides + * + * @return array + */ + public static function save_overrides_provider(): array { + return [ + 'good insert' => [ + 'data' => [ + 'timeopen' => 999, + ], + ], + 'bad insert' => [ + 'data' => [ + 'id' => ':existingid', + 'timeopen' => -1, + ], + 'expectedexception' => \invalid_parameter_exception::class, + ], + 'good update' => [ + 'data' => [ + 'timeopen' => 999, + ], + ], + 'bad update' => [ + 'data' => [ + 'id' => ':existingid', + 'timeopen' => -1, + ], + 'expectedexception' => \invalid_parameter_exception::class, + ], + ]; + } + + /** + * Tests save_overrides + * + * @dataProvider save_overrides_provider + * @param array $data + * @param string $expectedexception + */ + public function test_save_overrides(array $data, string $expectedexception = ''): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + $user = $this->getDataGenerator()->create_user(); + + if (!empty($data['id'])) { + $data['id'] = $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user->id]); + } + + // Make a new user to insert a new override for. + $user = $this->getDataGenerator()->create_user(); + $data = array_merge($data, ['userid' => $user->id]); + + $payload = [ + 'quizid' => $quiz->id, + 'overrides' => [ + $data, + ], + ]; + + if (!empty($expectedexception)) { + $this->expectException($expectedexception); + } + + $result = save_overrides::execute($payload); + + // If has reached here, but not thrown exception and was expected to, fail the test. + if ($expectedexception) { + $this->fail("Expected exception " . $expectedexception . " was not thrown"); + } + + $this->assertNotEmpty($result['ids']); + $this->assertCount(1, $result['ids']); + } + + /** + * Provides values to test_delete_overrides + * + * @return array + */ + public static function delete_overrides_provider(): array { + return [ + 'delete existing override' => [ + 'id' => ':existingid', + ], + 'delete override that does not exist' => [ + 'id' => -1, + 'expectedexception' => \invalid_parameter_exception::class, + ], + ]; + } + + /** + * Tests delete_overrides + * + * @dataProvider delete_overrides_provider + * @param int|string $id + * @param string $expectedexception + */ + public function test_delete_overrides(int|string $id, string $expectedexception = ''): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + $user = $this->getDataGenerator()->create_user(); + + if ($id == ':existingid') { + $id = $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user->id]); + } + + if (!empty($expectedexception)) { + $this->expectException($expectedexception); + } + + $result = delete_overrides::execute(['quizid' => $quiz->id, 'ids' => [$id]]); + + // If has reached here, but not thrown exception and was expected to, fail the test. + if ($expectedexception) { + $this->fail("Expected exception " . $expectedexception . " was not thrown"); + } + + $this->assertNotEmpty($result['ids']); + $this->assertContains($id, $result['ids']); + } +}