diff --git a/lib/db/services.php b/lib/db/services.php index fe1a70d4237..9853e36006f 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -2821,6 +2821,13 @@ $functions = array( 'capabilities' => '', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'core_xapi_delete_states' => [ + 'classname' => 'core_xapi\external\delete_states', + 'description' => 'Delete all xAPI state data from an activityId.', + 'type' => 'write', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], 'core_contentbank_delete_content' => [ 'classname' => 'core_contentbank\external\delete_content', 'classpath' => '', diff --git a/lib/xapi/classes/external/delete_states.php b/lib/xapi/classes/external/delete_states.php new file mode 100644 index 00000000000..c5033c31934 --- /dev/null +++ b/lib/xapi/classes/external/delete_states.php @@ -0,0 +1,101 @@ +. + +namespace core_xapi\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; +use core_xapi\handler; +use core_xapi\iri; +use core_xapi\xapi_exception; + +/** + * This is the external API for generic xAPI states deletion. + * + * @package core_xapi + * @since Moodle 4.3 + * @copyright 2023 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_states extends external_api { + + use \core_xapi\local\helper\state_trait; + + /** + * Process a state delete 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. + * @return void + */ + public static function execute( + string $component, + string $activityiri, + string $agent, + ?string $registration = null, + ): void { + global $USER; + + [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'registration' => $registration, + ] = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'registration' => $registration, + ]); + + static::validate_component($component); + + $handler = handler::create($component); + + $activityid = iri::extract($activityiri, 'activity'); + $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'); + } + $handler->wipe_states($activityid, $user->id, null, $registration); + } + + /** + * 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) + ]); + } + + /** + * Return for execute. + */ + public static function execute_returns() { + return null; + } +} diff --git a/lib/xapi/tests/external/delete_states_test.php b/lib/xapi/tests/external/delete_states_test.php new file mode 100644 index 00000000000..3dad00aa3be --- /dev/null +++ b/lib/xapi/tests/external/delete_states_test.php @@ -0,0 +1,325 @@ +. + +namespace core_xapi\external; + +use core\context\module; +use core_external\external_api; +use core_xapi\iri; +use core_xapi\local\statement\item_activity; +use core_xapi\local\statement\item_agent; +use core_xapi\test_helper; +use core_xapi\xapi_exception; +use externallib_advanced_testcase; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +/** + * Unit tests for xAPI delete states webservice. + * + * @package core_xapi + * @covers \core_xapi\external\delete_states + * @since Moodle 4.3 + * @copyright 2023 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_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'); + } + + /** + * Testing different component names on valid states. + * + * @dataProvider components_provider + * @param string $component component name + * @param object|null $expected expected results + */ + public function test_component_names(string $component, ?object $expected): void { + global $DB, $USER; + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Perform test. + $info = [ + 'agent' => item_agent::create_from_user($USER), + 'activity' => item_activity::create_from_id('12345'), + ]; + test_helper::create_state($info, true); + if (!empty($expected->exception)) { + $this->expectException($expected->exception); + } + $this->execute($component, + iri::generate($info['activity']->get_id(), 'activity'), + json_encode($info['agent']) + ); + + if (isset($expected->expectedcount)) { + $this->assertEquals($expected->expectedcount, $DB->record_exists('xapi_states', [])); + } + } + + /** + * This function execute the delete_states_data + * + * @param string $component component name + * @param string $activityiri + * @param string $agent + * @param string|null $registration + * @return array empty array + */ + private function execute(string $component, + string $activityiri, + string $agent, + ?string $registration = null + ): void { + $external = $this->get_external_class(); + $external::execute( + $component, + $activityiri, + $agent, + $registration + ); + } + + /** + * 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 delete_states the external class + */ + private function get_external_class(): delete_states { + $ws = new class extends delete_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; + } + + /** + * Data provider for the test_component_names tests. + * + * @return array + */ + public function components_provider(): array { + return [ + 'Inexistent component' => [ + 'component' => 'inexistent_component', + 'expected' => (object) ['exception' => xapi_exception::class], + ], + 'Compatible component' => [ + 'component' => 'fake_component', + 'expected' => (object) ['expectedcount' => 0], + ], + 'Incompatible component' => [ + 'component' => 'core_xapi', + 'expected' => (object) ['exception' => xapi_exception::class], + ], + ]; + } + + /** + * Testing invalid agent. + * + */ + public function test_invalid_agent(): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $other = $this->getDataGenerator()->create_user(); + + // Invalid agent (use different user, instead of the current one). + $info = [ + 'agent' => item_agent::create_from_user($other), + 'activity' => item_activity::create_from_id('12345'), + ]; + test_helper::create_state($info, true); + $this->expectException(xapi_exception::class); + $this->execute( + 'fake_component', + iri::generate($info['activity']->get_id(), 'activity'), + json_encode($info['agent']) + ); + } + + /** + * Testing deleting states + * + * @dataProvider states_provider + * @param string $testedusername + * @param string $testedcomponent + * @param string $testedactivityname + * @param array $states + * @param array $expectedstates + * @return void + */ + public function test_delete_states(string $testedusername, + string $testedcomponent, + string $testedactivityname, + array $states, + array $expectedstates + ): void { + global $DB; + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $course = $this->getDataGenerator()->create_course(); + + $activities = []; + $users = []; + // Create a set of states for different users and components. + foreach ($states as $stateinfo) { + $params = [ + 'component' => $stateinfo['component'] ?? 'mod_h5pactivity', + ]; + $uname = $stateinfo['user']; + $user = $users[$uname] ?? null; + if (empty($user)) { + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $users[$uname] = $user; + } + $activityname = $stateinfo['activity']; + $activity = $activities[$activityname] ?? null; + if (empty($activity)) { + if (empty($stateinfo['activityid'])) { + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $activitycontext = module::instance($activity->cmid); + $activities[$activityname] = item_activity::create_from_id($activitycontext->id); + } else { + $activities[$activityname] = item_activity::create_from_id($stateinfo['activityid']); + } + } + $params['activity'] = $activities[$activityname]; + $params['agent'] = item_agent::create_from_user($user); + test_helper::create_state($params, true); + } + if (empty($users[$testedusername])) { + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $users[$testedusername] = $user; + } + $this->setUser($users[$testedusername]); + $activity = $activities[$testedactivityname]; + $activityiri = iri::generate($activity->get_id(), 'activity'); + $agent = json_encode(item_agent::create_from_user($users[$testedusername])); + $this->execute($testedcomponent, + $activityiri, + $agent); + + $statesleft = $DB->get_records('xapi_states'); + // Check that we have the expected leftover records. + $this->assertCount(count($expectedstates), $statesleft); + foreach ($expectedstates as $expectedstate) { + $expectedactivityid = $activities[$expectedstate['activity']]->get_id(); + $expecteduserid = $users[$expectedstate['user']]->id; + $found = false; + foreach ($statesleft as $state) { + if ($state->userid == $expecteduserid && $state->itemid == $expectedactivityid) { + $found = true; + break; + } + } + $this->assertTrue($found, 'State not found:' . json_encode($statesleft)); + } + } + + /** + * Data provider for the test_get_state tests. + * + * @return array + */ + public function states_provider(): array { + return [ + 'Activities with different users and components' => [ + 'username' => 'user1', + 'component' => 'mod_h5pactivity', + 'activity' => 'Activity 1', + 'states' => [ + [ + 'user' => 'user1', + 'activity' => 'Activity 1', + 'component' => 'mod_h5pactivity' + ], + [ + 'user' => 'user2', + 'activity' => 'Activity 1', + 'component' => 'mod_h5pactivity' + ], + [ + 'user' => 'user1', + 'activity' => 'Activity 3', + 'activityid' => '1', + 'component' => 'core_xapi' + ], + [ + 'user' => 'user1', + 'activity' => 'Activity 1', + 'component' => 'mod_h5pactivity' + ], + ], + 'expectedstatesleft' => [ + ['user' => 'user2', 'activity' => 'Activity 1'], + ['user' => 'user1', 'activity' => 'Activity 3'] + ] + ], + 'Activities with one single user' => [ + 'username' => 'user1', + 'component' => 'mod_h5pactivity', + 'activity' => 'Activity 1', + 'states' => [ + [ + 'user' => 'user1', + 'activity' => 'Activity 1', + 'component' => 'mod_h5pactivity' + ], + [ + 'user' => 'user1', + 'activity' => 'Activity 1', + 'component' => 'mod_h5pactivity' + ], + [ + 'user' => 'user1', + 'activity' => 'Activity 1', + 'component' => 'mod_h5pactivity' + ], + ], + 'expectedstatesleft' => [] + ], + ]; + } +} diff --git a/lib/xapi/upgrade.txt b/lib/xapi/upgrade.txt index 4cc8dbd879f..38e379d2508 100644 --- a/lib/xapi/upgrade.txt +++ b/lib/xapi/upgrade.txt @@ -6,6 +6,8 @@ information provided here is intended especially for developers. a numeric value. This is to avoid problems with the database when using the default state store. If a plugin requires a non numeric activity ID, it must implement its own state store. +* New xAPI state webservices: + - core_xapi_delete_states: delete all user state data for an activity === 4.2 ===