Merge branch 'MDL-67789-master' of https://github.com/sarjona/moodle

This commit is contained in:
Andrew Nicols
2023-03-17 13:03:24 +01:00
committed by Sara Arjona
52 changed files with 4642 additions and 233 deletions
+8
View File
@@ -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',
+10
View File
@@ -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 <andrew@nicols.co.uk>
* @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
+1
View File
@@ -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 <http://www.gnu.org/licenses/>.\n\n/**\n * Module to handle AJAX interactions.\n *\n * @module core_h5p/repository\n * @copyright 2023 Andrew Nicols <[email protected]>\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"}
+99
View File
@@ -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 <http://www.gnu.org/licenses/>.
/**
* Module to handle AJAX interactions.
*
* @module core_h5p/repository
* @copyright 2023 Andrew Nicols <[email protected]>
* @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];
+39 -12
View File
@@ -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);
+16 -9
View File
@@ -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' => []
+65 -4
View File
@@ -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.
+5
View File
@@ -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),
@@ -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 = {
+90 -14
View File
@@ -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));
}
});
+84
View File
@@ -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 <http://www.gnu.org/licenses/>.
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);
}
};
+89 -14
View File
@@ -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 <[email protected]>
* @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);
}
/**
+39 -18
View File
@@ -14,21 +14,11 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Generator for the core_h5p subsystem.
*
* @package core_h5p
* @category test
* @copyright 2019 Victor Deniz <[email protected]>
* @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,
]
);
}
+122 -7
View File
@@ -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 <[email protected]>
* @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 <[email protected]>
* @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.
+13 -1
View File
@@ -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';
+5
View File
@@ -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);
+21
View File
@@ -4764,5 +4764,26 @@
<INDEX NAME="userid-coursemoduleid" UNIQUE="true" FIELDS="userid, coursemoduleid"/>
</INDEXES>
</TABLE>
<TABLE NAME="xapi_states" COMMENT="The stored xAPI states">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="component" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" COMMENT="The component name"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="itemid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The Agent Id (usually the plugin instance)"/>
<FIELD NAME="stateid" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" COMMENT="Component identified for the state data"/>
<FIELD NAME="statedata" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="JSON state data"/>
<FIELD NAME="registration" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Optional registration identifier"/>
<FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="component-itemid" UNIQUE="false" FIELDS="component, itemid"/>
<INDEX NAME="userid" UNIQUE="false" FIELDS="userid"/>
<INDEX NAME="timemodified" UNIQUE="false" FIELDS="timemodified"/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>
+27
View File
@@ -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' => '',
+10 -1
View File
@@ -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' => '*'
],
);
+37
View File
@@ -3162,5 +3162,42 @@ privatefiles,moodle|/user/files.php';
upgrade_main_savepoint(true, 2023031400.01);
}
if ($oldversion < 2023031400.02) {
// 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.02);
}
return true;
}
+69
View File
@@ -0,0 +1,69 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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();
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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');
}
}
+124 -18
View File
@@ -14,34 +14,28 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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);
}
}
@@ -0,0 +1,99 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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;
}
}
+198 -16
View File
@@ -14,33 +14,215 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace core_xapi;
use core_xapi\local\state;
/**
* The state store manager.
*
* @package core_xapi
* @since Moodle 4.2
* @copyright 2022 Ferran Recio <[email protected]>
* @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]
);
}
}
@@ -0,0 +1,44 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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();
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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']));
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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);
}
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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);
}
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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);
}
}
+21
View File
@@ -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;
}
}
+234 -13
View File
@@ -14,14 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
}
}
+40 -1
View File
@@ -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;
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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]));
}
}
+441
View File
@@ -0,0 +1,441 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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']));
}
}
@@ -0,0 +1,87 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
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 ([email protected])
* @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']));
}
}
@@ -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);
}
+30 -20
View File
@@ -14,15 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Defines {@link \mod_h5pactivity\privacy\provider} class.
*
* @package mod_h5pactivity
* @category privacy
* @copyright 2020 Ferran Recio <[email protected]>
* @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 <[email protected]>
* @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);
}
/**
+50 -12
View File
@@ -14,15 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* The xapi_handler for xAPI statements.
*
* @package mod_h5pactivity
* @since Moodle 3.9
* @copyright 2020 Ferran Recio <[email protected]>
* @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 <[email protected]>
* @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;
}
}
+5
View File
@@ -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';
+10
View File
@@ -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.
+33
View File
@@ -0,0 +1,33 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Module admin settings.
*
* @package mod_h5pactivity
* @copyright 2023 Sara Arjona ([email protected])
* @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));
}
@@ -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"
+168 -8
View File
@@ -14,14 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Unit tests for (some of) mod/h5pactivity/lib.php.
*
* @package mod_h5pactivity
* @copyright 2021 Ilya Tregubov <[email protected]>
* @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 <[email protected]>
* @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.
*/
@@ -253,4 +315,102 @@ class lib_test extends advanced_testcase {
// activity for separate groups mode.
$this->assertCount(0, $recentactivity);
}
/**
* 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'));
}
}
@@ -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);
+65 -54
View File
@@ -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 <[email protected]>
* @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);
}
}
}
+95 -10
View File
@@ -14,25 +14,16 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* mod_h5pactivity generator tests
*
* @package mod_h5pactivity
* @category test
* @copyright 2020 Ferran Recio <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
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;
use \core_xapi\local\statement\item_verb;
use \core_xapi\local\statement\item_result;
use context_module;
use core_xapi\test_helper;
use stdClass;
/**
@@ -42,9 +33,18 @@ use stdClass;
* @category test
* @copyright 2020 Ferran Recio <[email protected]>
* @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.
*
@@ -387,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);
}
}
+1 -1
View File
@@ -25,5 +25,5 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'mod_h5pactivity';
$plugin->version = 2022112800;
$plugin->version = 2023020900;
$plugin->requires = 2022111800;