diff --git a/lib/db/services.php b/lib/db/services.php index c3bf4883f49..be5f79db0e4 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -2794,6 +2794,13 @@ $functions = array( 'capabilities' => '', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'core_xapi_get_states' => [ + 'classname' => 'core_xapi\external\get_states', + 'description' => 'Get all state ID from an activityId.', + 'type' => 'read', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], 'core_xapi_delete_state' => [ 'classname' => 'core_xapi\external\delete_state', 'classpath' => '', diff --git a/lib/xapi/classes/external/get_states.php b/lib/xapi/classes/external/get_states.php new file mode 100644 index 00000000000..9f08863d7f8 --- /dev/null +++ b/lib/xapi/classes/external/get_states.php @@ -0,0 +1,147 @@ +. + +namespace core_xapi\external; + +use core_xapi\handler; +use core_xapi\xapi_exception; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_value; +use core_xapi\iri; +use core_xapi\local\statement\item_agent; + +/** + * This is the external API for generic xAPI get all states ids. + * + * @package core_xapi + * @since Moodle 4.2 + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class get_states extends external_api { + + use \core_xapi\local\helper\state_trait; + + /** + * Parameters for execute + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'component' => new external_value(PARAM_COMPONENT, 'Component name'), + 'activityId' => new external_value(PARAM_URL, 'xAPI activity ID IRI'), + 'agent' => new external_value(PARAM_RAW, 'The xAPI agent json'), + 'registration' => new external_value(PARAM_ALPHANUMEXT, 'The xAPI registration UUID', VALUE_DEFAULT, null), + 'since' => new external_value(PARAM_TEXT, 'Filter ids stored since the timestamp (exclusive)', VALUE_DEFAULT, null), + ]); + } + + /** + * Process a get states request. + * + * @param string $component The component name in frankenstyle. + * @param string $activityiri The activity IRI. + * @param string $agent The agent JSON. + * @param string|null $registration The xAPI registration UUID. + * @param string|null $since A ISO 8601 timestamps or a numeric timestamp. + * @return array the list of the stored state ids + */ + public static function execute( + string $component, + string $activityiri, + string $agent, + ?string $registration = null, + ?string $since = null + ): array { + global $USER; + + [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'registration' => $registration, + 'since' => $since, + ] = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'registration' => $registration, + 'since' => $since, + ]); + + static::validate_component($component); + + $handler = handler::create($component); + + $agent = self::get_agent_from_json($agent); + $user = $agent->get_user(); + + if ($user->id !== $USER->id) { + throw new xapi_exception('State agent is not the current user'); + } + + $activityid = iri::extract($activityiri, 'activity'); + $createdsince = self::convert_since_param_to_timestamp($since); + $store = $handler->get_state_store(); + + return $store->get_state_ids( + $activityid, + $user->id, + $registration, + $createdsince + ); + } + + /** + * Convert the xAPI since param into a Moodle integer timestamp. + * + * According to xAPI standard, the "since" param must follow the ISO 8601 + * format. However, because Moodle do not use this format, we accept both + * numeric timestamp and ISO 8601. + * + * @param string|null $since A ISO 8601 timestamps or a numeric timestamp. + * @return null|int the resulting timestamp or null if since is null. + */ + private static function convert_since_param_to_timestamp(?string $since): ?int { + if ($since === null) { + return null; + } + if (is_numeric($since)) { + return intval($since); + } + try { + $datetime = new \DateTime($since); + return $datetime->getTimestamp(); + } catch (\Exception $exception) { + throw new xapi_exception("Since param '$since' is not in ISO 8601 or a numeric timestamp format"); + } + } + + /** + * Return for execute. + * + * @return external_multiple_structure + */ + public static function execute_returns(): external_multiple_structure { + return new external_multiple_structure( + new external_value(PARAM_RAW, 'State ID'), + 'List of state Ids' + ); + } +} diff --git a/lib/xapi/classes/state_store.php b/lib/xapi/classes/state_store.php index 0cf4142a99a..95742520538 100644 --- a/lib/xapi/classes/state_store.php +++ b/lib/xapi/classes/state_store.php @@ -202,6 +202,47 @@ class state_store { $DB->delete_records('xapi_states', $data); } + /** + * Get all state ids from a specific activity and agent. + * + * Plugins may override this method if they store some data in different tables. + * + * @param string|null $itemid + * @param int|null $userid + * @param string|null $registration + * @param int|null $since filter ids updated since a specific timestamp + * @return string[] the state ids values + */ + public function get_state_ids( + ?string $itemid = null, + ?int $userid = null, + ?string $registration = null, + ?int $since = null, + ): array { + global $DB; + $select = 'component = :component'; + $params = [ + 'component' => $this->component, + ]; + if ($itemid) { + $select .= ' AND itemid = :itemid'; + $params['itemid'] = $itemid; + } + if ($userid) { + $select .= ' AND userid = :userid'; + $params['userid'] = $userid; + } + if ($registration) { + $select .= ' AND registration = :registration'; + $params['registration'] = $registration; + } + if ($since) { + $select .= ' AND timemodified > :since'; + $params['since'] = $since; + } + return $DB->get_fieldset_select('xapi_states', 'stateid', $select, $params, ''); + } + /** * Execute a state store clean up. * diff --git a/lib/xapi/tests/external/get_states_test.php b/lib/xapi/tests/external/get_states_test.php new file mode 100644 index 00000000000..a260dabf774 --- /dev/null +++ b/lib/xapi/tests/external/get_states_test.php @@ -0,0 +1,397 @@ +. + +namespace core_xapi\external; + +use core_xapi\xapi_exception; +use core_xapi\local\statement\item_agent; +use externallib_advanced_testcase; +use core_external\external_api; +use core_xapi\iri; +use core_xapi\local\state; +use core_xapi\local\statement\item_activity; +use core_xapi\test_helper; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +/** + * Unit tests for xAPI get states webservice. + * + * @package core_xapi + * @covers \core_xapi\external\get_states + * @since Moodle 4.2 + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class get_states_test extends externallib_advanced_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/lib/xapi/tests/helper.php'); + } + + /** + * Execute the get_states service from a generate state. + * + * @param string $component component name + * @param state $data the original state to extract the params + * @param string|null $since the formated timestamp or ISO 8601 date + * @param array $override overridden params + * @return string[] array of state ids + */ + private function execute_service( + string $component, + state $data, + ?string $since = null, + array $override = [] + ): array { + // Apply overrides. + $activityiri = $override['activityiri'] ?? iri::generate($data->get_activity_id(), 'activity'); + $registration = $override['registration'] ?? $data->get_registration(); + $agent = $override['agent'] ?? $data->get_agent(); + if (!empty($override['user'])) { + $agent = item_agent::create_from_user($override['user']); + } + + $external = $this->get_external_class(); + $result = $external::execute( + $component, + $activityiri, + json_encode($agent), + $registration, + $since + ); + $result = external_api::clean_returnvalue($external::execute_returns(), $result); + + // Sorting result to make them comparable. + sort($result); + return $result; + } + + /** + * Return a xAPI external webservice class to operate. + * + * The test needs to fake a component in order to test without + * using a real one. This way if in the future any component + * implement it's xAPI handler this test will continue working. + * + * @return get_states the external class + */ + private function get_external_class(): get_states { + $ws = new class extends get_states { + /** + * Method to override validate_component. + * + * @param string $component The component name in frankenstyle. + */ + protected static function validate_component(string $component): void { + if ($component != 'fake_component') { + parent::validate_component($component); + } + } + }; + return $ws; + } + + /** + * Testing different component names on valid states. + * + * @dataProvider components_provider + * @param string $component component name + * @param string|null $exception expect exception + */ + public function test_component_names(string $component, ?bool $exception): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Add, at least, one xAPI state record to database. + $data = test_helper::create_state( + ['activity' => item_activity::create_from_id('1'), 'stateid' => 'aa'], + true + ); + + // If no result is expected we will just incur in exception. + if ($exception) { + $this->expectException(xapi_exception::class); + } + + $result = $this->execute_service($component, $data); + $this->assertEquals(['aa'], $result); + } + + /** + * Data provider for the test_component_names tests. + * + * @return array + */ + public function components_provider() : array { + return [ + 'Inexistent component' => [ + 'component' => 'inexistent_component', + 'exception' => true, + ], + 'Compatible component' => [ + 'component' => 'fake_component', + 'exception' => false, + ], + 'Incompatible component' => [ + 'component' => 'core_xapi', + 'exception' => true, + ], + ]; + } + + /** + * Testing different since date formats. + * + * @dataProvider since_formats_provider + * @param string|null $since the formatted timestamps + * @param string[]|null $expected expected results + * @param bool $exception expect exception + */ + public function test_since_formats(?string $since, ?array $expected, bool $exception = false): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $states = $this->generate_states(); + + if ($exception) { + $this->expectException(xapi_exception::class); + } + + $result = $this->execute_service('fake_component', $states['aa'], $since); + $this->assertEquals($expected, $result); + } + + /** + * Data provider for the test_since_formats tests. + * + * @return array + */ + public function since_formats_provider(): array { + return [ + 'Null date' => [ + 'since' => null, + 'expected' => ['aa', 'bb', 'cc', 'dd'], + 'exception' => false, + ], + 'Numeric timestamp' => [ + 'since' => '1651100399', + 'expected' => ['aa', 'bb'], + 'exception' => false, + ], + 'ISO 8601 format 1' => [ + 'since' => '2022-04-28T06:59', + 'expected' => ['aa', 'bb'], + 'exception' => false, + ], + 'ISO 8601 format 2' => [ + 'since' => '2022-04-28T06:59:59', + 'expected' => ['aa', 'bb'], + 'exception' => false, + ], + 'Wrong format' => [ + 'since' => 'Spanish omelette without onion', + 'expected' => null, + 'exception' => true, + ], + ]; + } + + /** + * Testing different activity IRI values. + * + * @dataProvider activity_iri_provider + * @param string|null $activityiri + * @param string[]|null $expected expected results + */ + public function test_activity_iri(?string $activityiri, ?array $expected): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $states = $this->generate_states(); + + $override = ['activityiri' => $activityiri]; + $result = $this->execute_service('fake_component', $states['aa'], null, $override); + $this->assertEquals($expected, $result); + } + + /** + * Data provider for the test_activity_iri tests. + * + * @return array + */ + public function activity_iri_provider(): array { + return [ + 'Activity with several states' => [ + 'activityiri' => iri::generate('1', 'activity'), + 'expected' => ['aa', 'bb', 'cc', 'dd'], + ], + 'Activity with one state' => [ + 'activityiri' => iri::generate('2', 'activity'), + 'expected' => ['ee'], + ], + 'Inexistent activity' => [ + 'activityiri' => iri::generate('3', 'activity'), + 'expected' => [], + ], + ]; + } + + /** + * Testing different agent values. + * + * @dataProvider agent_values_provider + * @param string|null $agentreference the used agent reference + * @param string[]|null $expected expected results + * @param bool $exception expect exception + */ + public function test_agent_values(?string $agentreference, ?array $expected, bool $exception = false): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $states = $this->generate_states(); + + if ($exception) { + $this->expectException(xapi_exception::class); + } + + $userreferences = [ + 'current' => $states['aa']->get_user(), + 'other' => $this->getDataGenerator()->create_user(), + ]; + + $override = [ + 'user' => $userreferences[$agentreference], + ]; + $result = $this->execute_service('fake_component', $states['aa'], null, $override); + $this->assertEquals($expected, $result); + } + + /** + * Data provider for the test_agent_values tests. + * + * @return array + */ + public function agent_values_provider(): array { + return [ + 'Current user' => [ + 'agentreference' => 'current', + 'expected' => ['aa', 'bb', 'cc', 'dd'], + 'exception' => false, + ], + 'Other user' => [ + 'agentreference' => 'other', + 'expected' => null, + 'exception' => true, + ], + ]; + } + + /** + * Testing different registration values. + * + * @dataProvider registration_values_provider + * @param string|null $registration + * @param string[]|null $expected expected results + */ + public function test_registration_values(?string $registration, ?array $expected): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $states = $this->generate_states(); + + $override = ['registration' => $registration]; + $result = $this->execute_service('fake_component', $states['aa'], null, $override); + $this->assertEquals($expected, $result); + } + + /** + * Data provider for the test_registration_values tests. + * + * @return array + */ + public function registration_values_provider(): array { + return [ + 'Null registration' => [ + 'registration' => null, + 'expected' => ['aa', 'bb', 'cc', 'dd'], + ], + 'Registration with one state id' => [ + 'registration' => 'reg2', + 'expected' => ['cc'], + ], + 'Registration with two state ids' => [ + 'registration' => 'reg', + 'expected' => ['bb', 'dd'], + ], + 'Registration with no state ids' => [ + 'registration' => 'invented', + 'expected' => [], + ], + ]; + } + + /** + * Generate the state for the testing scenarios. + * + * Generate a variaty of states from several components, registrations and state ids. + * Some of the states are registered as they are done in 27-04-2022 07:00:00 while others + * are updated in 28-04-2022 07:00:00. + * + * @return state[] + */ + private function generate_states(): array { + global $DB; + + $testdate = \DateTime::createFromFormat('d-m-Y H:i:s', '28-04-2022 07:00:00'); + // Unix timestamp: 1651100400. + $currenttime = $testdate->getTimestamp(); + + $result = []; + + // Add a few xAPI state records to database. + $states = [ + ['activity' => item_activity::create_from_id('1'), 'stateid' => 'aa'], + ['activity' => item_activity::create_from_id('1'), 'registration' => 'reg', 'stateid' => 'bb'], + ['activity' => item_activity::create_from_id('1'), 'registration' => 'reg2', 'stateid' => 'cc'], + ['activity' => item_activity::create_from_id('1'), 'registration' => 'reg', 'stateid' => 'dd'], + ['activity' => item_activity::create_from_id('2'), 'stateid' => 'ee'], + ['activity' => item_activity::create_from_id('3'), 'component' => 'other', 'stateid' => 'gg'], + ['activity' => item_activity::create_from_id('3'), 'component' => 'other', 'registration' => 'reg', 'stateid' => 'ff'], + ]; + foreach ($states as $state) { + $result[$state['stateid']] = test_helper::create_state($state, true); + } + + $timepast = $currenttime - DAYSECS; + $DB->set_field('xapi_states', 'timecreated', $timepast); + $DB->set_field('xapi_states', 'timemodified', $timepast); + $DB->set_field('xapi_states', 'timemodified', $currenttime, ['stateid' => 'aa']); + $DB->set_field('xapi_states', 'timemodified', $currenttime, ['stateid' => 'bb']); + $DB->set_field('xapi_states', 'timemodified', $currenttime, ['stateid' => 'ee']); + + return $result; + } +} diff --git a/lib/xapi/tests/state_store_test.php b/lib/xapi/tests/state_store_test.php index f6792e65c45..3ef74c5ac1f 100644 --- a/lib/xapi/tests/state_store_test.php +++ b/lib/xapi/tests/state_store_test.php @@ -438,4 +438,120 @@ class state_store_test extends advanced_testcase { $this->assertEquals(1, $DB->count_records('xapi_states', ['component' => $component])); $this->assertEquals(2, $DB->count_records('xapi_states', ['component' => 'my_component'])); } + + /** + * Testing get_state_ids method. + * + * @dataProvider get_state_ids_provider + * @param string $component + * @param string|null $itemid + * @param string|null $registration + * @param bool|null $since + * @param array $expected the expected result + * @return void + */ + public function test_get_state_ids( + string $component, + ?string $itemid, + ?string $registration, + ?bool $since, + array $expected, + ): void { + global $DB, $USER; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $other = $this->getDataGenerator()->create_user(); + + // Add a few xAPI state records to database. + $states = [ + ['activity' => item_activity::create_from_id('1'), 'stateid' => 'aa'], + ['activity' => item_activity::create_from_id('1'), 'registration' => 'reg', 'stateid' => 'bb'], + ['activity' => item_activity::create_from_id('1'), 'registration' => 'reg2', 'stateid' => 'cc'], + ['activity' => item_activity::create_from_id('2'), 'registration' => 'reg', 'stateid' => 'dd'], + ['activity' => item_activity::create_from_id('3'), 'stateid' => 'ee'], + ['activity' => item_activity::create_from_id('4'), 'component' => 'other', 'stateid' => 'ff'], + ]; + foreach ($states as $state) { + test_helper::create_state($state, true); + } + + // Make all existing state entries older except form two. + $currenttime = time(); + $timepast = $currenttime - 5; + $DB->set_field('xapi_states', 'timecreated', $timepast); + $DB->set_field('xapi_states', 'timemodified', $timepast); + $DB->set_field('xapi_states', 'timemodified', $currenttime, ['stateid' => 'aa']); + $DB->set_field('xapi_states', 'timemodified', $currenttime, ['stateid' => 'bb']); + $DB->set_field('xapi_states', 'timemodified', $currenttime, ['stateid' => 'dd']); + + // Perform test. + $sincetime = ($since) ? $currenttime - 1 : null; + $store = new state_store($component); + $stateids = $store->get_state_ids($itemid, $USER->id, $registration, $sincetime); + sort($stateids); + + $this->assertEquals($expected, $stateids); + } + + /** + * Data provider for the test_get_state_ids. + * + * @return array + */ + public function get_state_ids_provider(): array { + return [ + 'empty_component' => [ + 'component' => 'empty_component', + 'itemid' => null, + 'registration' => null, + 'since' => null, + 'expected' => [], + ], + 'filter_by_itemid' => [ + 'component' => 'fake_component', + 'itemid' => '1', + 'registration' => null, + 'since' => null, + 'expected' => ['aa', 'bb', 'cc'], + ], + 'filter_by_registration' => [ + 'component' => 'fake_component', + 'itemid' => null, + 'registration' => 'reg', + 'since' => null, + 'expected' => ['bb', 'dd'], + ], + 'filter_by_since' => [ + 'component' => 'fake_component', + 'itemid' => null, + 'registration' => null, + 'since' => true, + 'expected' => ['aa', 'bb', 'dd'], + ], + 'filter_by_itemid_and_registration' => [ + 'component' => 'fake_component', + 'itemid' => '1', + 'registration' => 'reg', + 'since' => null, + 'expected' => ['bb'], + ], + 'filter_by_itemid_registration_since' => [ + 'component' => 'fake_component', + 'itemid' => '1', + 'registration' => 'reg', + 'since' => true, + 'expected' => ['bb'], + ], + 'filter_by_registration_since' => [ + 'component' => 'fake_component', + 'itemid' => null, + 'registration' => 'reg', + 'since' => true, + 'expected' => ['bb', 'dd'], + ], + ]; + } } diff --git a/lib/xapi/upgrade.txt b/lib/xapi/upgrade.txt new file mode 100644 index 00000000000..493dbf5ea7f --- /dev/null +++ b/lib/xapi/upgrade.txt @@ -0,0 +1,15 @@ +This files describes API changes in core_xapi libraries and APIs, +information provided here is intended especially for developers. + +=== 4.2 === + +* A new state store has been introduced. Now plugins can store state data + by overriding the PLUGINNAME\xapi\handler::validate_state method. +* New core_xapi\state_store class to handle the state data storing. Plugins + can provide alternative state store implementations by overriding the + PLUGINNAME\xapi\handler::get_state_store method. +* New xAPI state webservices: + - core_xapi_post_state: store a user state data + - core_xapi_get_state: gets a user state data + - core_xapi_get_states: get the list of user states + - core_xapi_delete_state: delete a user state data diff --git a/version.php b/version.php index d82410223f5..51c93be9e4e 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2023040100.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2023040100.02; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.2dev+ (Build: 20230401)'; // Human-friendly version name