diff --git a/h5p/amd/build/repository.min.js b/h5p/amd/build/repository.min.js new file mode 100644 index 0000000000000..751ebb9bd4433 --- /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 0000000000000..868ebc6fa6cc2 --- /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 0000000000000..a1b48e8f92357 --- /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 711cefcea37d0..b87a54bd0dcb5 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 @@ public function insertContent($content, $contentmainid = null) { 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 @@ public function updateContent($content, $contentmainid = null) { '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 @@ public function updateContent($content, $contentmainid = null) { * @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 @@ public function copyLibraryUsage($contentid, $copyfromid, $contentmainid = null) 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 002a90deb4e32..4cf36b287e47e 100644 --- a/h5p/classes/helper.php +++ b/h5p/classes/helper.php @@ -314,18 +314,20 @@ public static function get_cache_buster(): string { /** * 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 @@ public static function get_core_settings(): array { $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 @@ public static function get_core_settings(): array { /** * 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 518ae483109bb..3492ffe13406b 100644 --- a/h5p/classes/player.php +++ b/h5p/classes/player.php @@ -27,7 +27,11 @@ 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 @@ public function get_messages(): \stdClass { * 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 @@ public function add_assets_to_page() { \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 @@ public function add_assets_to_page() { '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 @@ public function add_assets_to_page() { $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 @@ private function get_cid(): string { */ 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 4b296f1c2ba81..744fe2f5a2603 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 d9a2c3ac2ceb4..72ea15c55ff21 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 892adcfa5c7dd..f8ee27034685c 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 a0b6bf3a868a2..96b40c429ca41 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 729808245a3ce..1dd5e391ebd17 100644 --- a/h5p/tests/framework_test.php +++ b/h5p/tests/framework_test.php @@ -20,6 +20,8 @@ use Moodle\H5PCore; use Moodle\H5PDisplayOptionBehaviour; +// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod + /** * * Test class covering the H5PFrameworkInterface interface implementation. @@ -28,6 +30,7 @@ * @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 @@ public function test_updateContent() { $this->resetAfterTest(); + /** @var \core_h5p_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); // Create a library record. @@ -1085,6 +1089,8 @@ public function test_updateContent() { // 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 @@ public function test_deleteContentData() { $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 15a13fe8a40f3..7a4591e55216f 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 @@ private function save_library(stdClass $lib) { * 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 @@ public function generate_h5p_data(bool $createlibraryfiles = false): stdClass { } // 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 @@ public function create_library_record(string $machinename, string $title, int $m * @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 @@ public function create_h5p_record(int $mainlibid, string $jsoncontent = null, st ); } + // 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 910bc5187931c..623bf3cd220e5 100644 --- a/h5p/tests/generator_test.php +++ b/h5p/tests/generator_test.php @@ -19,13 +19,14 @@ 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 @@ public function generate_h5p_data_files_creation_provider(): array { ]; } + /** + * 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 a200db2309e66..a9bc130689c1a 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 @@ public static function new_attempt(stdClass $user, stdClass $cm): ?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 729d68e0de66c..ffe0925300a85 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 @@ /** * 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 @@ public static function get_metadata(collection $collection) : collection { 'rawscore' => 'privacy:metadata:rawscore', ], 'privacy:metadata:xapi_track_results'); + $collection->add_subsystem_link('core_xapi', [], 'privacy:metadata:xapisummary'); + return $collection; } @@ -103,6 +88,8 @@ public static function get_contexts_for_userid(int $userid) : contextlist { $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 @@ public static function get_users_in_context(userlist $userlist) { $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 @@ public static function export_user_data(approved_contextlist $contextlist) { $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 @@ public static function export_user_data(approved_contextlist $contextlist) { /** * 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 @@ public static function delete_data_for_all_users_in_context(\context $context) { } 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 @@ public static function delete_data_for_user(approved_contextlist $contextlist) { $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 @@ public static function delete_data_for_users(approved_userlist $userlist) { 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 358f6a52eeac7..df9ae61ab2094 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 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 @@ 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 @@ public function statement_to_event(statement $statement): ?event_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 6372fbfa66895..136642ea3f94d 100644 --- a/mod/h5pactivity/lang/en/h5pactivity.php +++ b/mod/h5pactivity/lang/en/h5pactivity.php @@ -62,6 +62,8 @@ $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: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_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 f35965fade5d7..46864443db4de 100644 --- a/mod/h5pactivity/lib.php +++ b/mod/h5pactivity/lib.php @@ -26,6 +26,7 @@ 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 0000000000000..226afb2ae5365 --- /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 0000000000000..95f00df682406 --- /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 9bfa5ab774c9a..c0a78f3f4bc97 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 @@ /** * 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 @@ public function test_h5pactivity_fetch_recent_activity() { $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 e4ff6b8d9a2f3..916ff117568f7 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_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 @@ private function generate_testing_scenario(): array { * 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 e39f1623e8971..8317c9a919a80 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_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 @@ * @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 @@ public function test_delete_data_for_all_users_in_context() { $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 @@ public function test_delete_data_for_user() { $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 @@ public function test_delete_data_for_user() { 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 @@ public function test_delete_data_for_user() { $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 @@ public function test_delete_data_for_users() { // 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 @@ public function test_delete_data_for_users() { 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 @@ public function test_delete_data_for_users() { $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 @@ public function test_delete_data_for_users() { * @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 @@ protected function h5pactivity_setup_test_scenario_data(bool $extrauser = false) $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 @@ protected function h5pactivity_setup_test_scenario_data(bool $extrauser = false) $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 1db42c9977583..02880e0c9dada 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_verb; use \core_xapi\local\statement\item_result; use context_module; +use core_xapi\test_helper; use stdClass; /** @@ -41,9 +33,18 @@ * @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 @@ private function generate_statements(context_module $context, stdClass $user): a 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 369423bfbb2b5..a2dc8cf9bbf3e 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;