From 03a4abde0f6d38a9d7f55c146dfbb9f3d5d44a77 Mon Sep 17 00:00:00 2001 From: Ferran Recio Date: Wed, 18 Jan 2023 13:06:34 +0100 Subject: [PATCH 1/2] MDL-67789 xapi: Add support to save content state --- admin/settings/server.php | 8 + lang/en/xapi.php | 14 +- lib/adminlib.php | 5 + lib/db/install.xml | 23 +- lib/db/services.php | 27 ++ lib/db/tasks.php | 11 +- lib/db/upgrade.php | 37 ++ lib/xapi/classes/api.php | 69 +++ lib/xapi/classes/external/delete_state.php | 114 +++++ lib/xapi/classes/external/get_state.php | 119 +++++ lib/xapi/classes/external/post_state.php | 119 +++++ lib/xapi/classes/handler.php | 142 +++++- lib/xapi/classes/local/helper/state_trait.php | 99 ++++ lib/xapi/classes/local/state.php | 197 ++++++++ lib/xapi/classes/privacy/provider.php | 214 ++++++++- lib/xapi/classes/state_store.php | 223 +++++++++ lib/xapi/classes/task/state_cleanup_task.php | 44 ++ lib/xapi/tests/api_test.php | 132 ++++++ lib/xapi/tests/external/delete_state_test.php | 241 ++++++++++ lib/xapi/tests/external/get_state_test.php | 234 ++++++++++ lib/xapi/tests/external/post_state_test.php | 225 +++++++++ lib/xapi/tests/fixtures/handler.php | 21 + lib/xapi/tests/handler_test.php | 247 +++++++++- lib/xapi/tests/helper.php | 41 +- lib/xapi/tests/privacy/provider_test.php | 268 +++++++++++ lib/xapi/tests/state_store_test.php | 441 ++++++++++++++++++ .../tests/task/state_cleanup_task_test.php | 87 ++++ mod/h5pactivity/tests/xapi/handler_test.php | 1 - version.php | 2 +- 29 files changed, 3352 insertions(+), 53 deletions(-) create mode 100644 lib/xapi/classes/api.php create mode 100644 lib/xapi/classes/external/delete_state.php create mode 100644 lib/xapi/classes/external/get_state.php create mode 100644 lib/xapi/classes/external/post_state.php create mode 100644 lib/xapi/classes/local/helper/state_trait.php create mode 100644 lib/xapi/classes/local/state.php create mode 100644 lib/xapi/classes/state_store.php create mode 100644 lib/xapi/classes/task/state_cleanup_task.php create mode 100644 lib/xapi/tests/api_test.php create mode 100644 lib/xapi/tests/external/delete_state_test.php create mode 100644 lib/xapi/tests/external/get_state_test.php create mode 100644 lib/xapi/tests/external/post_state_test.php create mode 100644 lib/xapi/tests/privacy/provider_test.php create mode 100644 lib/xapi/tests/state_store_test.php create mode 100644 lib/xapi/tests/task/state_cleanup_task_test.php diff --git a/admin/settings/server.php b/admin/settings/server.php index 3b8d74fc202..21aa7241467 100644 --- a/admin/settings/server.php +++ b/admin/settings/server.php @@ -264,6 +264,14 @@ if ($hassiteconfig) { ] )); + $temp->add(new admin_setting_configduration( + 'xapicleanupperiod', + new lang_string('xapicleanupperiod', 'xapi'), + new lang_string('xapicleanupperiod_help', 'xapi'), + WEEKSECS * 8, + WEEKSECS + )); + $ADMIN->add('server', $temp); $temp->add(new admin_setting_configduration('filescleanupperiod', diff --git a/lang/en/xapi.php b/lang/en/xapi.php index 5b998975ac0..d92131387ba 100644 --- a/lang/en/xapi.php +++ b/lang/en/xapi.php @@ -23,4 +23,16 @@ */ $string['eventxapipost'] = 'Post xAPI statement'; -$string['privacy:metadata'] = 'The xAPI library does not store any personal data.'; +$string['privacy:metadata:component'] = 'The component name in frankenstyle'; +$string['privacy:metadata:itemid'] = 'The item ID of the state'; +$string['privacy:metadata:registration'] = 'The xAPI registration UUID'; +$string['privacy:metadata:statedata'] = 'JSON object with the state data'; +$string['privacy:metadata:stateid'] = 'The xAPI state id.'; +$string['privacy:metadata:timecreated'] = 'The time when the state element was created'; +$string['privacy:metadata:timemodified'] = 'The last time state was updated'; +$string['privacy:metadata:userid'] = 'The ID of the user who belongs the state '; +$string['privacy:metadata:xapi_states'] = 'The stored xAPI states'; +$string['privacy:xapistate'] = 'xAPI state'; +$string['xapicleanup'] = 'Stored xAPI states clean up'; +$string['xapicleanupperiod'] = 'Clean up xAPI states'; +$string['xapicleanupperiod_help'] = 'Remove any stored xAPI which is not updaded in the selected period'; diff --git a/lib/adminlib.php b/lib/adminlib.php index 9a56b85ffe0..3dc2c1dcb78 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -237,6 +237,11 @@ function uninstall_plugin($type, $name) { unset_all_config_for_plugin($pluginname); } + // Wipe any xAPI state information. + if (core_xapi\handler::supports_xapi($component)) { + core_xapi\api::remove_states_from_component($component); + } + // delete message provider message_provider_uninstall($component); diff --git a/lib/db/install.xml b/lib/db/install.xml index 617dd9da70d..275ab4c84c7 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -4762,5 +4762,26 @@ + + + + + + + + + + + + + + + + + + + + +
diff --git a/lib/db/services.php b/lib/db/services.php index fbfd7dbdbbd..352cf977c55 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -2758,6 +2758,33 @@ $functions = array( 'capabilities' => '', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'core_xapi_post_state' => [ + 'classname' => 'core_xapi\external\post_state', + 'classpath' => '', + 'description' => 'Post an xAPI state into an activityId.', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => '', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + 'core_xapi_get_state' => [ + 'classname' => 'core_xapi\external\get_state', + 'classpath' => '', + 'description' => 'Get an xAPI state data from an activityId.', + 'type' => 'read', + 'ajax' => true, + 'capabilities' => '', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + 'core_xapi_delete_state' => [ + 'classname' => 'core_xapi\external\delete_state', + 'classpath' => '', + 'description' => 'Delete an xAPI state data from an activityId.', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => '', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], 'core_contentbank_delete_content' => [ 'classname' => 'core_contentbank\external\delete_content', 'classpath' => '', diff --git a/lib/db/tasks.php b/lib/db/tasks.php index b7eaebfe355..311a95e6c22 100644 --- a/lib/db/tasks.php +++ b/lib/db/tasks.php @@ -436,5 +436,14 @@ $tasks = array( 'day' => '*', 'dayofweek' => '*', 'month' => '*' - ] + ], + [ + 'classname' => 'core_xapi\task\state_cleanup_task', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => '0', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ], ); diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 2662f1ad7e5..f9bf06f6cbf 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -3142,5 +3142,42 @@ privatefiles,moodle|/user/files.php'; upgrade_main_savepoint(true, 2023031000.02); } + if ($oldversion < 2023031400.01) { + + // Define table xapi_states to be created. + $table = new xmldb_table('xapi_states'); + + // Adding fields to table xapi_states. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('component', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('stateid', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('statedata', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('registration', XMLDB_TYPE_CHAR, '255', null, null, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + + // Adding keys to table xapi_states. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Adding indexes to table xapi_states. + $table->add_index('component-itemid', XMLDB_INDEX_NOTUNIQUE, ['component', 'itemid']); + $table->add_index('userid', XMLDB_INDEX_NOTUNIQUE, ['userid']); + $table->add_index('timemodified', XMLDB_INDEX_NOTUNIQUE, ['timemodified']); + + // Conditionally launch create table for xapi_states. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + if (!isset($CFG->xapicleanupperiod)) { + set_config('xapicleanupperiod', WEEKSECS * 8); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2023031400.01); + } + return true; } diff --git a/lib/xapi/classes/api.php b/lib/xapi/classes/api.php new file mode 100644 index 00000000000..fa3c0ec2f02 --- /dev/null +++ b/lib/xapi/classes/api.php @@ -0,0 +1,69 @@ +. + +namespace core_xapi; + +/** + * The xAPI internal API. + * + * @package core_xapi + * @copyright 2023 Ferran Recio + * @since Moodle 4.2 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api { + /** + * Delete all states from a component. + * + * @param string $component The component name in frankenstyle. + * @return void + */ + public static function remove_states_from_component(string $component): void { + global $DB; + + $statestore = null; + $dbman = $DB->get_manager(); + try { + $handler = handler::create($component); + $statestore = $handler->get_state_store(); + } catch (xapi_exception $exception) { + // If the component is not available but the xapi_states table exists, use the standard one to ensure we clean it. + $table = new \xmldb_table('xapi_states'); + if ($dbman->table_exists($table)) { + $statestore = new state_store($component); + } + } + if ($statestore) { + $statestore->wipe(); + } + } + + /** + * Execute the states clean up for all compatible components. + * + * @return void + */ + public static function execute_state_cleanup(): void { + foreach (\core_component::get_plugin_types() as $ptype => $unused) { + $components = \core_component::get_plugin_list_with_class($ptype, 'xapi\handler'); + foreach ($components as $component => $unused) { + $handler = handler::create($component); + $statestore = $handler->get_state_store(); + $statestore->cleanup(); + } + } + } +} diff --git a/lib/xapi/classes/external/delete_state.php b/lib/xapi/classes/external/delete_state.php new file mode 100644 index 00000000000..c5ec935feef --- /dev/null +++ b/lib/xapi/classes/external/delete_state.php @@ -0,0 +1,114 @@ +. + +namespace core_xapi\external; + +use core_xapi\local\state; +use core_xapi\local\statement\item_activity; +use core_xapi\handler; +use core_xapi\xapi_exception; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_value; +use core_xapi\iri; + +/** + * This is the external API for generic xAPI state deletion. + * + * @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 delete_state 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'), + 'stateId' => new external_value(PARAM_ALPHAEXT, 'The xAPI state ID'), + 'registration' => new external_value(PARAM_ALPHANUMEXT, 'The xAPI registration UUID', VALUE_DEFAULT, null), + ]); + } + + /** + * 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 $stateid The xAPI state id. + * @param string|null $registration The xAPI registration UUID. + * @return bool Whether the state has been removed or not. + */ + public static function execute( + string $component, + string $activityiri, + string $agent, + string $stateid, + ?string $registration = null + ): bool { + + $params = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'stateId' => $stateid, + 'registration' => $registration, + ]); + [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'stateId' => $stateid, + 'registration' => $registration, + ] = $params; + + static::validate_component($component); + + $handler = handler::create($component); + $activityid = iri::extract($activityiri, 'activity'); + + $state = new state( + self::get_agent_from_json($agent), + item_activity::create_from_id($activityid), + $stateid, + $registration, + null + ); + + if (!self::check_state_user($state)) { + throw new xapi_exception('State agent is not the current user'); + } + + return $handler->delete_state($state); + } + + /** + * Return for execute. + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_BOOL, 'If the state data is deleted'); + } +} diff --git a/lib/xapi/classes/external/get_state.php b/lib/xapi/classes/external/get_state.php new file mode 100644 index 00000000000..678c151cee9 --- /dev/null +++ b/lib/xapi/classes/external/get_state.php @@ -0,0 +1,119 @@ +. + +namespace core_xapi\external; + +use core_xapi\local\state; +use core_xapi\local\statement\item_activity; +use core_xapi\handler; +use core_xapi\xapi_exception; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_value; +use core_xapi\iri; + +/** + * This is the external API for generic xAPI state get. + * + * @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_state 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'), + 'stateId' => new external_value(PARAM_ALPHAEXT, 'The xAPI state ID'), + 'registration' => new external_value(PARAM_ALPHANUMEXT, 'The xAPI registration UUID', VALUE_DEFAULT, null), + ]); + } + + /** + * Process a get state request. + * + * @param string $component The component name in frankenstyle. + * @param string $activityiri The activity IRI. + * @param string $agent The agent JSON. + * @param string $stateid The xAPI state id. + * @param string|null $registration The xAPI registration UUID. + * @return string|null + */ + public static function execute( + string $component, + string $activityiri, + string $agent, + string $stateid, + ?string $registration = null + ): ?string { + + $params = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'stateId' => $stateid, + 'registration' => $registration, + ]); + [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'stateId' => $stateid, + 'registration' => $registration, + ] = $params; + + static::validate_component($component); + + $handler = handler::create($component); + $activityid = iri::extract($activityiri, 'activity'); + + $state = new state( + self::get_agent_from_json($agent), + item_activity::create_from_id($activityid), + $stateid, + null, + $registration + ); + + if (!self::check_state_user($state)) { + throw new xapi_exception('State agent is not the current user'); + } + + $result = $handler->load_state($state); + if ($result !== null) { + return json_encode($result); + } + + return $result; + } + + /** + * Return for execute. + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_RAW, 'The state data json'); + } +} diff --git a/lib/xapi/classes/external/post_state.php b/lib/xapi/classes/external/post_state.php new file mode 100644 index 00000000000..5432d8eb877 --- /dev/null +++ b/lib/xapi/classes/external/post_state.php @@ -0,0 +1,119 @@ +. + +namespace core_xapi\external; + +use core_xapi\local\state; +use core_xapi\local\statement\item_activity; +use core_xapi\handler; +use core_xapi\xapi_exception; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_value; +use core_xapi\iri; + +/** + * This is the external API for generic xAPI state post. + * + * @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 post_state 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'), + 'stateId' => new external_value(PARAM_ALPHAEXT, 'The xAPI state ID'), + 'stateData' => new external_value(PARAM_RAW, 'JSON object with the state data'), + 'registration' => new external_value(PARAM_ALPHANUMEXT, 'The xAPI registration UUID', VALUE_DEFAULT, null), + ]); + } + + /** + * Process a state post request. + * + * @param string $component The component name in frankenstyle. + * @param string $activityiri The activity IRI. + * @param string $agent The agent JSON. + * @param string $stateid The xAPI state id. + * @param string $statedata JSON object with the state data + * @param string|null $registration The xAPI registration UUID. + * @return bool + */ + public static function execute( + string $component, + string $activityiri, + string $agent, + string $stateid, + string $statedata, + ?string $registration = null + ): bool { + + $params = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'stateId' => $stateid, + 'stateData' => $statedata, + 'registration' => $registration, + ]); + [ + 'component' => $component, + 'activityId' => $activityiri, + 'agent' => $agent, + 'stateId' => $stateid, + 'stateData' => $statedata, + 'registration' => $registration, + ] = $params; + + static::validate_component($component); + + $handler = handler::create($component); + $activityid = iri::extract($activityiri, 'activity'); + + $state = new state( + self::get_agent_from_json($agent), + item_activity::create_from_id($activityid), + $stateid, + self::get_statedata_from_json($statedata), + $registration + ); + + if (!self::check_state_user($state)) { + throw new xapi_exception('State agent is not the current user'); + } + + return $handler->save_state($state); + } + + /** + * Return for execute. + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_BOOL, 'If the state is accepted'); + } +} diff --git a/lib/xapi/classes/handler.php b/lib/xapi/classes/handler.php index 8c0b2c457b2..1e167a45887 100644 --- a/lib/xapi/classes/handler.php +++ b/lib/xapi/classes/handler.php @@ -14,34 +14,28 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +namespace core_xapi; + +use core_xapi\local\state; +use core_xapi\local\statement; +use core_xapi\xapi_exception; + /** - * The core_xapi statement validation and tansformation. + * Class handler handles basic xAPI statements and states. * * @package core_xapi * @since Moodle 3.9 * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -namespace core_xapi; - -use core_xapi\local\statement; -use core_xapi\xapi_exception; -use stdClass; - -defined('MOODLE_INTERNAL') || die(); - -/** - * Class handler handles basic xapi statements. - * - * @package core_xapi - * @copyright 2020 Ferran Recio - */ abstract class handler { /** @var string component name in frankenstyle. */ protected $component; + /** @var state_store the state_store instance. */ + protected $statestore; + /** * Constructor for a xAPI handler base class. * @@ -49,6 +43,7 @@ abstract class handler { */ final protected function __construct(string $component) { $this->component = $component; + $this->statestore = $this->get_state_store(); } /** @@ -59,13 +54,24 @@ abstract class handler { * @throws xapi_exception */ final public static function create(string $component): self { - $classname = "\\$component\\xapi\\handler"; - if (class_exists($classname)) { + if (self::supports_xapi($component)) { + $classname = "\\$component\\xapi\\handler"; return new $classname($component); } throw new xapi_exception('Unknown handler'); } + /** + * Whether a component supports (and implements) xAPI. + * + * @param string $component the component name in frankenstyle. + * @return bool true if the given component implements xAPI handler; false otherwise. + */ + final public static function supports_xapi(string $component): bool { + $classname = "\\$component\\xapi\\handler"; + return class_exists($classname); + } + /** * Convert a statement object into a Moodle xAPI Event. * @@ -115,4 +121,104 @@ abstract class handler { } return $result; } + + /** + * Validate a xAPI state. + * + * Check if the state is valid for this handler. + * + * This method is used also for the state get requests so the validation + * cannot rely on having state data. + * + * Note: this method must be overridden by the plugins which want to use xAPI states. + * + * @param state $state + * @return bool if the state is valid or not + */ + abstract protected function validate_state(state $state): bool; + + /** + * Process a state save request. + * + * @param state $state the state object + * @return bool if the state can be saved + */ + public function save_state(state $state): bool { + if (!$this->validate_state($state)) { + throw new xapi_exception('The state is not accepted, so it cannot be saved'); + } + return $this->statestore->put($state); + } + + /** + * Process a state save request. + * + * @param state $state the state object + * @return state|null the resulting loaded state + */ + public function load_state(state $state): ?state { + if (!$this->validate_state($state)) { + throw new xapi_exception('The state is not accepted, so it cannot be loaded'); + } + $state = $this->statestore->get($state); + return $state; + } + + /** + * Process a state delete request. + * + * @param state $state the state object + * @return bool if the deletion is successful + */ + public function delete_state(state $state): bool { + if (!$this->validate_state($state)) { + throw new xapi_exception('The state is not accepted, so it cannot be deleted'); + } + return $this->statestore->delete($state); + } + + /** + * Delete all states from this component. + * + * @param string|null $itemid + * @param int|null $userid + * @param string|null $stateid + * @param string|null $registration + */ + public function wipe_states( + ?string $itemid = null, + ?int $userid = null, + ?string $stateid = null, + ?string $registration = null + ): void { + $this->statestore->wipe($itemid, $userid, $stateid, $registration); + } + + /** + * Reset all states from this component. + * + * @param string|null $itemid + * @param int|null $userid + * @param string|null $stateid + * @param string|null $registration + */ + public function reset_states( + ?string $itemid = null, + ?int $userid = null, + ?string $stateid = null, + ?string $registration = null + ): void { + $this->statestore->reset($itemid, $userid, $stateid, $registration); + } + + /** + * Return a valor state store for this component. + * + * Plugins may override this method is they want to use a different + * state store class. + * @return state_store the store to use to get/put/delete states. + */ + public function get_state_store(): state_store { + return new state_store($this->component); + } } diff --git a/lib/xapi/classes/local/helper/state_trait.php b/lib/xapi/classes/local/helper/state_trait.php new file mode 100644 index 00000000000..2f865ff118c --- /dev/null +++ b/lib/xapi/classes/local/helper/state_trait.php @@ -0,0 +1,99 @@ +. + +namespace core_xapi\local\helper; + +use core_component; +use core_xapi\local\state; +use core_xapi\local\statement\item_agent; +use core_xapi\xapi_exception; +use JsonException; +use stdClass; + +/** + * State trait helper, with common methods. + * + * @package core_xapi + * @since Moodle 4.2 + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait state_trait { + /** + * Check component name. + * + * Note: this function is separated mainly for testing purposes to + * be overridden to fake components. + * + * @throws xapi_exception if component is not available + * @param string $component component name + */ + protected static function validate_component(string $component): void { + // Check that $component is a real component name. + $dir = core_component::get_component_directory($component); + if (!$dir) { + throw new xapi_exception("Component $component not available."); + } + } + + /** + * Convert a JSON agent into a valid item_agent. + * + * @throws xapi_exception if JSON cannot be parsed + * @param string $agentjson JSON encoded agent structure + * @return item_agent the agent + */ + private static function get_agent_from_json(string $agentjson): item_agent { + try { + $agentdata = json_decode($agentjson, null, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new xapi_exception('No agent detected'); + } + return item_agent::create_from_data($agentdata); + } + + /** + * Check that $USER is actor in state. + * + * @param state $state The state + * @return bool if $USER is actor of the state + */ + private static function check_state_user(state $state): bool { + global $USER; + $user = $state->get_user(); + if ($user->id != $USER->id) { + return false; + } + return true; + } + + /** + * Convert the state data JSON into valid object. + * + * @throws xapi_exception if JSON cannot be parsed + * @param string $statedatajson JSON encoded structure + * @return stdClass the state data structure + */ + private static function get_statedata_from_json(string $statedatajson): stdClass { + try { + // Force it to be an object, because some statedata might be sent as array instead of JSON. + $statedata = json_decode($statedatajson, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new xapi_exception('Invalid state data format'); + } + return $statedata; + } +} diff --git a/lib/xapi/classes/local/state.php b/lib/xapi/classes/local/state.php new file mode 100644 index 00000000000..1850e13a142 --- /dev/null +++ b/lib/xapi/classes/local/state.php @@ -0,0 +1,197 @@ +. + +namespace core_xapi\local; + +use core_xapi\local\statement\item_agent; +use core_xapi\local\statement\item_activity; +use JsonSerializable; +use stdClass; + +/** + * State resource object for xAPI structure checking and validation. + * + * @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 state implements JsonSerializable { + + /** @var item_agent The state agent (user). */ + protected $agent = null; + + /** @var item_activity The state activity owner (the plugin instance). */ + protected $activity = null; + + /** @var string The state identifier. */ + protected $stateid = null; + + /** @var stdClass|null The state data. */ + protected $statedata = null; + + /** @var string|null The state registration. */ + protected $registration = null; + + /** + * State constructor. + * + * @param item_agent $agent The state agent (user) + * @param item_activity $activity The state activity owner + * @param string $stateid The state identifier + * @param stdClass|null $statedata The state data + * @param string|null $registration The state registration + */ + public function __construct( + item_agent $agent, + item_activity $activity, + string $stateid, + ?stdClass $statedata, + ?string $registration + ) { + $this->agent = $agent; + $this->activity = $activity; + $this->stateid = $stateid; + $this->statedata = $statedata; + $this->registration = $registration; + } + + /** + * Return the data to serialize in case JSON state when needed. + * + * @return stdClass The state data structure + */ + public function jsonSerialize(): stdClass { + if ($this->statedata) { + return $this->statedata; + } + + return new stdClass(); + } + + /** + * Return the record data of this state. + * + * @return stdClass the record data structure + */ + public function get_record_data(): stdClass { + $result = (object) [ + 'userid' => $this->get_user()->id, + 'itemid' => $this->get_activity_id(), + 'stateid' => $this->stateid, + 'statedata' => json_encode($this), + 'registration' => $this->registration, + ]; + return $result; + } + + /** + * Returns a minified version of a given state. + * + * The returned structure is suitable to store in the "other" field + * of logstore. xAPI standard specifies a list of attributes that can be calculated + * instead of stored literally. This function get rid of these attributes. + * + * Note: it also converts stdClass to assoc array to make it compatible + * with "other" field in the logstore + * + * @return array the minimal state needed to be stored a part from logstore data + */ + public function minify(): ?array { + $result = []; + $fields = ['activity', 'stateid', 'statedata', 'registration']; + foreach ($fields as $field) { + if (!empty($this->$field)) { + $result[$field] = $this->$field; + } + } + return json_decode(json_encode($result), true); + } + + /** + * Set the state data. + * + * @param stdClass|null $statedata the state data + */ + public function set_state_data(?stdClass $statedata): void { + $this->statedata = $statedata; + } + + /** + * Returns the state data. + * For getting the JSON representation of this state data, use jsonSerialize(). + * + * @return stdClass|null The state data object. + */ + public function get_state_data(): ?stdClass { + return $this->statedata; + } + + /** + * Returns the moodle user represented by this state agent. + * + * @return stdClass user record + */ + public function get_user(): stdClass { + return $this->agent->get_user(); + } + + /** + * Returns the state activity ID. + * + * @return string activity ID + */ + public function get_activity_id(): string { + return $this->activity->get_id(); + } + + /** + * Return the state agent. + * + * @return item_agent + */ + public function get_agent(): item_agent { + return $this->agent; + } + + /** + * Return the state object if it is defined. + * + * @return item_activity|null + */ + public function get_activity(): ?item_activity { + return $this->activity; + } + + /** + * Returns the state id. + * + * @return string state identifier + */ + public function get_state_id(): string { + return $this->stateid; + } + + /** + * Returns the state registration if any. + * + * @return string|null state registration + */ + public function get_registration(): ?string { + return $this->registration; + } + +} diff --git a/lib/xapi/classes/privacy/provider.php b/lib/xapi/classes/privacy/provider.php index b74a581f41b..cfdb2a57d1a 100644 --- a/lib/xapi/classes/privacy/provider.php +++ b/lib/xapi/classes/privacy/provider.php @@ -14,33 +14,215 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Privacy Subsystem implementation for core xAPI Library. - * - * @package core_xapi - * @copyright 2020 Ferran Recio - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core_xapi\privacy; -defined('MOODLE_INTERNAL') || die(); +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\transform; /** - * Privacy Subsystem for core_xapi implementing null_provider. + * Privacy implementation for core xAPI Library. * + * @package core_xapi + * @category privacy * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class provider implements \core_privacy\local\metadata\null_provider { +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\subsystem\plugin_provider, + \core_privacy\local\request\shared_userlist_provider { /** - * Get the language string identifier with the component's language - * file to explain why this plugin stores no data. + * Return the fields which contain personal data. * - * @return string + * @param collection $collection The initialised collection to add items to. + * @return collection A listing of user data stored through this system. */ - public static function get_reason(): string { - return 'privacy:metadata'; + public static function get_metadata(collection $collection) : collection { + $collection->add_database_table('xapi_states', [ + 'component' => 'privacy:metadata:component', + 'userid' => 'privacy:metadata:userid', + 'itemid' => 'privacy:metadata:itemid', + 'stateid' => 'privacy:metadata:stateid', + 'statedata' => 'privacy:metadata:statedata', + 'registration' => 'privacy:metadata:registration', + 'timecreated' => 'privacy:metadata:timecreated', + 'timemodified' => 'privacy:metadata:timemodified', + ], 'privacy:metadata:xapi_states'); + + return $collection; + } + + /** + * Provide a list of contexts which have xAPI for the user, in the respective area (component/itemtype combination). + * + * This method is to be called by consumers of the xAPI subsystem (plugins), in their get_contexts_for_userid() method, + * to add the contexts for items which may have xAPI data, but would normally not be reported as having user data by the + * plugin responsible for them. + * + * @param \core_privacy\local\request\contextlist $contextlist + * @param int $userid The id of the user in scope. + * @param string $component the frankenstyle component name. + */ + public static function add_contexts_for_userid( + \core_privacy\local\request\contextlist $contextlist, + int $userid, + string $component) { + $sql = "SELECT ctx.id + FROM {xapi_states} xs + JOIN {context} ctx + ON ctx.id = xs.itemid + WHERE xs.userid = :userid + AND xs.component = :component"; + + $params = ['userid' => $userid, 'component' => $component]; + + $contextlist->add_from_sql($sql, $params); + } + + /** + * Add users to a userlist who have xAPI within the specified context. + * + * @param \core_privacy\local\request\userlist $userlist The userlist to add the users to. + * @return void + */ + public static function add_userids_for_context(\core_privacy\local\request\userlist $userlist) { + if (empty($userlist)) { + return; + } + + $params = [ + 'contextid' => $userlist->get_context()->id, + 'component' => $userlist->get_component() + ]; + + $sql = "SELECT xs.userid + FROM {xapi_states} xs + JOIN {context} ctx + ON ctx.id = xs.itemid + WHERE ctx.id = :contextid + AND xs.component = :component"; + + $userlist->add_from_sql('userid', $sql, $params); + } + + /** + * Get xAPI states data for the specified user in the specified component and item ID. + * + * @param int $userid The id of the user in scope. + * @param string $component The component name. + * @param int $itemid The item ID. + * @return array|null + */ + public static function get_xapi_states_for_user(int $userid, string $component, int $itemid) { + global $DB; + + $params = [ + 'userid' => $userid, + 'component' => $component, + 'itemid' => $itemid, + ]; + + if (!$states = $DB->get_records('xapi_states', $params)) { + return; + } + + $result = []; + foreach ($states as $state) { + $result[] = [ + 'statedata' => $state->statedata, + 'timecreated' => transform::datetime($state->timecreated), + 'timemodified' => transform::datetime($state->timemodified) + ]; + } + + return $result; + } + + /** + * Delete all xAPI states for all users in the specified contexts, and component area. + * + * @param \context $context The context to which deletion is scoped. + * @param string $component The component name. + * @throws \dml_exception if any errors are encountered during deletion. + */ + public static function delete_states_for_all_users(\context $context, string $component) { + global $DB; + + $params = [ + 'component' => $component, + ]; + + $select = "component = :component"; + + if (!empty($context)) { + $select .= " AND itemid = :itemid"; + $params['itemid'] = $context->id; + } + $DB->delete_records_select('xapi_states', $select, $params); + } + + /** + * Delete all xAPI states for the specified users in the specified context, component area and item type. + * + * @param \core_privacy\local\request\approved_userlist $userlist The approved contexts and user information + * to delete information for. + * @param int $itemid Optional itemid associated with component. + * @throws \dml_exception if any errors are encountered during deletion. + */ + public static function delete_states_for_userlist(\core_privacy\local\request\approved_userlist $userlist, int $itemid = 0) { + global $DB; + + $userids = $userlist->get_userids(); + + if (empty($userids)) { + return; + } + + list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + + $params = [ + 'component' => $userlist->get_component(), + ]; + + $params += $userparams; + $select = "component = :component AND userid $usersql"; + + if (!empty($itemid)) { + $select .= " AND itemid = :itemid"; + $params['itemid'] = $itemid; + } + + $DB->delete_records_select('xapi_states', $select, $params); + } + + /** + * Delete all xAPI states for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + * @param string $component The component name. + * @param int $itemid Optional itemid associated with component. + * @throws \coding_exception + * @throws \dml_exception + */ + public static function delete_states_for_user(approved_contextlist $contextlist, string $component, int $itemid = 0) { + global $DB; + + $userid = $contextlist->get_user()->id; + + $params = [ + 'userid' => $userid, + 'component' => $component, + ]; + + $select = "userid = :userid AND component = :component"; + + if (!empty($itemid)) { + $select .= " AND itemid = :itemid"; + $params['itemid'] = $itemid; + } + + $DB->delete_records_select('xapi_states', $select, $params); } } diff --git a/lib/xapi/classes/state_store.php b/lib/xapi/classes/state_store.php new file mode 100644 index 00000000000..0cf4142a99a --- /dev/null +++ b/lib/xapi/classes/state_store.php @@ -0,0 +1,223 @@ +. + +namespace core_xapi; + +use core_xapi\local\state; + +/** + * The state store manager. + * + * @package core_xapi + * @since Moodle 4.2 + * @copyright 2022 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state_store { + + /** @var string component name in frankenstyle. */ + protected $component; + + /** + * Constructor for a xAPI handler base class. + * + * @param string $component the component name + */ + public function __construct(string $component) { + $this->component = $component; + } + + /** + * Delete any extra state data stored in the database. + * + * This method will be called only if the state is accepted by validate_state. + * + * Plugins may override this method add extra clean up tasks to the deletion. + * + * @param state $state + * @return bool if the state is removed + */ + public function delete(state $state): bool { + global $DB; + $data = [ + 'component' => $this->component, + 'userid' => $state->get_user()->id, + 'itemid' => $state->get_activity_id(), + 'stateid' => $state->get_state_id(), + 'registration' => $state->get_registration(), + ]; + return $DB->delete_records('xapi_states', $data); + } + + /** + * Get a state object from the database. + * + * This method will be called only if the state is accepted by validate_state. + * + * Plugins may override this method if they store some data in different tables. + * + * @param state $state + * @return state|null the state + */ + public function get(state $state): ?state { + global $DB; + $data = [ + 'component' => $this->component, + 'userid' => $state->get_user()->id, + 'itemid' => $state->get_activity_id(), + 'stateid' => $state->get_state_id(), + 'registration' => $state->get_registration(), + ]; + $record = $DB->get_record('xapi_states', $data); + if ($record) { + $statedata = null; + if ($record->statedata !== null) { + $statedata = json_decode($record->statedata, null, 512, JSON_THROW_ON_ERROR); + } + $state->set_state_data($statedata); + return $state; + } + + return null; + } + + /** + * Inserts an state object into the database. + * + * This method will be called only if the state is accepted by validate_state. + * + * Plugins may override this method if they store some data in different tables. + * + * @param state $state + * @return bool if the state is inserted/updated + */ + public function put(state $state): bool { + global $DB; + $data = [ + 'component' => $this->component, + 'userid' => $state->get_user()->id, + 'itemid' => $state->get_activity_id(), + 'stateid' => $state->get_state_id(), + 'registration' => $state->get_registration(), + ]; + $record = $DB->get_record('xapi_states', $data) ?: (object) $data; + if (isset($record->id)) { + $record->statedata = json_encode($state->jsonSerialize()); + $record->timemodified = time(); + $result = $DB->update_record('xapi_states', $record); + } else { + $data['statedata'] = json_encode($state->jsonSerialize()); + $data['timecreated'] = time(); + $data['timemodified'] = $data['timecreated']; + $result = $DB->insert_record('xapi_states', $data); + } + return $result ? true : false; + } + + /** + * Reset all states from the component. + * The given parameters are filters to decide the states to reset. If no parameters are defined, the only filter applied + * will be the component. + * + * Plugins may override this method if they store some data in different tables. + * + * @param string|null $itemid + * @param int|null $userid + * @param string|null $stateid + * @param string|null $registration + */ + public function reset( + ?string $itemid = null, + ?int $userid = null, + ?string $stateid = null, + ?string $registration = null + ): void { + global $DB; + + $data = [ + 'component' => $this->component, + ]; + if ($itemid) { + $data['itemid'] = $itemid; + } + if ($userid) { + $data['userid'] = $userid; + } + if ($stateid) { + $data['stateid'] = $stateid; + } + if ($registration) { + $data['registration'] = $registration; + } + $DB->set_field('xapi_states', 'statedata', null, $data); + } + + /** + * Remove all states from the component + * The given parameters are filters to decide the states to wipe. If no parameters are defined, the only filter applied + * will be the component. + * + * Plugins may override this method if they store some data in different tables. + * + * @param string|null $itemid + * @param int|null $userid + * @param string|null $stateid + * @param string|null $registration + */ + public function wipe( + ?string $itemid = null, + ?int $userid = null, + ?string $stateid = null, + ?string $registration = null + ): void { + global $DB; + $data = [ + 'component' => $this->component, + ]; + if ($itemid) { + $data['itemid'] = $itemid; + } + if ($userid) { + $data['userid'] = $userid; + } + if ($stateid) { + $data['stateid'] = $stateid; + } + if ($registration) { + $data['registration'] = $registration; + } + $DB->delete_records('xapi_states', $data); + } + + /** + * Execute a state store clean up. + * + * Plugins can override this methos to provide an alternative clean up logic. + */ + public function cleanup(): void { + global $DB; + $xapicleanupperiod = get_config('core', 'xapicleanupperiod'); + if (empty($xapicleanupperiod)) { + return; + } + $todelete = time() - $xapicleanupperiod; + $DB->delete_records_select( + 'xapi_states', + 'component = :component AND timemodified < :todelete', + ['component' => $this->component, 'todelete' => $todelete] + ); + } +} diff --git a/lib/xapi/classes/task/state_cleanup_task.php b/lib/xapi/classes/task/state_cleanup_task.php new file mode 100644 index 00000000000..8039639af5b --- /dev/null +++ b/lib/xapi/classes/task/state_cleanup_task.php @@ -0,0 +1,44 @@ +. + +namespace core_xapi\task; + +/** + * A scheduled task to clear up old xAPI state data. + * + * @package core_xapi + * @since Moodle 4.2 + * @copyright 2022 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state_cleanup_task extends \core\task\scheduled_task { + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('xapicleanup', 'xapi'); + } + + /** + * Run task. + */ + public function execute() { + \core_xapi\api::execute_state_cleanup(); + } +} diff --git a/lib/xapi/tests/api_test.php b/lib/xapi/tests/api_test.php new file mode 100644 index 00000000000..dd6848f55a0 --- /dev/null +++ b/lib/xapi/tests/api_test.php @@ -0,0 +1,132 @@ +. + +namespace core_xapi; + +use core_xapi\local\statement\item_activity; +use advanced_testcase; + +/** + * Contains test cases for testing xAPI API base methods. + * + * @package core_xapi + * @since Moodle 4.2 + * @covers \core_xapi\api + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api_test extends 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 remove_states_from_component method. + * + * @return void + */ + public function test_remove_states_from_component(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Add a few xAPI state records to database. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('3')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('4')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('6'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('7'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('8'), 'component' => 'unexisting'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('9'), 'component' => 'unexisting'], true); + + // Check no state has been removed (because there are no entries for the another_component). + api::remove_states_from_component('another_component'); + $this->assertEquals(9, $DB->count_records('xapi_states')); + + // Check states for the fake_component have been removed. + api::remove_states_from_component('fake_component'); + $this->assertEquals(5, $DB->count_records('xapi_states')); + $this->assertEquals(0, $DB->count_records('xapi_states', ['component' => 'fake_component'])); + $this->assertEquals(3, $DB->count_records('xapi_states', ['component' => 'mod_h5pactivity'])); + $this->assertEquals(2, $DB->count_records('xapi_states', ['component' => 'unexisting'])); + + // Check states for the mod_h5pactivity have been removed too. + api::remove_states_from_component('mod_h5pactivity'); + $this->assertEquals(2, $DB->count_records('xapi_states')); + $this->assertEquals(0, $DB->count_records('xapi_states', ['component' => 'mod_h5pactivity'])); + + // Check states for the unexisting component have been removed (using the default state_store). + api::remove_states_from_component('unexisting'); + $this->assertEquals(0, $DB->count_records('xapi_states')); + } + + /** + * Testing execute_state_cleanup method. + * + * @return void + */ + public function test_execute_state_cleanup(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Add a few xAPI state records to database. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('3')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('4')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('6'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('7'), 'component' => 'mod_h5pactivity'], true); + + // Perform test. + api::execute_state_cleanup(); + + // Check no state has been removed yet (because the entries are not old enough). + $this->assertEquals(7, $DB->count_records('xapi_states')); + + // Make the existing state entries older. + $timepast = time() - 2; + $DB->set_field('xapi_states', 'timecreated', $timepast); + $DB->set_field('xapi_states', 'timemodified', $timepast); + + // Create 1 more state, that shouldn't be removed after the cleanup. + test_helper::create_state(['activity' => item_activity::create_from_id('8'), 'component' => 'mod_h5pactivity'], true); + + // Set the config to remove states older than 1 second. + set_config('xapicleanupperiod', 1); + + // Check old states have been removed. + api::execute_state_cleanup(); + $this->assertEquals(5, $DB->count_records('xapi_states')); + $this->assertEquals(4, $DB->count_records('xapi_states', ['component' => 'fake_component'])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['component' => 'mod_h5pactivity'])); + $this->assertEquals(0, $DB->count_records('xapi_states', ['component' => 'my_component'])); + } +} diff --git a/lib/xapi/tests/external/delete_state_test.php b/lib/xapi/tests/external/delete_state_test.php new file mode 100644 index 00000000000..9cc7bf3c88f --- /dev/null +++ b/lib/xapi/tests/external/delete_state_test.php @@ -0,0 +1,241 @@ +. + +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 delete state webservice. + * + * @package core_xapi + * @covers \core_xapi\external\post_state + * @since Moodle 4.2 + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_state_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 string|null $expected expected results + */ + public function test_component_names(string $component, ?string $expected): void { + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Perform test. + $data = test_helper::create_state([], true); + $this->delete_state_data($component, $data, $expected); + } + + /** + * Data provider for the test_component_names tests. + * + * @return array + */ + public function components_provider() : array { + return [ + 'Inexistent component' => [ + 'component' => 'inexistent_component', + 'expected' => null, + ], + 'Compatible component' => [ + 'component' => 'fake_component', + 'expected' => 'true', + ], + 'Incompatible component' => [ + 'component' => 'core_xapi', + 'expected' => null, + ], + ]; + } + + /** + * 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), + ]; + $data = test_helper::create_state($info, true); + $this->delete_state_data('fake_component', $data, null); + } + + /** + * Testing valid/invalid state. + * + * @dataProvider states_provider + * @param array $info array of overriden state data. + * @param string|null $expected Expected results. + * @return void + */ + public function test_delete_state(array $info, ?string $expected): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = $info['component'] ?? 'fake_component'; + $params = []; + if ($component === 'mod_h5pactivity') { + // For the mod_h5pactivity component, the activity needs to be created too. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + + $activitycontext = \context_module::instance($activity->cmid); + $info['activity'] = item_activity::create_from_id($activitycontext->id); + $params['activity'] = $info['activity']; + $this->setUser($user); + } + + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state($params, true); + + // Perform test. + $data = test_helper::create_state($info); + + $this->delete_state_data($component, $data, $expected); + } + + /** + * Data provider for the test_get_state tests. + * + * @return array + */ + public function states_provider() : array { + return [ + 'Existing and valid state' => [ + 'info' => [], + 'expected' => 'true', + ], + 'No state (wrong activityid)' => [ + 'info' => ['activity' => item_activity::create_from_id('1')], + 'expected' => 'false', + ], + 'No state (wrong stateid)' => [ + 'info' => ['stateid' => 'food'], + 'expected' => 'false', + ], + 'No state (wrong component)' => [ + 'info' => ['component' => 'mod_h5pactivity'], + 'expected' => 'false', + ], + ]; + } + + /** + * 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_state the external class + */ + private function get_external_class(): delete_state { + $ws = new class extends delete_state { + /** + * 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; + } + + /** + * This function do all checks from a standard delete_state request. + * + * The reason for this function is because states crafting (special in error + * scenarios) is complicated to do via data providers because every test need a specific + * testing conditions. For this reason alls tests creates a scenario and then uses this + * function to check the results. + * + * @param string $component component name + * @param state $data data to encode and send to post_state + * @param string $expected expected results (if null an exception is expected) + */ + private function delete_state_data(string $component, state $data, ?string $expected): void { + global $DB; + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // When no result is expected, an exception will be incurred. + if (is_null($expected)) { + $this->expectException(xapi_exception::class); + } + + $external = $this->get_external_class(); + $result = $external::execute( + $component, + iri::generate($data->get_activity_id(), 'activity'), + json_encode($data->get_agent()), + $data->get_state_id(), + $data->get_registration() + ); + $result = external_api::clean_returnvalue($external::execute_returns(), $result); + + // Check the state has been removed. + $records = $DB->get_records('xapi_states'); + $this->assertTrue($result); + if ($expected === 'true') { + $this->assertCount($currentstates - 1, $records); + } else if ($expected === 'false') { + $this->assertCount($currentstates, $records); + } + } +} diff --git a/lib/xapi/tests/external/get_state_test.php b/lib/xapi/tests/external/get_state_test.php new file mode 100644 index 00000000000..56f9e23891c --- /dev/null +++ b/lib/xapi/tests/external/get_state_test.php @@ -0,0 +1,234 @@ +. + +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 state webservice. + * + * @package core_xapi + * @covers \core_xapi\external\get_state + * @since Moodle 4.2 + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class get_state_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 string|null $expected expected results + */ + public function test_component_names(string $component, ?string $expected): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Add, at least, one xAPI state record to database. + $data = test_helper::create_state([], true); + + // Perform test. + $this->get_state_data($component, $data, $expected); + } + + /** + * Data provider for the test_component_names tests. + * + * @return array + */ + public function components_provider() : array { + return [ + 'Inexistent component' => [ + 'component' => 'inexistent_component', + 'expected' => null, + ], + 'Compatible component' => [ + 'component' => 'fake_component', + 'expected' => 'true', + ], + 'Incompatible component' => [ + 'component' => 'core_xapi', + 'expected' => null, + ], + ]; + } + + /** + * Testing invalid agent for get_state. + * + */ + 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), + ]; + $data = test_helper::create_state($info, true); + $this->get_state_data('fake_component', $data, null); + } + + /** + * Testing valid/invalid state. + * + * @dataProvider states_provider + * @param array $info The xAPI state information (to override default values). + * @param string $expected Expected results. + */ + public function test_get_state(array $info, string $expected): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = $info['component'] ?? 'fake_component'; + $params = []; + if ($component === 'mod_h5pactivity') { + // For the mod_h5pactivity component, the activity needs to be created too. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + + $activitycontext = \context_module::instance($activity->cmid); + $info['activity'] = item_activity::create_from_id($activitycontext->id); + $params['activity'] = $info['activity']; + $this->setUser($user); + } + + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state($params, true); + + // Perform test. + $data = test_helper::create_state($info); + $component = $info['component'] ?? 'fake_component'; + $this->get_state_data($component, $data, $expected); + } + + /** + * Data provider for the test_get_state tests. + * + * @return array + */ + public function states_provider() : array { + return [ + 'Existing and valid state' => [ + 'info' => [], + 'expected' => 'true', + ], + 'No state (wrong activityid)' => [ + 'info' => ['activity' => item_activity::create_from_id('1')], + 'expected' => 'false', + ], + 'No state (wrong stateid)' => [ + 'info' => ['stateid' => 'food'], + 'expected' => 'false', + ], + 'No state (wrong component)' => [ + 'info' => ['component' => 'mod_h5pactivity'], + 'expected' => 'false', + ], + ]; + } + + /** + * 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_state the external class + */ + private function get_external_class(): get_state { + $ws = new class extends get_state { + /** + * 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; + } + + /** + * This function do all checks from a standard get_state request. + * + * The reason for this function is because states crafting (special in error + * scenarios) is complicated to do via data providers because every test need a specific + * testing conditions. For this reason alls tests creates a scenario and then uses this + * function to check the results. + * + * @param string $component component name + * @param state $data data to encode and send to get_state + * @param string $expected expected results (if null an exception is expected) + */ + private function get_state_data(string $component, state $data, ?string $expected): void { + // When no result is expected, an exception will be incurred. + if (is_null($expected)) { + $this->expectException(xapi_exception::class); + } + + $external = $this->get_external_class(); + $result = $external::execute( + $component, + iri::generate($data->get_activity_id(), 'activity'), + json_encode($data->get_agent()), + $data->get_state_id(), + $data->get_registration() + ); + $result = external_api::clean_returnvalue($external::execute_returns(), $result); + + // Check the returned state has the expected values. + if ($expected === 'true') { + $this->assertEquals(json_encode($data->jsonSerialize()), $result); + } else { + $this->assertNull($result); + } + } +} diff --git a/lib/xapi/tests/external/post_state_test.php b/lib/xapi/tests/external/post_state_test.php new file mode 100644 index 00000000000..f8878c16e51 --- /dev/null +++ b/lib/xapi/tests/external/post_state_test.php @@ -0,0 +1,225 @@ +. + +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\test_helper; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +/** + * Unit tests for xAPI post state webservice. + * + * @package core_xapi + * @covers \core_xapi\external\post_state + * @since Moodle 4.2 + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class post_state_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 string|null $expected expected results + */ + public function test_component_names(string $component, ?string $expected): void { + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Perform test. + $data = test_helper::create_state(); + $this->post_state_data($component, $data, $expected); + } + + /** + * Data provider for the test_component_names tests. + * + * @return array + */ + public function components_provider() : array { + return [ + 'Inexistent component' => [ + 'component' => 'inexistent_component', + 'expected' => null, + ], + 'Compatible component' => [ + 'component' => 'fake_component', + 'expected' => 'true', + ], + 'Incompatible component' => [ + 'component' => 'core_xapi', + 'expected' => null, + ], + ]; + } + + /** + * 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), + ]; + $data = test_helper::create_state($info); + $this->post_state_data('fake_component', $data, null); + } + + /** + * Testing valid/invalid state. + * + * @dataProvider states_provider + * @param string $stateid The xAPI state id. + * @param string|null $expected Expected results. + * @return void + */ + public function test_post_state(string $stateid, ?string $expected): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Perform test. + $info = [ + 'stateid' => $stateid, + ]; + $data = test_helper::create_state($info); + $this->post_state_data('fake_component', $data, $expected); + } + + /** + * Data provider for the test_post_state tests. + * + * @return array + */ + public function states_provider() : array { + return [ + 'Empty stateid' => [ + 'stateid' => '', + 'expected' => 'true', + ], + 'Valid stateid (any value but paella)' => [ + 'stateid' => 'sangria', + 'expected' => 'true', + ], + 'Invalid stateid' => [ + 'stateid' => 'paella', + 'expected' => null, + ], + ]; + } + + /** + * 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 post_state the external class + */ + private function get_external_class(): post_state { + $ws = new class extends post_state { + /** + * 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; + } + + /** + * This function do all checks from a standard post_state request. + * + * The reason for this function is because states crafting (special in error + * scenarios) is complicated to do via data providers because every test need a specific + * testing conditions. For this reason alls tests creates a scenario and then uses this + * function to check the results. + * + * @param string $component component name + * @param state $data data to encode and send to post_state + * @param string $expected expected results (if null an exception is expected) + */ + private function post_state_data(string $component, state $data, ?string $expected): void { + global $DB; + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // When no result is expected, an exception will be incurred. + if (is_null($expected)) { + $this->expectException(xapi_exception::class); + } + + $external = $this->get_external_class(); + $result = $external::execute( + $component, + iri::generate($data->get_activity_id(), 'activity'), + json_encode($data->get_agent()), + $data->get_state_id(), + json_encode($data->jsonSerialize()), + $data->get_registration() + ); + $result = external_api::clean_returnvalue($external::execute_returns(), $result); + + // Check the state has been saved with the expected values. + $this->assertTrue($result); + $records = $DB->get_records('xapi_states'); + $this->assertCount($currentstates + 1, $records); + $record = reset($records); + $this->assertEquals($component, $record->component); + $this->assertEquals($data->get_activity_id(), $record->itemid); + $this->assertEquals($data->get_user()->id, $record->userid); + $this->assertEquals(json_encode($data->jsonSerialize()), $record->statedata); + $this->assertEquals($data->get_registration(), $record->registration); + } +} diff --git a/lib/xapi/tests/fixtures/handler.php b/lib/xapi/tests/fixtures/handler.php index 89cf21ddf05..15bcf7d5fa0 100644 --- a/lib/xapi/tests/fixtures/handler.php +++ b/lib/xapi/tests/fixtures/handler.php @@ -30,6 +30,7 @@ use core_xapi\handler as handler_base; use core_xapi\event\xapi_test_statement_post; use context_system; use core\event\base; +use core_xapi\local\state; defined('MOODLE_INTERNAL') || die(); @@ -112,4 +113,24 @@ class handler extends handler_base { } return parent::supports_group_actors(); } + + /** + * Validate a xAPI state. + * + * Check if the state is valid for this handler. + * + * This method is used also for the state get requests so the validation + * cannot rely on having state data. + * + * @param state $state + * @return bool if the state is valid or not + */ + protected function validate_state(state $state): bool { + // For testing purposes, the state will be considered NOT valid when stateid is set to 'paella'. + if ($state->get_state_id() === 'paella') { + return false; + } + return true; + } + } diff --git a/lib/xapi/tests/handler_test.php b/lib/xapi/tests/handler_test.php index 7abdebf20aa..c5d84c74521 100644 --- a/lib/xapi/tests/handler_test.php +++ b/lib/xapi/tests/handler_test.php @@ -14,14 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * This file contains unit test related to xAPI library. - * - * @package core_xapi - * @copyright 2020 Ferran Recio - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core_xapi; use core_xapi\xapi_exception; @@ -30,15 +22,15 @@ use core_xapi\local\statement\item_agent; use core_xapi\local\statement\item_verb; use core_xapi\local\statement\item_activity; use advanced_testcase; -use core\event\base; - -defined('MOODLE_INTERNAL') || die(); +use core_xapi\local\state; +use stdClass; /** - * Contains test cases for testing xAPI statement handler base methods. + * Contains test cases for testing xAPI handler base methods. * * @package core_xapi * @since Moodle 3.9 + * @covers \core_xapi\handler * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -47,7 +39,7 @@ class handler_test extends advanced_testcase { /** * Setup to ensure that fixtures are loaded. */ - public static function setupBeforeClass(): void { + public static function setUpBeforeClass(): void { global $CFG; require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); } @@ -65,6 +57,19 @@ class handler_test extends advanced_testcase { $value = handler::create('potato_omelette'); } + /** + * Test xAPI support. + */ + public function test_supports_xapi() { + // Get an existent handler. + $result = handler::supports_xapi('fake_component'); + $this->assertTrue($result); + + // Get a non existent handler. + $result = handler::supports_xapi('potato_omelette'); + $this->assertFalse($result); + } + /** * Test support group. */ @@ -115,6 +120,7 @@ class handler_test extends advanced_testcase { $this->assertEquals(false, $result[1]); // Check log entries. + /** @var \core_xapi\event\xapi_test_statement_post $log */ $log = $testhelper->get_last_log_entry(); $this->assertNotEmpty($log); @@ -126,4 +132,219 @@ class handler_test extends advanced_testcase { $this->assertEquals($value, 'User \''.$user->id.'\' send a statement to component \'core_xapi\''); $this->assertTrue($log->compare_statement($statement)); } + + /** + * Testing save_state method. + */ + public function test_save_state(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = 'fake_component'; + $handler = handler::create($component); + + // Check the state has been added. + $state = test_helper::create_state(); + $this->assertEquals(0, $DB->count_records('xapi_states')); + $result = $handler->save_state($state); + $this->assertTrue($result); + $records = $DB->get_records('xapi_states'); + $this->assertCount(1, $records); + $record = reset($records); + $this->check_state($component, $state, $record); + + // Check the state has been updated. + $statedata = '{"progress":0,"answers":[[["BB"],[""]],[{"answers":[]}]],"answered":[true,false]}'; + $state->set_state_data(json_decode($statedata)); + $result = $handler->save_state($state); + $this->assertTrue($result); + $records = $DB->get_records('xapi_states'); + $this->assertCount(1, $records); + $record = reset($records); + $this->check_state($component, $state, $record); + + // Check an exception is thrown when the state is not valid. + $this->expectException(xapi_exception::class); + $state = test_helper::create_state(['stateid' => 'paella']); + $result = $handler->save_state($state); + } + + /** + * Testing load_state method. + */ + public function test_load_state(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = 'fake_component'; + $handler = handler::create($component); + + // Check the state is not found (when there are no states). + $state = test_helper::create_state(); + $state->set_state_data(null); + $this->assertEquals(0, $DB->count_records('xapi_states')); + $result = $handler->load_state($state); + $this->assertEquals(0, $DB->count_records('xapi_states')); + $this->assertNull($result); + + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Check the state is found when it exists. + $result = $handler->load_state($state); + $records = $DB->get_records('xapi_states'); + $this->assertCount(1, $records); + $record = reset($records); + $this->check_state($component, $state, $record); + $this->assertEquals($state->jsonSerialize(), $result->jsonSerialize()); + + // Check the state is not found when it doesn't exist. + $state = test_helper::create_state(['activity' => item_activity::create_from_id('1')]); + $state->set_state_data(null); + $result = $handler->load_state($state); + $records = $DB->get_records('xapi_states'); + $this->assertCount(1, $DB->get_records('xapi_states')); + $this->assertNull($result); + + // Check an exception is thrown when the state is not valid. + $this->expectException(xapi_exception::class); + $state = test_helper::create_state(['stateid' => 'paella']); + $result = $handler->load_state($state); + } + + /** + * Testing delete_state method. + */ + public function test_delete_state(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = 'fake_component'; + $handler = handler::create($component); + + // Check the state is not deleted (when there are no states). + $state = test_helper::create_state(); + $this->assertEquals(0, $DB->count_records('xapi_states')); + $result = $handler->delete_state($state); + $this->assertTrue($result); + $this->assertEquals(0, $DB->count_records('xapi_states')); + + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Check the state is not deleted if the given state doesn't meet its values. + $state2 = test_helper::create_state(['activity' => item_activity::create_from_id('1')]); + $result = $handler->delete_state($state2); + $this->assertTrue($result); + $this->assertCount(1, $DB->get_records('xapi_states')); + + // Check the state is deleted if it exists. + $result = $handler->delete_state($state); + $this->assertTrue($result); + $this->assertCount(0, $DB->get_records('xapi_states')); + + // Check an exception is thrown when the state is not valid. + $this->expectException(xapi_exception::class); + $state = test_helper::create_state(['stateid' => 'paella']); + $result = $handler->delete_state($state); + } + + /** + * Testing reset_states method. + */ + public function test_reset_states(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = 'fake_component'; + $handler = handler::create($component); + + // Check the state is not reset (when there are no states). + $this->assertCount(0, $DB->get_records_select('xapi_states', 'statedata IS NULL')); + $handler->reset_states(); + $this->assertCount(0, $DB->get_records_select('xapi_states', 'statedata IS NULL')); + + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Check the state is not reset if the given state doesn't meet its values. + $handler->reset_states('1'); + $this->assertCount(1, $DB->get_records('xapi_states')); + $this->assertCount(0, $DB->get_records_select('xapi_states', 'statedata IS NULL')); + + // Check the state is reset if it exists. + $handler->reset_states(); + $this->assertCount(1, $DB->get_records('xapi_states')); + $this->assertCount(1, $DB->get_records_select('xapi_states', 'statedata IS NULL')); + + // Check the state is reset too when using some of the given parameters. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + $handler->reset_states('1'); + $this->assertCount(2, $DB->get_records('xapi_states')); + $this->assertCount(2, $DB->get_records_select('xapi_states', 'statedata IS NULL')); + } + + /** + * Testing wipe_states method. + */ + public function test_wipe_states(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $component = 'fake_component'; + $handler = handler::create($component); + + // Check the state is not wiped (when there are no states). + $this->assertCount(0, $DB->get_records('xapi_states')); + $handler->wipe_states(); + $this->assertCount(0, $DB->get_records('xapi_states')); + + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Check the state is not wiped if the given state doesn't meet its values. + $handler->wipe_states('1'); + $this->assertCount(1, $DB->get_records('xapi_states')); + + // Check the state is wiped if it exists. + $handler->wipe_states(); + $this->assertCount(0, $DB->get_records('xapi_states')); + + // Check the state is wiped too when using some of the given parameters. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + $this->assertCount(1, $DB->get_records('xapi_states')); + $handler->wipe_states('1'); + $this->assertCount(0, $DB->get_records('xapi_states')); + } + + /** + * Check if the given state and record are equals. + * + * @param string $component The component name in frankenstyle. + * @param state $state The state to check. + * @param stdClass $record The record to be compared with the state. + */ + private function check_state(string $component, state $state, stdClass $record): void { + $this->assertEquals($component, $record->component); + $this->assertEquals($state->get_activity_id(), $record->itemid); + $this->assertEquals($state->get_user()->id, $record->userid); + $this->assertEquals(json_encode($state->jsonSerialize()), $record->statedata); + $this->assertEquals($state->get_registration(), $record->registration); + } + } diff --git a/lib/xapi/tests/helper.php b/lib/xapi/tests/helper.php index 3ed38cc5b61..d3d4d5e0eb2 100644 --- a/lib/xapi/tests/helper.php +++ b/lib/xapi/tests/helper.php @@ -25,7 +25,9 @@ */ namespace core_xapi; -use stdClass; +use core_xapi\local\state; +use core_xapi\local\statement\item_activity; +use core_xapi\local\statement\item_agent; defined('MOODLE_INTERNAL') || die(); @@ -76,4 +78,41 @@ class test_helper { } return array_pop($records); } + + + /** + * Return a valid state object with the params passed. + * + * All tests are based on craft different types of states. This function + * is made to prevent redundant code on the test. + * + * @param array $info array of overriden state data (default []). + * @param bool $createindatabase Whether the state object should be created in database too or not. + * @return state the resulting state + */ + public static function create_state(array $info = [], bool $createindatabase = false): state { + global $USER; + + $component = $info['component'] ?? 'fake_component'; + $agent = $info['agent'] ?? item_agent::create_from_user($USER); + $activity = $info['activity'] ?? item_activity::create_from_id('12345'); + $stateid = $info['stateid'] ?? 'state'; + $data = $info['data'] ?? json_decode('{"progress":0,"answers":[[["AA"],[""]],[{"answers":[]}]],"answered":[true,false]}'); + $registration = $info['registration'] ?? null; + + $state = new state($agent, $activity, $stateid, (object)$data, $registration); + + if ($createindatabase) { + try { + $handler = handler::create($component); + $statestore = $handler->get_state_store(); + } catch (\Exception $exception) { + // If the component is not available, use the standard one to force it's creation. + $statestore = new state_store($component); + } + $statestore->put($state); + } + + return $state; + } } diff --git a/lib/xapi/tests/privacy/provider_test.php b/lib/xapi/tests/privacy/provider_test.php new file mode 100644 index 00000000000..c83d698e95f --- /dev/null +++ b/lib/xapi/tests/privacy/provider_test.php @@ -0,0 +1,268 @@ +. + +namespace core_xapi\privacy; + +use core_privacy\tests\provider_testcase; +use core_privacy\local\request\transform; +use core_xapi\privacy\provider; +use core_xapi\local\statement\item_activity; +use core_xapi\test_helper; + +/** + * Privacy tests for core_xapi. + * + * @package core_xapi + * @category test + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_xapi\privacy\provider + */ +class provider_test extends provider_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); + } + + /** + * Helper to set up some sample data. + * + * @return array Array with the users that have been created. + */ + protected function set_up_data(): array { + $user1 = self::getDataGenerator()->create_user(); + $user2 = self::getDataGenerator()->create_user(); + $user3 = self::getDataGenerator()->create_user(); + + // Add a few xAPI state records to database. + $context = \context_system::instance(); + $cid = $context->id; + $this->setUser($user1); + test_helper::create_state(['activity' => item_activity::create_from_id($context->id)], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('3'), 'component' => 'mod_h5pactivity'], true); + $this->setUser($user2); + test_helper::create_state(['activity' => item_activity::create_from_id($context->id)], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('4')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5')], true); + $this->setUser($user3); + test_helper::create_state(['activity' => item_activity::create_from_id($cid), 'component' => 'mod_h5pactivity'], true); + + return [$user1, $user2, $user3]; + } + + /** + * Test confirming that contexts of xapi items can be added to the contextlist. + */ + public function test_add_contexts_for_userid(): void { + $this->resetAfterTest(); + + // Scenario. + list($user1, $user2) = $this->set_up_data(); + + // Ask the xapi privacy api to export contexts for xapi of the type we just created, for user1. + $contextlist = new \core_privacy\local\request\contextlist(); + provider::add_contexts_for_userid($contextlist, $user1->id, 'fake_component'); + $this->assertCount(2, $contextlist->get_contextids()); + + $contextlist = new \core_privacy\local\request\contextlist(); + provider::add_contexts_for_userid($contextlist, $user1->id, 'mod_h5pactivity'); + $this->assertCount(1, $contextlist->get_contextids()); + + // Ask the xapi privacy api to export contexts for xapi of the type we just created, for user2. + $contextlist = new \core_privacy\local\request\contextlist(); + provider::add_contexts_for_userid($contextlist, $user2->id, 'fake_component'); + $this->assertCount(4, $contextlist->get_contextids()); + + $contextlist = new \core_privacy\local\request\contextlist(); + provider::add_contexts_for_userid($contextlist, $user2->id, 'mod_h5pactivity'); + $this->assertCount(0, $contextlist->get_contextids()); + } + + /** + * Test confirming that user ID's of xapi states can be added to the userlist. + */ + public function test_add_userids_for_context() { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + list($user1, $user2, $user3) = $this->set_up_data(); + $this->assertEquals(3, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(4, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + $systemcontext = \context_system::instance(); + + // Ask the xapi privacy api to export userids for xapi states of the type we just created, in the system context. + $userlist = new \core_privacy\local\request\userlist($systemcontext, 'fake_component'); + provider::add_userids_for_context($userlist, 'fake_component'); + // Only user1 and user2 should be returned, because user3 has a different component for the system context. + $this->assertCount(2, $userlist->get_userids()); + $expected = [ + $user1->id, + $user2->id, + ]; + $this->assertEqualsCanonicalizing($expected, $userlist->get_userids()); + + // Ask the xapi privacy api to export userids for xapi states of the type we just created for a different component. + $userlist = new \core_privacy\local\request\userlist($systemcontext, 'mod_h5pactivity'); + provider::add_userids_for_context($userlist, 'mod_h5pactivity'); + // Only user3 should be returned, because the others have a different component for the system context. + $this->assertCount(1, $userlist->get_userids()); + $expected = [$user3->id]; + $this->assertEqualsCanonicalizing($expected, $userlist->get_userids()); + + // Ask the xapi privacy api to export userids xapi states for an empty component. + $userlist = new \core_privacy\local\request\userlist($systemcontext, 'empty_component'); + provider::add_userids_for_context($userlist, 'empty_component'); + $this->assertCount(0, $userlist->get_userids()); + } + + /** + * Test fetching the xapi state data for a specified user in a specified component and itemid. + */ + public function test_get_xapi_states_for_user() { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + list($user1, $user2, $user3) = $this->set_up_data(); + $this->assertEquals(3, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(4, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + $systemcontext = \context_system::instance(); + + // Get the states info for user1 in the system context. + $result = provider::get_xapi_states_for_user($user1->id, 'fake_component', $systemcontext->id); + $info = (object) reset($result); + // Ensure the correct data has been returned. + $this->assertNotEmpty($info->statedata); + $this->assertNotEmpty(transform::datetime($info->timecreated)); + $this->assertNotEmpty(transform::datetime($info->timemodified)); + + // Get the states info for user2 in the system context. + $result = provider::get_xapi_states_for_user($user2->id, 'fake_component', $systemcontext->id); + $info = (object) reset($result); + // Ensure the correct data has been returned. + $this->assertNotEmpty($info->statedata); + $this->assertNotEmpty(transform::datetime($info->timecreated)); + $this->assertNotEmpty(transform::datetime($info->timemodified)); + + // Get the states info for user3 in the system context (it should be empty). + $info = provider::get_xapi_states_for_user($user3->id, 'fake_component', $systemcontext->id); + // Ensure the correct data has been returned. + $this->assertEmpty($info); + } + + /** + * Test deletion of user xapi states based on an approved_contextlist and component area. + */ + public function test_delete_states_for_user(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + list($user1, $user2, $user3) = $this->set_up_data(); + $this->assertEquals(3, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(4, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + + // Now, delete the xapistates for user1 only. + $user1context = \context_user::instance($user1->id); + $approvedcontextlist = new \core_privacy\local\request\approved_contextlist($user1, 'fake_component', [$user1context->id]); + provider::delete_states_for_user($approvedcontextlist, 'fake_component'); + + // Verify that we have no xapi states for user1 for the fake_component but that the rest of records are intact. + $this->assertEquals(0, $DB->count_records('xapi_states', ['userid' => $user1->id, 'component' => 'fake_component'])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user1->id, 'component' => 'mod_h5pactivity'])); + $this->assertEquals(4, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + } + + /** + * Test deletion of all user xapi states. + */ + public function test_delete_states_for_all_users(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + list($user1, $user2, $user3) = $this->set_up_data(); + $this->assertEquals(3, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(4, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + + // Now, delete all course module xapi states in the 'fake_component' context only. + provider::delete_states_for_all_users(\context_system::instance(), 'fake_component'); + + // Verify that only content with the context_system for the fake_component have been removed. + $this->assertEquals(2, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(3, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + } + + /** + * Test deletion of user xapi states based on an approved_userlist and component area. + */ + public function test_delete_states_for_userlist() { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + list($user1, $user2, $user3) = $this->set_up_data(); + $this->assertEquals(3, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(4, $DB->count_records('xapi_states', ['userid' => $user2->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + $systemcontext = \context_system::instance(); + + // Ask the xapi privacy api to export userids for states of the type we just created, in the system context. + $userlist1 = new \core_privacy\local\request\userlist($systemcontext, 'fake_component'); + provider::add_userids_for_context($userlist1); + // Verify we have two userids in the list for system context. + $this->assertCount(2, $userlist1->get_userids()); + + // Now, delete the states for user1 only in the system context. + $approveduserlist = new \core_privacy\local\request\approved_userlist($systemcontext, 'fake_component', [$user1->id]); + provider::delete_states_for_userlist($approveduserlist); + // Ensure user1's data was deleted and user2 is still returned for system context. + $userlist1 = new \core_privacy\local\request\userlist($systemcontext, 'fake_component'); + provider::add_userids_for_context($userlist1); + $this->assertCount(1, $userlist1->get_userids()); + // Verify that user2 is still in the list for system context. + $expected = [$user2->id]; + $this->assertEquals($expected, $userlist1->get_userids()); + // Verify that the data of user1 in other contexts was not deleted. + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user3->id])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['userid' => $user1->id])); + $this->assertEquals(2, $DB->count_records('xapi_states', ['itemid' => $systemcontext->id])); + + // Verify that no data is removed if the component is empty. + $userlist3 = new \core_privacy\local\request\userlist($systemcontext, 'empty_component'); + provider::add_userids_for_context($userlist3); + $this->assertCount(0, $userlist3->get_userids()); + $this->assertEquals(2, $DB->count_records('xapi_states', ['itemid' => $systemcontext->id])); + } +} diff --git a/lib/xapi/tests/state_store_test.php b/lib/xapi/tests/state_store_test.php new file mode 100644 index 00000000000..f6792e65c45 --- /dev/null +++ b/lib/xapi/tests/state_store_test.php @@ -0,0 +1,441 @@ +. + +namespace core_xapi; + +use core_xapi\local\statement\item_agent; +use core_xapi\local\statement\item_activity; +use advanced_testcase; + +/** + * Contains test cases for testing xAPI state store methods. + * + * @package core_xapi + * @since Moodle 4.2 + * @covers \core_xapi\state_store + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state_store_test extends 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 delete method. + * + * @dataProvider states_provider + * @param array $info Array of overriden state data. + * @param bool $expected Expected results. + * @return void + */ + public function test_state_store_delete(array $info, bool $expected): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // Perform test. + $component = $info['component'] ?? 'fake_component'; + $state = test_helper::create_state($info); + $store = new state_store($component); + $result = $store->delete($state); + + // Check the state has been removed. + $records = $DB->get_records('xapi_states'); + $this->assertTrue($result); + if ($expected) { + $this->assertCount($currentstates - 1, $records); + } else if ($expected === 'false') { + $this->assertCount($currentstates, $records); + } + } + + /** + * Testing get method. + * + * @dataProvider states_provider + * @param array $info Array of overriden state data. + * @param bool $expected Expected results. + * @return void + */ + public function test_state_store_get(array $info, bool $expected): void { + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Perform test. + $component = $info['component'] ?? 'fake_component'; + $state = test_helper::create_state($info); + // Remove statedata from the state object, to guarantee the get method is working as expected. + $state->set_state_data(null); + $store = new state_store($component); + $result = $store->get($state); + + // Check the returned state has the expected values. + if ($expected) { + $this->assertEquals(json_encode($state->jsonSerialize()), json_encode($result->jsonSerialize())); + } else { + $this->assertNull($result); + } + } + + /** + * Data provider for the test_state_store_delete and test_state_store_get tests. + * + * @return array + */ + public function states_provider() : array { + return [ + 'Existing and valid state' => [ + 'info' => [], + 'expected' => true, + ], + 'No state (wrong activityid)' => [ + 'info' => ['activity' => item_activity::create_from_id('1')], + 'expected' => false, + ], + 'No state (wrong stateid)' => [ + 'info' => ['stateid' => 'food'], + 'expected' => false, + ], + 'No state (wrong component)' => [ + 'info' => ['component' => 'mod_h5pactivity'], + 'expected' => false, + ], + ]; + } + + /** + * Testing put method. + * + * @dataProvider put_states_provider + * @param array $info Array of overriden state data. + * @param string $expected Expected results. + * @return void + */ + public function test_state_store_put(array $info, string $expected): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + // Add, at least, one xAPI state record to database (with the default values). + test_helper::create_state([], true); + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // Perform test. + $component = $info['component'] ?? 'fake_component'; + $state = test_helper::create_state($info); + $store = new state_store($component); + $result = $store->put($state); + + // Check the state has been added/updated. + $this->assertTrue($result); + $recordsnum = $DB->count_records('xapi_states'); + $params = [ + 'component' => $component, + 'userid' => $state->get_user()->id, + 'itemid' => $state->get_activity_id(), + 'stateid' => $state->get_state_id(), + 'registration' => $state->get_registration(), + ]; + $records = $DB->get_records('xapi_states', $params); + $record = reset($records); + if ($expected === 'added') { + $this->assertEquals($currentstates + 1, $recordsnum); + $this->assertEquals($record->timecreated, $record->timemodified); + } else if ($expected === 'updated') { + $this->assertEquals($currentstates, $recordsnum); + $this->assertGreaterThanOrEqual($record->timecreated, $record->timemodified); + } + + $this->assertEquals($component, $record->component); + $this->assertEquals($state->get_activity_id(), $record->itemid); + $this->assertEquals($state->get_user()->id, $record->userid); + $this->assertEquals(json_encode($state->jsonSerialize()), $record->statedata); + $this->assertEquals($state->get_registration(), $record->registration); + } + + /** + * Data provider for the test_state_store_put tests. + * + * @return array + */ + public function put_states_provider() : array { + return [ + 'Update existing state' => [ + 'info' => [], + 'expected' => 'updated', + ], + 'Update existing state (change statedata)' => [ + 'info' => ['statedata' => '{"progress":0,"answers":[[["BB"],[""]],[{"answers":[]}]],"answered":[true,false]}'], + 'expected' => 'updated', + ], + 'Add state (with different itemid)' => [ + 'info' => ['activity' => item_activity::create_from_id('1')], + 'expected' => 'added', + ], + 'Add state (with different stateid)' => [ + 'info' => ['stateid' => 'food'], + 'expected' => 'added', + ], + 'Add state (with different component)' => [ + 'info' => ['component' => 'mod_h5pactivity'], + 'expected' => 'added', + ], + ]; + } + + /** + * Testing reset method. + * + * @dataProvider reset_wipe_states_provider + * @param array $info Array of overriden state data. + * @param int $expected The states that will be reset. + * @return void + */ + public function test_state_store_reset(array $info, int $expected): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $other = $this->getDataGenerator()->create_user(); + + // Add a few xAPI state records to database. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2'), 'stateid' => 'paella'], true); + test_helper::create_state([ + 'activity' => item_activity::create_from_id('3'), + 'agent' => item_agent::create_from_user($other), + 'stateid' => 'paella', + 'registration' => 'ABC', + ], true); + test_helper::create_state([ + 'activity' => item_activity::create_from_id('4'), + 'agent' => item_agent::create_from_user($other), + ], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5'), 'component' => 'my_component'], true); + test_helper::create_state([ + 'activity' => item_activity::create_from_id('6'), + 'component' => 'my_component', + 'stateid' => 'paella', + 'agent' => item_agent::create_from_user($other), + ], true); + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // Perform test. + $component = $info['component'] ?? 'fake_component'; + $itemid = $info['activity'] ?? null; + $userid = (array_key_exists('agent', $info) && $info['agent'] === 'other') ? $other->id : null; + $stateid = $info['stateid'] ?? null; + $registration = $info['registration'] ?? null; + $store = new state_store($component); + $store->reset($itemid, $userid, $stateid, $registration); + + // Check the states haven't been removed. + $this->assertCount($currentstates, $DB->get_records('xapi_states')); + $records = $DB->get_records_select('xapi_states', 'statedata IS NULL'); + $this->assertCount($expected, $records); + } + + /** + * Testing wipe method. + * + * @dataProvider reset_wipe_states_provider + * @param array $info Array of overriden state data. + * @param int $expected The removed states. + * @return void + */ + public function test_state_store_wipe(array $info, int $expected): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $other = $this->getDataGenerator()->create_user(); + + // Add a few xAPI state records to database. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2'), 'stateid' => 'paella'], true); + test_helper::create_state([ + 'activity' => item_activity::create_from_id('3'), + 'agent' => item_agent::create_from_user($other), + 'stateid' => 'paella', + 'registration' => 'ABC', + ], true); + test_helper::create_state([ + 'activity' => item_activity::create_from_id('4'), + 'agent' => item_agent::create_from_user($other), + ], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5'), 'component' => 'my_component'], true); + test_helper::create_state([ + 'activity' => item_activity::create_from_id('6'), + 'component' => 'my_component', + 'stateid' => 'paella', + 'agent' => item_agent::create_from_user($other), + ], true); + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // Perform test. + $component = $info['component'] ?? 'fake_component'; + $itemid = $info['activity'] ?? null; + $userid = (array_key_exists('agent', $info) && $info['agent'] === 'other') ? $other->id : null; + $stateid = $info['stateid'] ?? null; + $registration = $info['registration'] ?? null; + $store = new state_store($component); + $store->wipe($itemid, $userid, $stateid, $registration); + + // Check the states have been removed. + $records = $DB->get_records('xapi_states'); + $this->assertCount($currentstates - $expected, $records); + } + + /** + * Data provider for the test_state_store_reset and test_state_store_wipe tests. + * + * @return array + */ + public function reset_wipe_states_provider() : array { + return [ + 'With fake_component' => [ + 'info' => [], + 'expected' => 4, + ], + 'With my_component' => [ + 'info' => ['component' => 'my_component'], + 'expected' => 2, + ], + 'With unexisting_component' => [ + 'info' => ['component' => 'unexisting_component'], + 'expected' => 0, + ], + 'Existing activity' => [ + 'info' => ['activity' => '1'], + 'expected' => 1, + ], + 'Unexisting activity' => [ + 'info' => ['activity' => '1111'], + 'expected' => 0, + ], + 'Existing userid' => [ + 'info' => ['agent' => 'other'], + 'expected' => 2, + ], + 'Existing stateid' => [ + 'info' => ['stateid' => 'paella'], + 'expected' => 2, + ], + 'Unexisting stateid' => [ + 'info' => ['stateid' => 'chorizo'], + 'expected' => 0, + ], + 'Existing registration' => [ + 'info' => ['registration' => 'ABC'], + 'expected' => 1, + ], + 'Uxexisting registration' => [ + 'info' => ['registration' => 'XYZ'], + 'expected' => 0, + ], + 'Existing stateid combined with activity' => [ + 'info' => ['activity' => '3', 'stateid' => 'paella'], + 'expected' => 1, + ], + 'Uxexisting stateid combined with activity' => [ + 'info' => ['activity' => '1', 'stateid' => 'paella'], + 'expected' => 0, + ], + ]; + } + + /** + * Testing cleanup method. + * + * @return void + */ + public function test_state_store_cleanup(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + $other = $this->getDataGenerator()->create_user(); + + // Add a few xAPI state records to database. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('3')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('4')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5'), 'component' => 'my_component'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('6'), 'component' => 'my_component'], true); + + // Get current states in database. + $currentstates = $DB->count_records('xapi_states'); + + // Perform test. + $component = 'fake_component'; + $store = new state_store($component); + $store->cleanup(); + + // Check no state has been removed (because the entries are not old enough). + $this->assertEquals($currentstates, $DB->count_records('xapi_states')); + + // Make the existing state entries older. + $timepast = time() - 2; + $DB->set_field('xapi_states', 'timecreated', $timepast); + $DB->set_field('xapi_states', 'timemodified', $timepast); + + // Create 1 more state, that shouldn't be removed after the cleanup. + test_helper::create_state(['activity' => item_activity::create_from_id('7')], true); + + // Set the config to remove states older than 1 second. + set_config('xapicleanupperiod', 1); + + // Check old states for fake_component have been removed. + $currentstates = $DB->count_records('xapi_states'); + $store->cleanup(); + $this->assertEquals($currentstates - 4, $DB->count_records('xapi_states')); + $this->assertEquals(1, $DB->count_records('xapi_states', ['component' => $component])); + $this->assertEquals(2, $DB->count_records('xapi_states', ['component' => 'my_component'])); + } +} diff --git a/lib/xapi/tests/task/state_cleanup_task_test.php b/lib/xapi/tests/task/state_cleanup_task_test.php new file mode 100644 index 00000000000..40f9653146e --- /dev/null +++ b/lib/xapi/tests/task/state_cleanup_task_test.php @@ -0,0 +1,87 @@ +. + +namespace core_xapi\task; + +use core_xapi\local\statement\item_activity; +use advanced_testcase; +use core_xapi\test_helper; + +/** + * Contains test cases for testing the scheduled task state_cleanup_task. + * + * @package core_xapi + * @since Moodle 4.2 + * @covers \core_xapi\task\state_cleanup_task + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state_cleanup_task_test extends 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 execute method in state_cleanup_task. + */ + public function test_state_cleanup_task(): void { + global $DB; + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Add a few xAPI state records to database. + test_helper::create_state(['activity' => item_activity::create_from_id('1')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('2')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('3')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('4')], true); + test_helper::create_state(['activity' => item_activity::create_from_id('5'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('6'), 'component' => 'mod_h5pactivity'], true); + test_helper::create_state(['activity' => item_activity::create_from_id('7'), 'component' => 'mod_h5pactivity'], true); + + // Perform test. + $task = new state_cleanup_task(); + $task->execute(); + + // Check no state has been removed yet (because the entries are not old enough). + $this->assertEquals(7, $DB->count_records('xapi_states')); + + // Make the existing state entries older. + $timepast = time() - 2; + $DB->set_field('xapi_states', 'timecreated', $timepast); + $DB->set_field('xapi_states', 'timemodified', $timepast); + + // Create 1 more state, that shouldn't be removed after the cleanup. + test_helper::create_state(['activity' => item_activity::create_from_id('8'), 'component' => 'mod_h5pactivity'], true); + + // Set the config to remove states older than 1 second. + set_config('xapicleanupperiod', 1); + + // Check old states have been removed. + $task->execute(); + $this->assertEquals(5, $DB->count_records('xapi_states')); + $this->assertEquals(4, $DB->count_records('xapi_states', ['component' => 'fake_component'])); + $this->assertEquals(1, $DB->count_records('xapi_states', ['component' => 'mod_h5pactivity'])); + $this->assertEquals(0, $DB->count_records('xapi_states', ['component' => 'my_component'])); + } +} diff --git a/mod/h5pactivity/tests/xapi/handler_test.php b/mod/h5pactivity/tests/xapi/handler_test.php index 481a9383178..1db42c99775 100644 --- a/mod/h5pactivity/tests/xapi/handler_test.php +++ b/mod/h5pactivity/tests/xapi/handler_test.php @@ -26,7 +26,6 @@ namespace mod_h5pactivity\xapi; use \core_xapi\local\statement; -use \core_xapi\local\statement\item; use \core_xapi\local\statement\item_agent; use \core_xapi\local\statement\item_activity; use \core_xapi\local\statement\item_definition; diff --git a/version.php b/version.php index 1792f6faeab..b60d5a15bdc 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2023031400.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2023031400.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.2dev+ (Build: 20230314)'; // Human-friendly version name From bd0a6e6dccf7dd8f19c174209a231fb40ee6e5b8 Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Wed, 18 Jan 2023 16:19:18 +0100 Subject: [PATCH 2/2] MDL-67789 h5p: Save current state using xAPI State Co-author: Andrew Lyons --- h5p/amd/build/repository.min.js | 10 + h5p/amd/build/repository.min.js.map | 1 + h5p/amd/src/repository.js | 99 ++++++++++ h5p/classes/framework.php | 51 +++-- h5p/classes/helper.php | 25 ++- h5p/classes/player.php | 69 ++++++- h5p/h5plib/v124/joubel/core/js/h5p.js | 5 + h5p/h5plib/v124/joubel/core/readme_moodle.txt | 15 ++ h5p/js/embed.js | 104 +++++++++-- h5p/js/h5p_overrides.js | 84 +++++++++ h5p/tests/framework_test.php | 103 ++++++++-- h5p/tests/generator/lib.php | 57 ++++-- h5p/tests/generator_test.php | 129 ++++++++++++- mod/h5pactivity/classes/local/attempt.php | 6 + mod/h5pactivity/classes/privacy/provider.php | 50 +++-- mod/h5pactivity/classes/xapi/handler.php | 62 ++++-- mod/h5pactivity/lang/en/h5pactivity.php | 5 + mod/h5pactivity/lib.php | 10 + mod/h5pactivity/settings.php | 33 ++++ .../tests/behat/save_content_state.feature | 143 ++++++++++++++ mod/h5pactivity/tests/lib_test.php | 176 +++++++++++++++++- mod/h5pactivity/tests/local/attempt_test.php | 12 ++ .../tests/privacy/provider_test.php | 119 ++++++------ mod/h5pactivity/tests/xapi/handler_test.php | 104 ++++++++++- mod/h5pactivity/version.php | 2 +- 25 files changed, 1292 insertions(+), 182 deletions(-) create mode 100644 h5p/amd/build/repository.min.js create mode 100644 h5p/amd/build/repository.min.js.map create mode 100644 h5p/amd/src/repository.js create mode 100644 mod/h5pactivity/settings.php create mode 100644 mod/h5pactivity/tests/behat/save_content_state.feature diff --git a/h5p/amd/build/repository.min.js b/h5p/amd/build/repository.min.js new file mode 100644 index 00000000000..751ebb9bd44 --- /dev/null +++ b/h5p/amd/build/repository.min.js @@ -0,0 +1,10 @@ +define("core_h5p/repository",["exports","core/ajax","core/config"],(function(_exports,_ajax,config){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.postStatement=_exports.postState=_exports.deleteState=void 0,config=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj} +/** + * Module to handle AJAX interactions. + * + * @module core_h5p/repository + * @copyright 2023 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */(config);_exports.postStatement=(component,statements)=>(0,_ajax.call)([{methodname:"core_xapi_statement_post",args:{component:component,requestjson:JSON.stringify(statements)}}])[0];_exports.postState=(component,activityId,agent,stateId,stateData)=>{const requestUrl=new URL("".concat(config.wwwroot,"/lib/ajax/service.php"));requestUrl.searchParams.set("sesskey",config.sesskey),navigator.sendBeacon(requestUrl,JSON.stringify([{index:0,methodname:"core_xapi_post_state",args:{component:component,activityId:activityId,agent:JSON.stringify(agent),stateId:stateId,stateData:stateData}}]))};_exports.deleteState=(component,activityId,agent,stateId)=>(0,_ajax.call)([{methodname:"core_xapi_delete_state",args:{component:component,activityId:activityId,agent:JSON.stringify(agent),stateId:stateId}}])[0]})); + +//# sourceMappingURL=repository.min.js.map \ No newline at end of file diff --git a/h5p/amd/build/repository.min.js.map b/h5p/amd/build/repository.min.js.map new file mode 100644 index 00000000000..868ebc6fa6c --- /dev/null +++ b/h5p/amd/build/repository.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to handle AJAX interactions.\n *\n * @module core_h5p/repository\n * @copyright 2023 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {call as fetchMany} from 'core/ajax';\nimport * as config from 'core/config';\n\n/**\n * Send a xAPI statement to LMS.\n *\n * @param {string} component\n * @param {Object} statements\n * @returns {Promise}\n */\nexport const postStatement = (component, statements) => fetchMany([{\n methodname: 'core_xapi_statement_post',\n args: {\n component,\n requestjson: JSON.stringify(statements),\n }\n}])[0];\n\n/**\n * Send a xAPI state to LMS.\n *\n * @param {string} component\n * @param {string} activityId\n * @param {Object} agent\n * @param {string} stateId\n * @param {string} stateData\n */\nexport const postState = (\n component,\n activityId,\n agent,\n stateId,\n stateData,\n) => {\n // Please note that we must use a Beacon send here.\n // The XHR is not guaranteed because it will be aborted on page transition.\n // https://developer.mozilla.org/en-US/docs/Web/API/Beacon_API\n // Note: Moodle does not currently have a sendBeacon API endpoint.\n const requestUrl = new URL(`${config.wwwroot}/lib/ajax/service.php`);\n requestUrl.searchParams.set('sesskey', config.sesskey);\n\n navigator.sendBeacon(requestUrl, JSON.stringify([{\n index: 0,\n methodname: 'core_xapi_post_state',\n args: {\n component,\n activityId,\n agent: JSON.stringify(agent),\n stateId,\n stateData,\n }\n }]));\n};\n\n/**\n * Delete a xAPI state from LMS.\n *\n * @param {string} component\n * @param {string} activityId\n * @param {Object} agent\n * @param {string} stateId\n * @returns {Promise}\n */\nexport const deleteState = (\n component,\n activityId,\n agent,\n stateId,\n) => fetchMany([{\n methodname: 'core_xapi_delete_state',\n args: {\n component,\n activityId,\n agent: JSON.stringify(agent),\n stateId,\n },\n}])[0];\n"],"names":["component","statements","methodname","args","requestjson","JSON","stringify","activityId","agent","stateId","stateData","requestUrl","URL","config","wwwroot","searchParams","set","sesskey","navigator","sendBeacon","index"],"mappings":";;;;;;;qCAgC6B,CAACA,UAAWC,cAAe,cAAU,CAAC,CAC/DC,WAAY,2BACZC,KAAM,CACFH,UAAAA,UACAI,YAAaC,KAAKC,UAAUL,gBAEhC,sBAWqB,CACrBD,UACAO,WACAC,MACAC,QACAC,mBAMMC,WAAa,IAAIC,cAAOC,OAAOC,kCACrCH,WAAWI,aAAaC,IAAI,UAAWH,OAAOI,SAE9CC,UAAUC,WAAWR,WAAYN,KAAKC,UAAU,CAAC,CAC7Cc,MAAO,EACPlB,WAAY,uBACZC,KAAM,CACFH,UAAAA,UACAO,WAAAA,WACAC,MAAOH,KAAKC,UAAUE,OACtBC,QAAAA,QACAC,UAAAA,qCAce,CACvBV,UACAO,WACAC,MACAC,WACC,cAAU,CAAC,CACZP,WAAY,yBACZC,KAAM,CACFH,UAAAA,UACAO,WAAAA,WACAC,MAAOH,KAAKC,UAAUE,OACtBC,QAAAA,YAEJ"} \ No newline at end of file diff --git a/h5p/amd/src/repository.js b/h5p/amd/src/repository.js new file mode 100644 index 00000000000..a1b48e8f923 --- /dev/null +++ b/h5p/amd/src/repository.js @@ -0,0 +1,99 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Module to handle AJAX interactions. + * + * @module core_h5p/repository + * @copyright 2023 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +import {call as fetchMany} from 'core/ajax'; +import * as config from 'core/config'; + +/** + * Send a xAPI statement to LMS. + * + * @param {string} component + * @param {Object} statements + * @returns {Promise} + */ +export const postStatement = (component, statements) => fetchMany([{ + methodname: 'core_xapi_statement_post', + args: { + component, + requestjson: JSON.stringify(statements), + } +}])[0]; + +/** + * Send a xAPI state to LMS. + * + * @param {string} component + * @param {string} activityId + * @param {Object} agent + * @param {string} stateId + * @param {string} stateData + */ +export const postState = ( + component, + activityId, + agent, + stateId, + stateData, +) => { + // Please note that we must use a Beacon send here. + // The XHR is not guaranteed because it will be aborted on page transition. + // https://developer.mozilla.org/en-US/docs/Web/API/Beacon_API + // Note: Moodle does not currently have a sendBeacon API endpoint. + const requestUrl = new URL(`${config.wwwroot}/lib/ajax/service.php`); + requestUrl.searchParams.set('sesskey', config.sesskey); + + navigator.sendBeacon(requestUrl, JSON.stringify([{ + index: 0, + methodname: 'core_xapi_post_state', + args: { + component, + activityId, + agent: JSON.stringify(agent), + stateId, + stateData, + } + }])); +}; + +/** + * Delete a xAPI state from LMS. + * + * @param {string} component + * @param {string} activityId + * @param {Object} agent + * @param {string} stateId + * @returns {Promise} + */ +export const deleteState = ( + component, + activityId, + agent, + stateId, +) => fetchMany([{ + methodname: 'core_xapi_delete_state', + args: { + component, + activityId, + agent: JSON.stringify(agent), + stateId, + }, +}])[0]; diff --git a/h5p/classes/framework.php b/h5p/classes/framework.php index 711cefcea37..b87a54bd0dc 100644 --- a/h5p/classes/framework.php +++ b/h5p/classes/framework.php @@ -16,6 +16,8 @@ namespace core_h5p; +use core_xapi\handler; +use core_xapi\xapi_exception; use Moodle\H5PFrameworkInterface; use Moodle\H5PCore; @@ -886,14 +888,6 @@ class framework implements H5PFrameworkInterface { public function updateContent($content, $contentmainid = null) { global $DB; - if (!isset($content['pathnamehash'])) { - $content['pathnamehash'] = ''; - } - - if (!isset($content['contenthash'])) { - $content['contenthash'] = ''; - } - // If the libraryid declared in the package is empty, get the latest version. if (empty($content['library']['libraryId'])) { $mainlibrary = $this->get_latest_library_version($content['library']['machineName']); @@ -919,11 +913,19 @@ class framework implements H5PFrameworkInterface { 'mainlibraryid' => $content['library']['libraryId'], 'timemodified' => time(), 'filtered' => null, - 'pathnamehash' => $content['pathnamehash'], - 'contenthash' => $content['contenthash'] ]; + if (isset($content['pathnamehash'])) { + $data['pathnamehash'] = $content['pathnamehash']; + } + + if (isset($content['contenthash'])) { + $data['contenthash'] = $content['contenthash']; + } + if (!isset($content['id'])) { + $data['pathnamehash'] = $data['pathnamehash'] ?? ''; + $data['contenthash'] = $data['contenthash'] ?? ''; $data['timecreated'] = $data['timemodified']; $id = $DB->insert_record('h5p', $data); } else { @@ -941,7 +943,28 @@ class framework implements H5PFrameworkInterface { * @param int $contentid The h5p content id */ public function resetContentUserData($contentid) { - // Currently, we do not store user data for a content. + global $DB; + + // Get the component associated to the H5P content to reset. + $h5p = $DB->get_record('h5p', ['id' => $contentid]); + if (!$h5p) { + return; + } + + $fs = get_file_storage(); + $file = $fs->get_file_by_hash($h5p->pathnamehash); + if (!$file) { + return; + } + + // Reset user data. + try { + $xapihandler = handler::create($file->get_component()); + $xapihandler->reset_states($file->get_contextid()); + } catch (xapi_exception $exception) { + // This component doesn't support xAPI State, so no content needs to be reset. + return; + } } /** @@ -998,8 +1021,12 @@ class framework implements H5PFrameworkInterface { public function deleteContentData($contentid) { global $DB; + // The user content should be reset (instead of removed), because this method is called when H5P content needs + // to be updated too (and the previous states must be kept, but reset). + $this->resetContentUserData($contentid); + // Remove content. - $DB->delete_records('h5p', array('id' => $contentid)); + $DB->delete_records('h5p', ['id' => $contentid]); // Remove content library dependencies. $this->deleteLibraryUsage($contentid); diff --git a/h5p/classes/helper.php b/h5p/classes/helper.php index 002a90deb4e..4cf36b287e4 100644 --- a/h5p/classes/helper.php +++ b/h5p/classes/helper.php @@ -314,18 +314,20 @@ class helper { /** * Get the settings needed by the H5P library. * + * @param string|null $component * @return array The settings. */ - public static function get_core_settings(): array { + public static function get_core_settings(?string $component = null): array { global $CFG, $USER; $basepath = $CFG->wwwroot . '/'; $systemcontext = context_system::instance(); - // Generate AJAX paths. - $ajaxpaths = []; - $ajaxpaths['xAPIResult'] = ''; - $ajaxpaths['contentUserData'] = ''; + // H5P doesn't currently support xAPI State. It implements a mechanism in contentUserDataAjax() in h5p.js to update user + // data. However, in our case, we're overriding this method to call the xAPI State web services. + $ajaxpaths = [ + 'contentUserData' => '', + ]; $factory = new factory(); $core = $factory->get_core(); @@ -336,13 +338,17 @@ class helper { $usersettings['name'] = $USER->username; $usersettings['id'] = $USER->id; } + $savefreq = false; + if ($component !== null && get_config($component, 'enablesavestate')) { + $savefreq = get_config($component, 'savestatefreq'); + } $settings = array( 'baseUrl' => $basepath, 'url' => "{$basepath}pluginfile.php/{$systemcontext->instanceid}/core_h5p", 'urlLibraries' => "{$basepath}pluginfile.php/{$systemcontext->id}/core_h5p/libraries", 'postUserStatistics' => false, 'ajax' => $ajaxpaths, - 'saveFreq' => false, + 'saveFreq' => $savefreq, 'siteUrl' => $CFG->wwwroot, 'l10n' => array('H5P' => $core->getLocalization()), 'user' => $usersettings, @@ -360,13 +366,14 @@ class helper { /** * Get the core H5P assets, including all core H5P JavaScript and CSS. * + * @param string|null $component * @return Array core H5P assets. */ - public static function get_core_assets(): array { - global $CFG, $PAGE; + public static function get_core_assets(?string $component = null): array { + global $PAGE; // Get core settings. - $settings = self::get_core_settings(); + $settings = self::get_core_settings($component); $settings['core'] = [ 'styles' => [], 'scripts' => [] diff --git a/h5p/classes/player.php b/h5p/classes/player.php index 518ae483109..3492ffe1340 100644 --- a/h5p/classes/player.php +++ b/h5p/classes/player.php @@ -27,7 +27,11 @@ namespace core_h5p; defined('MOODLE_INTERNAL') || die(); use core_h5p\local\library\autoloader; +use core_xapi\handler; +use core_xapi\local\state; use core_xapi\local\statement\item_activity; +use core_xapi\local\statement\item_agent; +use core_xapi\xapi_exception; /** * H5P player class, for displaying any local H5P content. @@ -102,7 +106,7 @@ class player { * Inits the H5P player for rendering the content. * * @param string $url Local URL of the H5P file to display. - * @param stdClass $config Configuration for H5P buttons. + * @param \stdClass $config Configuration for H5P buttons. * @param bool $preventredirect Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions * @param string $component optional moodle component to sent xAPI tracking * @param bool $skipcapcheck Whether capabilities should be checked or not to get the pluginfile URL because sometimes they @@ -207,7 +211,7 @@ class player { * main H5P config variable. */ public function add_assets_to_page() { - global $PAGE; + global $PAGE, $USER; $cid = $this->get_cid(); $systemcontext = \context_system::instance(); @@ -219,6 +223,7 @@ class player { \core_h5p\file_storage::CONTENT_FILEAREA, $this->h5pid, null, null); $exporturl = $this->get_export_settings($displayoptions[ core::DISPLAY_OPTION_DOWNLOAD ]); $xapiobject = item_activity::create_from_id($this->context->id); + $contentsettings = [ 'library' => core::libraryToString($this->content['library']), 'fullScreen' => $this->content['library']['fullscreen'], @@ -231,7 +236,7 @@ class player { 'url' => $xapiobject->get_data()->id, 'contentUrl' => $contenturl->out(), 'metadata' => $this->content['metadata'], - 'contentUserData' => [0 => ['state' => '{}']] + 'contentUserData' => [0 => ['state' => $this->get_state_data($xapiobject)]], ]; // Get the core H5P assets, needed by the H5P classes to render the H5P content. $settings = $this->get_assets(); @@ -241,6 +246,62 @@ class player { $PAGE->requires->data_for_js('H5PIntegration', $settings, true); } + /** + * Get the stored xAPI state to use as user data. + * + * @param item_activity $xapiobject + * @return string The state data to pass to the player frontend + */ + private function get_state_data(item_activity $xapiobject): string { + global $USER; + + // Initialize the H5P content with the saved state (if it's enabled and the user has some stored state). + $emptystatedata = '{}'; + $savestate = (bool) get_config($this->component, 'enablesavestate'); + if (!$savestate) { + return $emptystatedata; + } + + $xapihandler = handler::create($this->component); + if (!$xapihandler) { + return $emptystatedata; + } + + // The component implements the xAPI handler, so the state can be loaded. + $state = new state( + item_agent::create_from_user($USER), + $xapiobject, + 'state', + null, + null + ); + try { + $state = $xapihandler->load_state($state); + if (!$state) { + return $emptystatedata; + } + + if (is_null($state->get_state_data())) { + // The state content should be reset because, for instance, the content has changed. + return 'RESET'; + } + + $statedata = $state->jsonSerialize(); + if (is_null($statedata)) { + return $emptystatedata; + } + + if (property_exists($statedata, 'h5p')) { + // As the H5P state doesn't always use JSON, we have added this h5p object to jsonize it. + return $statedata->h5p; + } + } catch (xapi_exception $exception) { + return $emptystatedata; + } + + return $emptystatedata; + } + /** * Outputs H5P wrapper HTML. * @@ -371,7 +432,7 @@ class player { */ private function get_assets(): array { // Get core assets. - $settings = helper::get_core_assets(); + $settings = helper::get_core_assets($this->component); // Added here because in the helper we don't have the h5p content id. $settings['moodleLibraryPaths'] = $this->core->get_dependency_roots($this->h5pid); // Add also the Moodle component where the results will be tracked. diff --git a/h5p/h5plib/v124/joubel/core/js/h5p.js b/h5p/h5plib/v124/joubel/core/js/h5p.js index 4b296f1c2ba..744fe2f5a26 100644 --- a/h5p/h5plib/v124/joubel/core/js/h5p.js +++ b/h5p/h5plib/v124/joubel/core/js/h5p.js @@ -2344,6 +2344,11 @@ H5P.createTitle = function (rawTitle, maxLength) { done('Not signed in.'); return; } + // Moodle patch to let override this method. + if (H5P.contentUserDataAjax !== undefined) { + return H5P.contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async); + } + // End of Moodle patch. var options = { url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), diff --git a/h5p/h5plib/v124/joubel/core/readme_moodle.txt b/h5p/h5plib/v124/joubel/core/readme_moodle.txt index d9a2c3ac2ce..72ea15c55ff 100644 --- a/h5p/h5plib/v124/joubel/core/readme_moodle.txt +++ b/h5p/h5plib/v124/joubel/core/readme_moodle.txt @@ -35,3 +35,18 @@ The library needs to be saved in the database first before creating the files, b 5. Check if new methods have been added to any of the interfaces. If that's the case, implement them in the proper class. For instance, if a new method is added to h5p-file-storage.interface.php, it should be implemented in h5p/classes/file_storage.php. + +6. Open js/h5p.js and in function contentUserDataAjax() add the following patch: + function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + if (H5PIntegration.user === undefined) { + // Not logged in, no use in saving. + done('Not signed in.'); + return; + } + // Moodle patch to let override this method. + if (H5P.contentUserDataAjax !== undefined) { + return H5P.contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async); + } + // End of Moodle patch. + + var options = { diff --git a/h5p/js/embed.js b/h5p/js/embed.js index 892adcfa5c7..f8ee2703468 100644 --- a/h5p/js/embed.js +++ b/h5p/js/embed.js @@ -72,26 +72,71 @@ H5PEmbedCommunicator = (function() { window.parent.postMessage(data, '*'); }; + /* eslint-disable promise/avoid-new */ + const repositoryPromise = new Promise((resolve) => { + require(['core_h5p/repository'], (Repository) => { + + // Replace the default versions. + self.post = Repository.postStatement; + self.postState = Repository.postState; + self.deleteState = Repository.deleteState; + + // Resolve the Promise with Repository to allow any queued calls to be executed. + resolve(Repository); + }); + }); + /** * Send a xAPI statement to LMS. * * @param {string} component * @param {Object} statements + * @returns {Promise} */ - self.post = function(component, statements) { - require(['core/ajax'], function(ajax) { - var data = { - component: component, - requestjson: JSON.stringify(statements) - }; - ajax.call([ - { - methodname: 'core_xapi_statement_post', - args: data - } - ]); - }); - }; + self.post = (component, statements) => repositoryPromise.then((Repository) => Repository.postStatement( + component, + statements, + )); + + /** + * Send a xAPI state to LMS. + * + * @param {string} component + * @param {string} activityId + * @param {Object} agent + * @param {string} stateId + * @param {string} stateData + * @returns {void} + */ + self.postState = ( + component, + activityId, + agent, + stateId, + stateData, + ) => repositoryPromise.then((Repository) => Repository.postState( + component, + activityId, + agent, + stateId, + stateData, + )); + + /** + * Delete a xAPI state from LMS. + * + * @param {string} component + * @param {string} activityId + * @param {Object} agent + * @param {string} stateId + * @returns {Promise} + */ + self.deleteState = (component, activityId, agent, stateId) => repositoryPromise.then((Repository) => Repository.deleteState( + component, + activityId, + agent, + stateId, + )); } return (window.postMessage && window.addEventListener ? new Communicator() : undefined); @@ -120,6 +165,9 @@ document.onreadystatechange = async() => { return; } + /** @var {boolean} statementPosted Whether the statement has been sent or not, to avoid sending xAPI State after it. */ + var statementPosted = false; + // Check for H5P iFrame. var iFrame = document.querySelector('.h5p-iframe'); if (!iFrame || !iFrame.contentWindow) { @@ -188,6 +236,7 @@ document.onreadystatechange = async() => { // Get emitted xAPI data. H5P.externalDispatcher.on('xAPI', function(event) { + statementPosted = false; var moodlecomponent = H5P.getMoodleComponent(); if (moodlecomponent == undefined) { return; @@ -215,6 +264,33 @@ document.onreadystatechange = async() => { if (isCompleted && !isChild) { var statements = H5P.getXAPIStatements(this.contentId, statement); H5PEmbedCommunicator.post(moodlecomponent, statements); + // Mark the statement has been sent, to avoid sending xAPI State after it. + statementPosted = true; + } + }); + + H5P.externalDispatcher.on('xAPIState', function(event) { + var moodlecomponent = H5P.getMoodleComponent(); + var contentId = event.data.activityId; + var stateId = event.data.stateId; + var state = event.data.state; + if (state === undefined) { + // When state is undefined, a call to the WS for getting the state could be done. However, for now, this is not + // required because the content state is initialised with PHP. + return; + } + + if (state === null) { + // When this method is called from the H5P API with null state, the state must be deleted using the rest of attributes. + H5PEmbedCommunicator.deleteState(moodlecomponent, contentId, H5P.getxAPIActor(), stateId); + } else if (!statementPosted) { + // Only update the state if a statement hasn't been posted recently. + // When state is defined, it needs to be updated. As not all the H5P content types are returning a JSON, we need + // to simulate it because xAPI State defines statedata as a JSON. + var statedata = { + h5p: state + }; + H5PEmbedCommunicator.postState(moodlecomponent, contentId, H5P.getxAPIActor(), stateId, JSON.stringify(statedata)); } }); diff --git a/h5p/js/h5p_overrides.js b/h5p/js/h5p_overrides.js index a0b6bf3a868..96b40c429ca 100644 --- a/h5p/js/h5p_overrides.js +++ b/h5p/js/h5p_overrides.js @@ -1,3 +1,18 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + H5P._getLibraryPath = H5P.getLibraryPath; H5P.getLibraryPath = function (library) { if (H5PIntegration.moodleLibraryPaths) { @@ -88,3 +103,72 @@ H5P.XAPIEvent.prototype.setActor = function () { }; } }; + +/** + * Get the actor. + * + * @returns {Object} The Actor object. + */ +H5P.getxAPIActor = function() { + var actor = null; + if (H5PIntegration.user !== undefined) { + actor = { + 'name': H5PIntegration.user.name, + 'objectType': 'Agent' + }; + if (H5PIntegration.user.id !== undefined) { + actor.account = { + 'name': H5PIntegration.user.id, + 'homePage': H5PIntegration.siteUrl + }; + } else if (H5PIntegration.user.mail !== undefined) { + actor.mbox = 'mailto:' + H5PIntegration.user.mail; + } + } else { + var uuid; + try { + if (localStorage.H5PUserUUID) { + uuid = localStorage.H5PUserUUID; + } else { + uuid = H5P.createUUID(); + localStorage.H5PUserUUID = uuid; + } + } catch (err) { + // LocalStorage and Cookies are probably disabled. Do not track the user. + uuid = 'not-trackable-' + H5P.createUUID(); + } + actor = { + 'account': { + 'name': uuid, + 'homePage': H5PIntegration.siteUrl + }, + 'objectType': 'Agent' + }; + } + return actor; +}; + +/** + * Creates requests for inserting, updating and deleting content user data. + * It overrides the contentUserDataAjax private method in h5p.js. + * + * @param {number} contentId What content to store the data for. + * @param {string} dataType Identifies the set of data for this content. + * @param {string} subContentId Identifies sub content + * @param {function} [done] Callback when ajax is done. + * @param {object} [data] To be stored for future use. + * @param {boolean} [preload=false] Data is loaded when content is loaded. + * @param {boolean} [invalidate=false] Data is invalidated when content changes. + * @param {boolean} [async=true] + */ +H5P.contentUserDataAjax = function(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + var instance = H5P.findInstanceFromId(contentId); + if (instance !== undefined) { + var xAPIState = { + activityId: H5P.XAPIEvent.prototype.getContentXAPIId(instance), + stateId: dataType, + state: data + }; + H5P.externalDispatcher.trigger('xAPIState', xAPIState); + } +}; diff --git a/h5p/tests/framework_test.php b/h5p/tests/framework_test.php index 729808245a3..1dd5e391ebd 100644 --- a/h5p/tests/framework_test.php +++ b/h5p/tests/framework_test.php @@ -20,6 +20,8 @@ use core_collator; use Moodle\H5PCore; use Moodle\H5PDisplayOptionBehaviour; +// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod + /** * * Test class covering the H5PFrameworkInterface interface implementation. @@ -28,6 +30,7 @@ use Moodle\H5PDisplayOptionBehaviour; * @category test * @copyright 2019 Mihail Geshoski * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_h5p\framework * @runTestsInSeparateProcesses */ class framework_test extends \advanced_testcase { @@ -1061,6 +1064,7 @@ class framework_test extends \advanced_testcase { $this->resetAfterTest(); + /** @var \core_h5p_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); // Create a library record. @@ -1085,6 +1089,8 @@ class framework_test extends \advanced_testcase { // Make sure the h5p content was properly updated. $this->assertNotEmpty($h5pcontent); + $this->assertNotEmpty($h5pcontent->pathnamehash); + $this->assertNotEmpty($h5pcontent->contenthash); $this->assertEquals($content['params'], $h5pcontent->jsoncontent); $this->assertEquals($content['library']['libraryId'], $h5pcontent->mainlibraryid); $this->assertEquals($content['disable'], $h5pcontent->displayoptions); @@ -1139,33 +1145,102 @@ class framework_test extends \advanced_testcase { $this->resetAfterTest(); + /** @var \core_h5p_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + // For the mod_h5pactivity component, the activity needs to be created too. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($user); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $activitycontext = \context_module::instance($activity->cmid); + $filerecord = [ + 'contextid' => $activitycontext->id, + 'component' => 'mod_h5pactivity', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'dummy.h5p', + 'addxapistate' => true, + ]; // Generate some h5p related data. - $data = $generator->generate_h5p_data(); + $data = $generator->generate_h5p_data(false, $filerecord); $h5pid = $data->h5pcontent->h5pid; - $h5pcontent = $DB->get_record('h5p', ['id' => $h5pid]); // Make sure the particular h5p content exists in the DB. - $this->assertNotEmpty($h5pcontent); - - // Get the h5p content libraries from the DB. - $h5pcontentlibraries = $DB->get_records('h5p_contents_libraries', ['h5pid' => $h5pid]); - + $this->assertNotEmpty($DB->get_record('h5p', ['id' => $h5pid])); // Make sure the content libraries exists in the DB. - $this->assertNotEmpty($h5pcontentlibraries); - $this->assertCount(5, $h5pcontentlibraries); + $this->assertCount(5, $DB->get_records('h5p_contents_libraries', ['h5pid' => $h5pid])); + // Make sure the particular xAPI state exists in the DB. + $records = $DB->get_records('xapi_states'); + $record = reset($records); + $this->assertCount(1, $records); + $this->assertNotNull($record->statedata); // Delete the h5p content and it's related data. $this->framework->deleteContentData($h5pid); - $h5pcontent = $DB->get_record('h5p', ['id' => $h5pid]); - $h5pcontentlibraries = $DB->get_record('h5p_contents_libraries', ['h5pid' => $h5pid]); - // The particular h5p content should no longer exist in the db. - $this->assertEmpty($h5pcontent); + $this->assertEmpty($DB->get_record('h5p', ['id' => $h5pid])); // The particular content libraries should no longer exist in the db. - $this->assertEmpty($h5pcontentlibraries); + $this->assertEmpty($DB->get_record('h5p_contents_libraries', ['h5pid' => $h5pid])); + // The xAPI state should be reseted. + $records = $DB->get_records('xapi_states'); + $record = reset($records); + $this->assertCount(1, $records); + $this->assertNull($record->statedata); + } + + /** + * Test the behaviour of resetContentUserData(). + */ + public function test_resetContentUserData() { + global $DB; + + $this->resetAfterTest(); + + /** @var \core_h5p_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + // For the mod_h5pactivity component, the activity needs to be created too. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($user); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $activitycontext = \context_module::instance($activity->cmid); + $filerecord = [ + 'contextid' => $activitycontext->id, + 'component' => 'mod_h5pactivity', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'dummy.h5p', + 'addxapistate' => true, + ]; + + // Generate some h5p related data. + $data = $generator->generate_h5p_data(false, $filerecord); + $h5pid = $data->h5pcontent->h5pid; + + // Make sure the H5P content, libraries and xAPI state exist in the DB. + $this->assertNotEmpty($DB->get_record('h5p', ['id' => $h5pid])); + $this->assertCount(5, $DB->get_records('h5p_contents_libraries', ['h5pid' => $h5pid])); + $records = $DB->get_records('xapi_states'); + $record = reset($records); + $this->assertCount(1, $records); + $this->assertNotNull($record->statedata); + + // Reset the user data associated to this H5P content. + $this->framework->resetContentUserData($h5pid); + + // The H5P content should still exist in the db. + $this->assertNotEmpty($DB->get_record('h5p', ['id' => $h5pid])); + // The particular content libraries should still exist in the db. + $this->assertCount(5, $DB->get_records('h5p_contents_libraries', ['h5pid' => $h5pid])); + // The xAPI state should still exist in the db, but should be reset. + $records = $DB->get_records('xapi_states'); + $record = reset($records); + $this->assertCount(1, $records); + $this->assertNull($record->statedata); } /** diff --git a/h5p/tests/generator/lib.php b/h5p/tests/generator/lib.php index 15a13fe8a40..7a4591e5521 100644 --- a/h5p/tests/generator/lib.php +++ b/h5p/tests/generator/lib.php @@ -14,21 +14,11 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Generator for the core_h5p subsystem. - * - * @package core_h5p - * @category test - * @copyright 2019 Victor Deniz - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - use core_h5p\local\library\autoloader; use core_h5p\core; use core_h5p\player; use core_h5p\factory; - -defined('MOODLE_INTERNAL') || die(); +use core_xapi\local\statement\item_activity; /** * Generator for the core_h5p subsystem. @@ -169,9 +159,10 @@ class core_h5p_generator extends \component_generator_base { * Populate H5P database tables with relevant data to simulate the process of adding H5P content. * * @param bool $createlibraryfiles Whether to create and store library files on the filesystem + * @param array|null $filerecord The file associated to the H5P entry. * @return stdClass An object representing the added H5P records */ - public function generate_h5p_data(bool $createlibraryfiles = false): stdClass { + public function generate_h5p_data(bool $createlibraryfiles = false, ?array $filerecord = null): stdClass { // Create libraries. $mainlib = $libraries[] = $this->create_library_record('MainLibrary', 'Main Lib', 1, 0, 1, '', null, 'http://tutorial.org', 'http://example.org'); @@ -189,7 +180,7 @@ class core_h5p_generator extends \component_generator_base { } // Create h5p content. - $h5p = $this->create_h5p_record($mainlib->id); + $h5p = $this->create_h5p_record($mainlib->id, null, null, $filerecord); // Create h5p content library dependencies. $this->create_contents_libraries_record($h5p, $mainlib->id); $this->create_contents_libraries_record($h5p, $lib1->id); @@ -289,9 +280,11 @@ class core_h5p_generator extends \component_generator_base { * @param int $mainlibid The ID of the content's main library * @param string $jsoncontent The content in json format * @param string $filtered The filtered content parameters + * @param array|null $filerecord The file associated to the H5P entry. * @return int The ID of the added record */ - public function create_h5p_record(int $mainlibid, string $jsoncontent = null, string $filtered = null): int { + public function create_h5p_record(int $mainlibid, string $jsoncontent = null, string $filtered = null, + ?array $filerecord = null): int { global $DB; if (!$jsoncontent) { @@ -312,18 +305,46 @@ class core_h5p_generator extends \component_generator_base { ); } + // Load the H5P file into DB. + $pathnamehash = sha1('pathname'); + $contenthash = sha1('content'); + if ($filerecord) { + $fs = get_file_storage(); + if (!$fs->get_file( + $filerecord['contextid'], + $filerecord['component'], + $filerecord['filearea'], + $filerecord['itemid'], + $filerecord['filepath'], + $filerecord['filename'])) { + $file = $fs->create_file_from_string($filerecord, $jsoncontent); + $pathnamehash = $file->get_pathnamehash(); + $contenthash = $file->get_contenthash(); + if (array_key_exists('addxapistate', $filerecord) && $filerecord['addxapistate']) { + // Save some xAPI state associated to this H5P content. + $params = [ + 'component' => $filerecord['component'], + 'activity' => item_activity::create_from_id($filerecord['contextid']), + ]; + global $CFG; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); + \core_xapi\test_helper::create_state($params, true); + } + } + } + return $DB->insert_record( 'h5p', - array( + [ 'jsoncontent' => $jsoncontent, 'displayoptions' => 8, 'mainlibraryid' => $mainlibid, 'timecreated' => time(), 'timemodified' => time(), 'filtered' => $filtered, - 'pathnamehash' => sha1('pathname'), - 'contenthash' => sha1('content') - ) + 'pathnamehash' => $pathnamehash, + 'contenthash' => $contenthash, + ] ); } diff --git a/h5p/tests/generator_test.php b/h5p/tests/generator_test.php index 910bc518793..623bf3cd220 100644 --- a/h5p/tests/generator_test.php +++ b/h5p/tests/generator_test.php @@ -19,13 +19,14 @@ namespace core_h5p; use core_h5p\local\library\autoloader; /** -* Test class covering the h5p data generator class. -* -* @package core_h5p -* @category test -* @copyright 2019 Mihail Geshoski -* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later -* @runTestsInSeparateProcesses + * Test class covering the h5p data generator class. + * + * @package core_h5p + * @category test + * @copyright 2019 Mihail Geshoski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @runTestsInSeparateProcesses + * @covers \core_h5p_generator */ class generator_test extends \advanced_testcase { @@ -207,6 +208,120 @@ class generator_test extends \advanced_testcase { ]; } + /** + * Test the returned data of generate_h5p_data() when the method requests + * creation of H5P file and xAPI states. + * + * @dataProvider generate_h5p_data_xapistates_provider + * @param array|null $filerecord + */ + public function test_generate_h5p_data_xapistates(?array $filerecord) { + global $DB; + + $this->resetAfterTest(); + + /** @var \core_h5p_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($user); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $activitycontext = \context_module::instance($activity->cmid); + if ($filerecord) { + $filerecord['contextid'] = $activitycontext->id; + $filerecord['component'] = 'mod_h5pactivity'; + $filerecord['filearea'] = 'package'; + $filerecord['itemid'] = 0; + $filerecord['filepath'] = '/'; + $filerecord['filepath'] = '/'; + $filerecord['filename'] = 'dummy.h5p'; + } + + $data = $generator->generate_h5p_data(false, $filerecord); + + $mainlib = $DB->get_record('h5p_libraries', ['machinename' => 'MainLibrary']); + $lib1 = $DB->get_record('h5p_libraries', ['machinename' => 'Library1']); + $lib2 = $DB->get_record('h5p_libraries', ['machinename' => 'Library2']); + $lib3 = $DB->get_record('h5p_libraries', ['machinename' => 'Library3']); + $lib4 = $DB->get_record('h5p_libraries', ['machinename' => 'Library4']); + $lib5 = $DB->get_record('h5p_libraries', ['machinename' => 'Library5']); + + $h5p = $DB->get_record('h5p', ['mainlibraryid' => $mainlib->id]); + + $expected = (object) [ + 'h5pcontent' => (object) [ + 'h5pid' => $h5p->id, + 'contentdependencies' => [$mainlib, $lib1, $lib2, $lib3, $lib4], + ], + 'mainlib' => (object) [ + 'data' => $mainlib, + 'dependencies' => [$lib1, $lib2, $lib3], + ], + 'lib1' => (object) [ + 'data' => $lib1, + 'dependencies' => [$lib2, $lib3, $lib4], + ], + 'lib2' => (object) [ + 'data' => $lib2, + 'dependencies' => [], + ], + 'lib3' => (object) [ + 'data' => $lib3, + 'dependencies' => [$lib5], + ], + 'lib4' => (object) [ + 'data' => $lib4, + 'dependencies' => [], + ], + 'lib5' => (object) [ + 'data' => $lib5, + 'dependencies' => [], + ], + ]; + + $this->assertEquals($expected, $data); + if ($filerecord) { + // Confirm the H5P file has been created (when $filerecord is not empty). + $fs = get_file_storage(); + $this->assertNotFalse($fs->get_file_by_hash($h5p->pathnamehash)); + // Confirm xAPI state has been created when $filerecord['addxapistate'] is given. + if (array_key_exists('addxapistate', $filerecord) && $filerecord['addxapistate']) { + $this->assertEquals(1, $DB->count_records('xapi_states')); + } else { + $this->assertEquals(0, $DB->count_records('xapi_states')); + } + } else { + // Confirm the H5P file doesn't exist when $filerecord is null. + $fs = get_file_storage(); + $this->assertFalse($fs->get_file_by_hash($h5p->pathnamehash)); + // Confirm xAPI state hasn't been created when $filerecord is null. + $this->assertEquals(0, $DB->count_records('xapi_states')); + } + } + + /** + * Data provider for test_generate_h5p_data_xapistates(). + * + * @return array + */ + public function generate_h5p_data_xapistates_provider(): array { + return [ + 'Do not create the file nor xAPI states' => [ + 'filerecord' => null, + ], + 'Create the H5P file but not create any xAPI state' => [ + 'filerecord' => [ + 'addxapistate' => false, + ], + ], + 'Create the H5P file and the xAPI state' => [ + 'filerecord' => [ + 'addxapistate' => true, + ], + ], + ]; + } + /** * Test the behaviour of create_library_record(). Test whether the library data is properly * saved in the database. diff --git a/mod/h5pactivity/classes/local/attempt.php b/mod/h5pactivity/classes/local/attempt.php index a200db2309e..a9bc130689c 100644 --- a/mod/h5pactivity/classes/local/attempt.php +++ b/mod/h5pactivity/classes/local/attempt.php @@ -25,6 +25,7 @@ namespace mod_h5pactivity\local; +use core_xapi\handler; use stdClass; use core_xapi\local\statement; @@ -83,6 +84,11 @@ class attempt { if (!$record->id) { return null; } + // Remove any xAPI State associated to this attempt. + $context = \context_module::instance($cm->id); + $xapihandler = handler::create('mod_h5pactivity'); + $xapihandler->wipe_states($context->id); + return new attempt($record); } diff --git a/mod/h5pactivity/classes/privacy/provider.php b/mod/h5pactivity/classes/privacy/provider.php index 729d68e0de6..ffe0925300a 100644 --- a/mod/h5pactivity/classes/privacy/provider.php +++ b/mod/h5pactivity/classes/privacy/provider.php @@ -14,15 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Defines {@link \mod_h5pactivity\privacy\provider} class. - * - * @package mod_h5pactivity - * @category privacy - * @copyright 2020 Ferran Recio - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_h5pactivity\privacy; use core_privacy\local\metadata\collection; @@ -38,6 +29,8 @@ use stdClass; /** * Privacy API implementation for the H5P activity plugin. * + * @package mod_h5pactivity + * @category privacy * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -46,16 +39,6 @@ class provider implements \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { - /** - * Get the language string identifier with the component's language - * file to explain why this plugin stores no data. - * - * @return string - */ - public static function get_reason() : string { - return 'privacy:metadata'; - } - /** * Return the fields which contain personal data. * @@ -77,6 +60,8 @@ class provider implements 'rawscore' => 'privacy:metadata:rawscore', ], 'privacy:metadata:xapi_track_results'); + $collection->add_subsystem_link('core_xapi', [], 'privacy:metadata:xapisummary'); + return $collection; } @@ -103,6 +88,8 @@ class provider implements $contextlist = new contextlist(); $contextlist->add_from_sql($sql, $params); + \core_xapi\privacy\provider::add_contexts_for_userid($contextlist, $userid, 'mod_h5pactivity'); + return $contextlist; } @@ -133,6 +120,8 @@ class provider implements $params = ['modlevel' => CONTEXT_MODULE, 'contextid' => $context->id]; $userlist->add_from_sql('userid', $sql, $params); + + \core_xapi\privacy\provider::add_userids_for_context($userlist); } /** @@ -163,6 +152,16 @@ class provider implements $data = helper::get_context_data($context, $user); writer::with_context($context)->export_data([], $data); helper::export_context_files($context, $user); + + // Get user's xAPI state data for the particular context. + $state = \core_xapi\privacy\provider::get_xapi_states_for_user($contextlist->get_user()->id, + 'mod_h5pactivity', $context->instanceid); + if ($state) { + // If the activity has xAPI state data by the user, include it in the export. + writer::with_context($context)->export_data( + [get_string('privacy:xapistate', 'core_xapi')], (object) $state); + } + } // Get attempts track data. @@ -226,7 +225,7 @@ class provider implements /** * Delete all user data which matches the specified context. * - * @param context $context A user context. + * @param \context $context A user context. */ public static function delete_data_for_all_users_in_context(\context $context) { // This should not happen, but just in case. @@ -241,6 +240,10 @@ class provider implements } self::delete_all_attempts($cm); + + // Delete xAPI state data. + \core_xapi\privacy\provider::delete_states_for_all_users($context, 'mod_h5pactivity'); + } /** @@ -264,6 +267,9 @@ class provider implements $user = $contextlist->get_user(); self::delete_all_attempts($cm, $user); + + // Delete xAPI state data. + \core_xapi\privacy\provider::delete_states_for_user($contextlist, 'mod_h5pactivity'); } } @@ -291,6 +297,10 @@ class provider implements foreach ($userids as $userid) { self::delete_all_attempts ($cm, (object)['id' => $userid]); } + + // Delete xAPI states data. + \core_xapi\privacy\provider::delete_states_for_userlist($userlist); + } /** diff --git a/mod/h5pactivity/classes/xapi/handler.php b/mod/h5pactivity/classes/xapi/handler.php index 358f6a52eea..df9ae61ab20 100644 --- a/mod/h5pactivity/classes/xapi/handler.php +++ b/mod/h5pactivity/classes/xapi/handler.php @@ -14,15 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * The xapi_handler for xAPI statements. - * - * @package mod_h5pactivity - * @since Moodle 3.9 - * @copyright 2020 Ferran Recio - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_h5pactivity\xapi; use mod_h5pactivity\local\attempt; @@ -31,7 +22,8 @@ use mod_h5pactivity\event\statement_received; use core_xapi\local\statement; use core_xapi\handler as handler_base; use core\event\base as event_base; -use context_module; +use core_xapi\local\state; +use moodle_exception; defined('MOODLE_INTERNAL') || die(); @@ -39,11 +31,12 @@ global $CFG; require_once($CFG->dirroot.'/mod/h5pactivity/lib.php'); /** - * Class xapi_handler for H5P statements. + * Class xapi_handler for H5P statements and states. * - * @package mod_h5pactivity + * @package mod_h5pactivity * @since Moodle 3.9 * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class handler extends handler_base { @@ -145,4 +138,49 @@ class handler extends handler_base { ]; return statement_received::create($params); } + + /** + * Validate a xAPI state. + * + * Check if the state is valid for this handler. + * + * This method is used also for the state get requests so the validation + * cannot rely on having state data. + * + * @param state $state + * @return bool if the state is valid or not + */ + protected function validate_state(state $state): bool { + $xapiobject = $state->get_activity_id(); + + // H5P add some extra params to ID to define subcontents. + $parts = explode('?', $xapiobject, 2); + $contextid = array_shift($parts); + if (empty($contextid) || !is_numeric($contextid)) { + return false; + } + + try { + $context = \context::instance_by_id($contextid); + if (!$context instanceof \context_module) { + return false; + } + } catch (moodle_exception $exception) { + return false; + } + + $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid, 0, false); + if (!$cm) { + return false; + } + + // If tracking is not enabled, the state won't be considered valid. + $manager = manager::create_from_coursemodule($cm); + $user = $state->get_user(); + if (!$manager->is_tracking_enabled($user)) { + return false; + } + + return true; + } } diff --git a/mod/h5pactivity/lang/en/h5pactivity.php b/mod/h5pactivity/lang/en/h5pactivity.php index 6372fbfa668..136642ea3f9 100644 --- a/mod/h5pactivity/lang/en/h5pactivity.php +++ b/mod/h5pactivity/lang/en/h5pactivity.php @@ -62,6 +62,8 @@ $string['displayembed'] = 'Embed button'; $string['displaycopyright'] = 'Copyright button'; $string['dnduploadh5pactivity'] = 'Add an H5P activity'; $string['duration'] = 'Duration'; +$string['enablesavestate'] = 'Save state'; +$string['enablesavestate_help'] = 'Automatically save the user\'s current state. The user can return later and resume where they left off.'; $string['enabletracking'] = 'Enable attempt tracking'; $string['false'] = 'False'; $string['grade_grademethod'] = 'Grading method'; @@ -112,6 +114,7 @@ $string['privacy:metadata:rawscore'] = 'The score obtained'; $string['privacy:metadata:timecreated'] = 'The time when the tracked element was created'; $string['privacy:metadata:timemodified'] = 'The last time element was tracked'; $string['privacy:metadata:userid'] = 'The ID of the user who accessed the H5P activity'; +$string['privacy:metadata:xapisummary'] = 'The H5P activity contains information relating to the xAPI content state stored by the user.'; $string['privacy:metadata:xapi_track'] = 'Attempt tracking information'; $string['privacy:metadata:xapi_track_results'] = 'Attempt results tracking information'; $string['report_viewed'] = 'Report viewed'; @@ -129,6 +132,8 @@ $string['review_my_attempts'] = 'View my attempts'; $string['review_user_attempts'] = 'View user attempts ({$a})'; $string['review_none'] = 'Participants cannot review their own attempts'; $string['review_on_completion'] = 'Participants can review their own attempts'; +$string['savestatefreq'] = 'Save state frequency'; +$string['savestatefreq_help'] = 'How often (in seconds) that the user\'s current state is saved.'; $string['score'] = 'Score'; $string['score_out_of'] = '{$a->rawscore} out of {$a->maxscore}'; $string['search:activity'] = 'H5P - activity information'; diff --git a/mod/h5pactivity/lib.php b/mod/h5pactivity/lib.php index f35965fade5..46864443db4 100644 --- a/mod/h5pactivity/lib.php +++ b/mod/h5pactivity/lib.php @@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die(); use mod_h5pactivity\local\manager; use mod_h5pactivity\local\grader; +use mod_h5pactivity\xapi\handler; /** * Checks if H5P activity supports a specific feature. @@ -145,6 +146,12 @@ function h5pactivity_delete_instance(int $id): bool { return false; } + if ($cm = get_coursemodule_from_instance('h5pactivity', $activity->id)) { + $context = context_module::instance($cm->id); + $xapihandler = handler::create('mod_h5pactivity'); + $xapihandler->wipe_states($context->id); + } + $DB->delete_records('h5pactivity', ['id' => $id]); h5pactivity_grade_item_delete($activity); @@ -270,6 +277,7 @@ function h5pactivity_reset_userdata(stdClass $data): array { $params = ['courseid' => $data->courseid]; $sql = "SELECT a.id FROM {h5pactivity} a WHERE a.course=:courseid"; if ($activities = $DB->get_records_sql($sql, $params)) { + $xapihandler = handler::create('mod_h5pactivity'); foreach ($activities as $activity) { $cm = get_coursemodule_from_instance('h5pactivity', $activity->id, @@ -277,6 +285,8 @@ function h5pactivity_reset_userdata(stdClass $data): array { false, MUST_EXIST); mod_h5pactivity\local\attempt::delete_all_attempts ($cm); + $context = context_module::instance($cm->id); + $xapihandler->wipe_states($context->id); } } // Remove all grades from gradebook. diff --git a/mod/h5pactivity/settings.php b/mod/h5pactivity/settings.php new file mode 100644 index 00000000000..226afb2ae53 --- /dev/null +++ b/mod/h5pactivity/settings.php @@ -0,0 +1,33 @@ +. + +/** + * Module admin settings. + * + * @package mod_h5pactivity + * @copyright 2023 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +if ($ADMIN->fulltree) { + $settings->add(new admin_setting_configcheckbox('mod_h5pactivity/enablesavestate', + get_string('enablesavestate', 'mod_h5pactivity'), get_string('enablesavestate_help', 'mod_h5pactivity'), 1)); + + $settings->add(new admin_setting_configtext('mod_h5pactivity/savestatefreq', + get_string('savestatefreq', 'mod_h5pactivity'), get_string('savestatefreq_help', 'mod_h5pactivity'), 60, PARAM_INT)); +} diff --git a/mod/h5pactivity/tests/behat/save_content_state.feature b/mod/h5pactivity/tests/behat/save_content_state.feature new file mode 100644 index 00000000000..95f00df6824 --- /dev/null +++ b/mod/h5pactivity/tests/behat/save_content_state.feature @@ -0,0 +1,143 @@ +@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe @javascript +Feature: Users can save the current state of an H5P activity + In order to continue an H5P activity where I left + As a user + I need to be able to save the current state + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | teacher1 | C1 | editingteacher | + And the following "permission overrides" exist: + | capability | permission | role | contextlevel | reference | + | moodle/h5p:updatelibraries | Allow | editingteacher | System | | + And the following "activity" exists: + | activity | h5pactivity | + | course | C1 | + | name | Awesome H5P package | + | packagefilepath | h5p/tests/fixtures/filltheblanks.h5p | + + Scenario: Content state is not saved when enablesavestate is disabled + Given the following config values are set as admin: + | enablesavestate | 0 | mod_h5pactivity| + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I switch to the main frame + And I am on the "Course 1" course page + When I am on the "Awesome H5P package" "h5pactivity activity" page + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" + + Scenario: Content state is saved when enablesavestate is enabled + Given the following config values are set as admin: + | enablesavestate | 1 | mod_h5pactivity| + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I switch to the main frame + And I am on the "Course 1" course page + When I am on the "Awesome H5P package" "h5pactivity activity" page + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia" + + Scenario: Content state is not saved for teachers when enablesavestate is enabled + Given the following config values are set as admin: + | enablesavestate | 1 | mod_h5pactivity| + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as teacher1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I switch to the main frame + And I am on the "Course 1" course page + When I am on the "Awesome H5P package" "h5pactivity activity" page + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" + + Scenario: Content state is reseted when content changes + Given the following config values are set as admin: + | enablesavestate | 1 | mod_h5pactivity| + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I switch to the main frame + And I am on the "Course 1" course page + When I am on the "Awesome H5P package" "h5pactivity activity" page logged in as admin + # Change the content. + And I follow "Edit H5P content" + And I switch to "h5p-editor-iframe" class iframe + And I set the field "Title" to "Capitals" + And I switch to the main frame + And I click on "Save changes" "button" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I should see "Check" + # Check the content state has been reseted. + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then I should see "Data Reset" + And I should see "This content has changed since you last used it." + And I click on "OK" "button" + And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" + + Scenario: Content state is not reseted when content edition is cancelled + Given the following config values are set as admin: + | enablesavestate | 1 | mod_h5pactivity| + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I switch to the main frame + And I am on the "Course 1" course page + When I am on the "Awesome H5P package" "h5pactivity activity" page logged in as admin + # Start content edition. + And I follow "Edit H5P content" + And I switch to "h5p-editor-iframe" class iframe + And I set the field "Title" to "Capitals" + And I switch to the main frame + And I click on "Cancel" "button" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I should see "Check" + # Check the content state hasn't been reseted. + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I should see "Awesome H5P package" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then I should not see "Data Reset" + And I should not see "This content has changed since you last used it." + And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia" + + Scenario: Content state is removed when an attempt is created + Given the following config values are set as admin: + | enablesavestate | 1 | mod_h5pactivity| + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + # Check there are no attempts. + And I should not see "Attempts report" + # Create an attempt. + When I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I click on "Check" "button" + # Check the state content has been removed. + And I reload the page + Then I should see "Attempts report" + And I am on the "Awesome H5P package" "h5pactivity activity" page + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" diff --git a/mod/h5pactivity/tests/lib_test.php b/mod/h5pactivity/tests/lib_test.php index 9bfa5ab774c..c0a78f3f4bc 100644 --- a/mod/h5pactivity/tests/lib_test.php +++ b/mod/h5pactivity/tests/lib_test.php @@ -14,14 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Unit tests for (some of) mod/h5pactivity/lib.php. - * - * @package mod_h5pactivity - * @copyright 2021 Ilya Tregubov - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - use mod_h5pactivity\local\manager; defined('MOODLE_INTERNAL') || die(); @@ -32,11 +24,81 @@ require_once($CFG->dirroot . '/mod/h5pactivity/lib.php'); /** * Unit tests for (some of) mod/h5pactivity/lib.php. * + * @package mod_h5pactivity * @copyright 2021 Ilya Tregubov * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class lib_test extends advanced_testcase { + /** + * Test that h5pactivity_delete_instance removes data. + * + * @covers ::h5pactivity_delete_instance + */ + public function test_h5pactivity_delete_instance() { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $this->setUser($user); + + /** @var \mod_h5pactivity_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity'); + + /** @var \core_h5p_generator $h5pgenerator */ + $h5pgenerator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + + // Add an attempt to the H5P activity. + $attemptinfo = [ + 'userid' => $user->id, + 'h5pactivityid' => $activity->id, + 'attempt' => 1, + 'interactiontype' => 'compound', + 'rawscore' => 2, + 'maxscore' => 2, + 'duration' => 1, + 'completion' => 1, + 'success' => 0, + ]; + $generator->create_attempt($attemptinfo); + + // Add also a xAPI state to the H5P activity. + $filerecord = [ + 'contextid' => \context_module::instance($activity->cmid)->id, + 'component' => 'mod_h5pactivity', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filepath' => '/', + 'filename' => 'dummy.h5p', + 'addxapistate' => true, + ]; + $h5pgenerator->generate_h5p_data(false, $filerecord); + + // Check the H5P activity exists and the attempt has been created. + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Check nothing happens when given activity id doesn't exist. + h5pactivity_delete_instance($activity->id + 1); + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Check the H5P instance and its associated data is removed. + h5pactivity_delete_instance($activity->id); + $this->assertEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(1, $DB->count_records('grade_items')); + $this->assertEquals(1, $DB->count_records('grade_grades')); + $this->assertEquals(0, $DB->count_records('xapi_states')); + } + /** * Test that assign_print_recent_activity shows ungraded submitted assignments. */ @@ -239,4 +301,102 @@ class lib_test extends advanced_testcase { $this->assertEquals($students[1]->id, $recentactivity[$students[1]->id]->userid); $this->assertEquals($students[2]->id, $recentactivity[$students[2]->id]->userid); } + + /** + * Test that h5pactivity_reset_userdata reset user data. + * + * @covers ::h5pactivity_reset_userdata + */ + public function test_h5pactivity_reset_userdata() { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $this->setUser($user); + + /** @var \mod_h5pactivity_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity'); + + /** @var \core_h5p_generator $h5pgenerator */ + $h5pgenerator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + + // Add an attempt to the H5P activity. + $attemptinfo = [ + 'userid' => $user->id, + 'h5pactivityid' => $activity->id, + 'attempt' => 1, + 'interactiontype' => 'compound', + 'rawscore' => 2, + 'maxscore' => 2, + 'duration' => 1, + 'completion' => 1, + 'success' => 0, + ]; + $generator->create_attempt($attemptinfo); + + // Add also a xAPI state to the H5P activity. + $filerecord = [ + 'contextid' => \context_module::instance($activity->cmid)->id, + 'component' => 'mod_h5pactivity', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filepath' => '/', + 'filename' => 'dummy.h5p', + 'addxapistate' => true, + ]; + $h5pgenerator->generate_h5p_data(false, $filerecord); + + // Check the H5P activity exists and the attempt has been created with the expected data. + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Check nothing happens when reset_h5pactivity is not set. + $data = new stdClass(); + h5pactivity_reset_userdata($data); + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Check nothing happens when reset_h5pactivity is not set. + $data = (object) [ + 'courseid' => $course->id, + ]; + h5pactivity_reset_userdata($data); + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Check nothing happens when the given course doesn't exist. + $data = (object) [ + 'reset_h5pactivity' => true, + 'courseid' => $course->id + 1, + ]; + h5pactivity_reset_userdata($data); + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Check the H5P instance and its associated data is reset. + $data = (object) [ + 'reset_h5pactivity' => true, + 'courseid' => $course->id, + ]; + h5pactivity_reset_userdata($data); + $this->assertNotEmpty($DB->get_record('h5pactivity', ['id' => $activity->id])); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(1, $DB->count_records('grade_grades')); + $this->assertEquals(0, $DB->count_records('xapi_states')); + } } diff --git a/mod/h5pactivity/tests/local/attempt_test.php b/mod/h5pactivity/tests/local/attempt_test.php index e4ff6b8d9a2..916ff117568 100644 --- a/mod/h5pactivity/tests/local/attempt_test.php +++ b/mod/h5pactivity/tests/local/attempt_test.php @@ -32,6 +32,7 @@ use \core_xapi\local\statement\item_activity; use \core_xapi\local\statement\item_definition; use \core_xapi\local\statement\item_verb; use \core_xapi\local\statement\item_result; +use core_xapi\test_helper; use stdClass; /** @@ -64,14 +65,25 @@ class attempt_test extends \advanced_testcase { * Test for create_attempt method. */ public function test_create_attempt() { + global $CFG, $DB; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); list($cm, $student) = $this->generate_testing_scenario(); + // Save the current state for this activity (before creating the first attempt). + $manager = manager::create_from_coursemodule($cm); + test_helper::create_state([ + 'activity' => item_activity::create_from_id($manager->get_context()->id), + 'component' => 'mod_h5pactivity', + ], true); + $this->assertEquals(1, $DB->count_records('xapi_states')); + // Create first attempt. $attempt = attempt::new_attempt($student, $cm); $this->assertEquals($student->id, $attempt->get_userid()); $this->assertEquals($cm->instance, $attempt->get_h5pactivityid()); $this->assertEquals(1, $attempt->get_attempt()); + $this->assertEquals(0, $DB->count_records('xapi_states')); // Create a second attempt. $attempt = attempt::new_attempt($student, $cm); diff --git a/mod/h5pactivity/tests/privacy/provider_test.php b/mod/h5pactivity/tests/privacy/provider_test.php index e39f1623e89..8317c9a919a 100644 --- a/mod/h5pactivity/tests/privacy/provider_test.php +++ b/mod/h5pactivity/tests/privacy/provider_test.php @@ -29,6 +29,9 @@ use \core_privacy\local\request\approved_contextlist; use \core_privacy\local\request\approved_userlist; use \core_privacy\local\request\writer; use \core_privacy\tests\provider_testcase; +use core_xapi\local\statement\item_activity; +use core_xapi\test_helper; +use stdClass; /** * Privacy tests class for mod_h5pactivity. @@ -37,6 +40,7 @@ use \core_privacy\tests\provider_testcase; * @category test * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_h5pactivity\privacy\provider */ class provider_test extends provider_testcase { @@ -49,7 +53,10 @@ class provider_test extends provider_testcase { /** @var stdClass User with some attempt. */ protected $student2; - /** @var context context_module of the H5P activity. */ + /** @var stdClass User with some attempt. */ + protected $student3; + + /** @var \context context_module of the H5P activity. */ protected $context; /** @@ -149,24 +156,22 @@ class provider_test extends provider_testcase { $this->resetAfterTest(true); $this->setAdminUser(); - $this->h5pactivity_setup_test_scenario_data(); + $this->h5pactivity_setup_test_scenario_data(true); - // Before deletion, we should have 4 entries in the attempts table. - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(4, $count); - // Before deletion, we should have 12 entries in the results table. - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(12, $count); + // Check data before deletion. + $this->assertEquals(6, $DB->count_records('h5pactivity_attempts')); + $this->assertEquals(18, $DB->count_records('h5pactivity_attempts_results')); + $this->assertEquals(2, $DB->count_records('xapi_states')); // Delete data based on the context. provider::delete_data_for_all_users_in_context($this->context); // After deletion, the attempts entries should have been deleted. - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(0, $count); + $this->assertEquals(0, $DB->count_records('h5pactivity_attempts')); // After deletion, the results entries should have been deleted. - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(0, $count); + $this->assertEquals(0, $DB->count_records('h5pactivity_attempts_results')); + // After deletion, the xapi states should have been deleted. + $this->assertEquals(0, $DB->count_records('xapi_states')); } /** @@ -177,16 +182,14 @@ class provider_test extends provider_testcase { $this->resetAfterTest(true); $this->setAdminUser(); - $this->h5pactivity_setup_test_scenario_data(); + $this->h5pactivity_setup_test_scenario_data(true); $params = ['userid' => $this->student1->id]; - // Before deletion, we should have 4 entries in the attempts table. - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(4, $count); - // Before deletion, we should have 12 entries in the results table. - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(12, $count); + // Check data before deletion. + $this->assertEquals(6, $DB->count_records('h5pactivity_attempts')); + $this->assertEquals(18, $DB->count_records('h5pactivity_attempts_results')); + $this->assertEquals(2, $DB->count_records('xapi_states')); // Save student1 attempts ids. $attemptsids = $DB->get_records_menu('h5pactivity_attempts', $params, '', 'attempt, id'); @@ -197,16 +200,15 @@ class provider_test extends provider_testcase { provider::delete_data_for_user($approvedcontextlist); // After deletion, the h5pactivity_attempts entries for the first student should have been deleted. - $count = $DB->count_records('h5pactivity_attempts', $params); - $this->assertEquals(0, $count); - - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(2, $count); + $this->assertEquals(0, $DB->count_records('h5pactivity_attempts', $params)); + $this->assertEquals(4, $DB->count_records('h5pactivity_attempts')); // After deletion, the results entries for the first student should have been deleted. $count = $DB->count_records_select('h5pactivity_attempts_results', $resultselect, $attemptids); $this->assertEquals(0, $count); - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(6, $count); + $this->assertEquals(12, $DB->count_records('h5pactivity_attempts_results')); + // After deletion, the results entries for the first student should have been deleted. + $this->assertEquals(0, $DB->count_records('xapi_states', $params)); + $this->assertEquals(1, $DB->count_records('xapi_states')); // Confirm that the h5pactivity hasn't been removed. $h5pactivitycount = $DB->get_records('h5pactivity'); @@ -216,10 +218,9 @@ class provider_test extends provider_testcase { $approvedcontextlist = new approved_contextlist($this->student0, 'h5pactivity', [$this->context->id]); provider::delete_data_for_user($approvedcontextlist); - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(2, $count); - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(6, $count); + $this->assertEquals(4, $DB->count_records('h5pactivity_attempts')); + $this->assertEquals(12, $DB->count_records('h5pactivity_attempts_results')); + $this->assertEquals(1, $DB->count_records('xapi_states')); } /** @@ -235,12 +236,10 @@ class provider_test extends provider_testcase { // Create student2 with 2 attempts. $this->h5pactivity_setup_test_scenario_data(true); - // Before deletion, we should have 6 entries in the attempts table. - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(6, $count); - // Before deletion, we should have 18 entries in the results table. - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(18, $count); + // Check data before deletion. + $this->assertEquals(6, $DB->count_records('h5pactivity_attempts')); + $this->assertEquals(18, $DB->count_records('h5pactivity_attempts_results')); + $this->assertEquals(2, $DB->count_records('xapi_states')); // Save student1 and student2 attempts ids. $params1 = ['userid' => $this->student1->id]; @@ -256,18 +255,17 @@ class provider_test extends provider_testcase { provider::delete_data_for_users($approvedlist); // After deletion, the h5pactivity_attempts entries for student1 and student2 should have been deleted. - $count = $DB->count_records('h5pactivity_attempts', $params1); - $this->assertEquals(0, $count); - $count = $DB->count_records('h5pactivity_attempts', $params2); - $this->assertEquals(0, $count); + $this->assertEquals(0, $DB->count_records('h5pactivity_attempts', $params1)); + $this->assertEquals(0, $DB->count_records('h5pactivity_attempts', $params2)); + $this->assertEquals(0, $DB->count_records('xapi_states', $params1)); + $this->assertEquals(0, $DB->count_records('xapi_states', $params2)); - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(2, $count); + $this->assertEquals(2, $DB->count_records('h5pactivity_attempts')); // After deletion, the results entries for the first and second student should have been deleted. $count = $DB->count_records_select('h5pactivity_attempts_results', $resultselect, $attemptids); $this->assertEquals(0, $count); - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(6, $count); + $this->assertEquals(6, $DB->count_records('h5pactivity_attempts_results')); + $this->assertEquals(1, $DB->count_records('xapi_states')); // Confirm that the h5pactivity hasn't been removed. $h5pactivitycount = $DB->get_records('h5pactivity'); @@ -278,10 +276,9 @@ class provider_test extends provider_testcase { $approvedlist = new approved_userlist($this->context, $component, $approveduserids); provider::delete_data_for_users($approvedlist); - $count = $DB->count_records('h5pactivity_attempts'); - $this->assertEquals(2, $count); - $count = $DB->count_records('h5pactivity_attempts_results'); - $this->assertEquals(6, $count); + $this->assertEquals(2, $DB->count_records('h5pactivity_attempts')); + $this->assertEquals(6, $DB->count_records('h5pactivity_attempts_results')); + $this->assertEquals(1, $DB->count_records('xapi_states')); } /** @@ -291,7 +288,8 @@ class provider_test extends provider_testcase { * @param bool $extrauser generate a 3rd user (default false). */ protected function h5pactivity_setup_test_scenario_data(bool $extrauser = false): void { - global $DB; + global $CFG, $USER; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); $generator = $this->getDataGenerator(); @@ -301,19 +299,24 @@ class provider_test extends provider_testcase { $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST); $this->context = \context_module::instance($activity->cmid); - // Users enrolments. - $studentrole = $DB->get_record('role', ['shortname' => 'student']); - + /** @var \mod_h5pactivity_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity'); - // Create student0 withot any attempt. + // Create student0 without any attempt. $this->student0 = $this->getDataGenerator()->create_and_enrol($course, 'student'); - // Create student1 with 2 attempts. + // Create student1 with 2 attempts and 1 xapi state. $this->student1 = $this->getDataGenerator()->create_and_enrol($course, 'student'); $params = ['cmid' => $cm->id, 'userid' => $this->student1->id]; $generator->create_content($activity, $params); $generator->create_content($activity, $params); + $currentuser = $USER; + $this->setUser($this->student1); + test_helper::create_state([ + 'activity' => item_activity::create_from_id($this->context->id), + 'component' => 'mod_h5pactivity', + ], true); + $this->setUser($currentuser); // Create student2 with 2 attempts. $this->student2 = $this->getDataGenerator()->create_and_enrol($course, 'student'); @@ -326,6 +329,14 @@ class provider_test extends provider_testcase { $params = ['cmid' => $cm->id, 'userid' => $this->student3->id]; $generator->create_content($activity, $params); $generator->create_content($activity, $params); + // Add 1 xapi state. + $currentuser = $USER; + $this->setUser($this->student3); + test_helper::create_state([ + 'activity' => item_activity::create_from_id($this->context->id), + 'component' => 'mod_h5pactivity', + ], true); + $this->setUser($currentuser); } } } diff --git a/mod/h5pactivity/tests/xapi/handler_test.php b/mod/h5pactivity/tests/xapi/handler_test.php index 1db42c99775..02880e0c9da 100644 --- a/mod/h5pactivity/tests/xapi/handler_test.php +++ b/mod/h5pactivity/tests/xapi/handler_test.php @@ -14,15 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * mod_h5pactivity generator tests - * - * @package mod_h5pactivity - * @category test - * @copyright 2020 Ferran Recio - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_h5pactivity\xapi; use \core_xapi\local\statement; @@ -32,6 +23,7 @@ use \core_xapi\local\statement\item_definition; use \core_xapi\local\statement\item_verb; use \core_xapi\local\statement\item_result; use context_module; +use core_xapi\test_helper; use stdClass; /** @@ -41,9 +33,18 @@ use stdClass; * @category test * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_h5pactivity\xapi\handler */ class handler_test extends \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'); + } + /** * Generate a valid scenario for each tests. * @@ -386,4 +387,89 @@ class handler_test extends \advanced_testcase { return $statements; } + + /** + * Test validate_state method. + */ + public function test_validate_state(): void { + global $DB; + + $this->resetAfterTest(); + + /** @var \core_h5p_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + + // Create a valid H5P activity with a valid xAPI state. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($user); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $coursecontext = \context_course::instance($course->id); + $activitycontext = \context_module::instance($activity->cmid); + $component = 'mod_h5pactivity'; + $filerecord = [ + 'contextid' => $activitycontext->id, + 'component' => $component, + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'dummy.h5p', + 'addxapistate' => true, + ]; + $generator->generate_h5p_data(false, $filerecord); + + $handler = handler::create($component); + // Change the method visibility for validate_state in order to test it. + $method = new \ReflectionMethod(handler::class, 'validate_state'); + $method->setAccessible(true); + + // The activity id should be numeric. + $state = test_helper::create_state(['activity' => item_activity::create_from_id('AA')]); + $result = $method->invoke($handler, $state); + $this->assertFalse($result); + + // The activity id should exist. + $state = test_helper::create_state(); + $result = $method->invoke($handler, $state); + $this->assertFalse($result); + + // The given activity should be H5P activity. + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course]); + $state = test_helper::create_state([ + 'activity' => item_activity::create_from_id($forum->cmid), + ]); + $result = $method->invoke($handler, $state); + $this->assertFalse($result); + + // Tracking should be enabled for the H5P activity. + $state = test_helper::create_state([ + 'activity' => item_activity::create_from_id($activitycontext->id), + 'component' => $component, + ]); + $result = $method->invoke($handler, $state); + $this->assertTrue($result); + + // So, when tracking is disabled, the state won't be considered valid. + $activity2 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course, 'enabletracking' => 0]); + $activitycontext2 = \context_module::instance($activity2->cmid); + $state = test_helper::create_state([ + 'activity' => item_activity::create_from_id($activitycontext2->id), + 'component' => $component, + ]); + $result = $method->invoke($handler, $state); + $this->assertFalse($result); + + // The user should have permission to submit. + $studentrole = $DB->get_record('role', array('shortname' => 'student')); + assign_capability('mod/h5pactivity:submit', CAP_PROHIBIT, $studentrole->id, $coursecontext->id); + // Empty all the caches that may be affected by this change. + accesslib_clear_all_caches_for_unit_testing(); + \course_modinfo::clear_instance_cache(); + $state = test_helper::create_state([ + 'activity' => item_activity::create_from_id($activitycontext->id), + 'component' => $component, + ]); + $result = $method->invoke($handler, $state); + $this->assertFalse($result); + } } diff --git a/mod/h5pactivity/version.php b/mod/h5pactivity/version.php index 369423bfbb2..a2dc8cf9bbf 100644 --- a/mod/h5pactivity/version.php +++ b/mod/h5pactivity/version.php @@ -25,5 +25,5 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_h5pactivity'; -$plugin->version = 2022112800; +$plugin->version = 2023020900; $plugin->requires = 2022111800;