diff --git a/mod/h5pactivity/classes/event/course_module_instance_list_viewed.php b/mod/h5pactivity/classes/event/course_module_instance_list_viewed.php
index f0a720495d9..8700085dd0c 100644
--- a/mod/h5pactivity/classes/event/course_module_instance_list_viewed.php
+++ b/mod/h5pactivity/classes/event/course_module_instance_list_viewed.php
@@ -15,7 +15,7 @@
// along with Moodle. If not, see .
/**
- * Plugin event classes are defined here.
+ * H5P Activity list viewed event.
*
* @package mod_h5pactivity
* @copyright 2020 Ferran Recio
diff --git a/mod/h5pactivity/classes/event/course_module_viewed.php b/mod/h5pactivity/classes/event/course_module_viewed.php
index 4dabab3e9d5..93604a6ca62 100644
--- a/mod/h5pactivity/classes/event/course_module_viewed.php
+++ b/mod/h5pactivity/classes/event/course_module_viewed.php
@@ -15,7 +15,7 @@
// along with Moodle. If not, see .
/**
- * Plugin event classes are defined here.
+ * H5P activity viewed.
*
* @package mod_h5pactivity
* @copyright 2020 Ferran Recio
diff --git a/mod/h5pactivity/classes/event/statement_received.php b/mod/h5pactivity/classes/event/statement_received.php
new file mode 100644
index 00000000000..c6a3b646078
--- /dev/null
+++ b/mod/h5pactivity/classes/event/statement_received.php
@@ -0,0 +1,97 @@
+.
+
+/**
+ * H5P activity send an xAPI tracking statement.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The statement_received event class.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class statement_received extends \core\event\base {
+
+ /**
+ * Init method.
+ *
+ * @return void
+ */
+ protected function init(): void {
+ $this->data['objecttable'] = 'h5pactivity';
+ $this->data['crud'] = 'u';
+ $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+ }
+
+ /**
+ * Returns localised general event name.
+ *
+ * @return string
+ */
+ public static function get_name() {
+ return get_string('statement_received', 'mod_h5pactivity');
+ }
+
+ /**
+ * Replace add_to_log() statement.
+ *
+ * @return array of parameters to be passed to legacy add_to_log() function.
+ */
+ protected function get_legacy_logdata() {
+ return [$this->courseid, 'h5pactivity', 'statement received', 'grade.php?user=' . $this->userid,
+ 0, $this->contextinstanceid];
+ }
+
+ /**
+ * Returns non-localised description of what happened.
+ *
+ * @return string
+ */
+ public function get_description() {
+ return "The user with the id '$this->userid' send a tracking statement " .
+ "for a H5P activity with the course module id '$this->contextinstanceid'.";
+ }
+
+ /**
+ * Get URL related to the action
+ *
+ * @return \moodle_url
+ */
+ public function get_url() {
+ return new \moodle_url('/mod/h5pactivity/grade.php',
+ ['id' => $this->contextinstanceid, 'user' => $this->userid]);
+ }
+
+ /**
+ * This is used when restoring course logs where it is required that we
+ * map the objectid to it's new value in the new course.
+ *
+ * @return array
+ */
+ public static function get_objectid_mapping() {
+ return ['db' => 'h5pactivity', 'restore' => 'h5pactivity'];
+ }
+}
diff --git a/mod/h5pactivity/classes/local/attempt.php b/mod/h5pactivity/classes/local/attempt.php
new file mode 100644
index 00000000000..84890034a99
--- /dev/null
+++ b/mod/h5pactivity/classes/local/attempt.php
@@ -0,0 +1,367 @@
+.
+
+/**
+ * H5P activity attempt object
+ *
+ * @package mod_h5pactivity
+ * @since Moodle 3.9
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use stdClass;
+use core_xapi\local\statement;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class attempt for H5P activity
+ *
+ * @package mod_h5pactivity
+ * @since Moodle 3.9
+ * @copyright 2020 Ferran Recio
+ */
+class attempt {
+
+ /** @var stdClass the h5pactivity_attempts record. */
+ private $record;
+
+ /**
+ * Create a new attempt object.
+ *
+ * @param stdClass $record the h5pactivity_attempts record
+ */
+ protected function __construct(stdClass $record) {
+ $this->record = $record;
+ $this->results = null;
+ }
+
+ /**
+ * Create a new user attempt in a specific H5P activity.
+ *
+ * @param stdClass $user a user record
+ * @param stdClass $cm a course_module record
+ * @return attempt|null a new attempt object or null if fail
+ */
+ public static function new_attempt(stdClass $user, stdClass $cm): ?attempt {
+ global $DB;
+ $record = new stdClass();
+ $record->h5pactivityid = $cm->instance;
+ $record->userid = $user->id;
+ $record->timecreated = time();
+ $record->timemodified = $record->timecreated;
+ $record->rawscore = 0;
+ $record->maxscore = 0;
+
+ // Get last attempt number.
+ $conditions = ['h5pactivityid' => $cm->instance, 'userid' => $user->id];
+ $countattempts = $DB->count_records('h5pactivity_attempts', $conditions);
+ $record->attempt = $countattempts + 1;
+
+ $record->id = $DB->insert_record('h5pactivity_attempts', $record);
+ if (!$record->id) {
+ return null;
+ }
+ return new attempt($record);
+ }
+
+ /**
+ * Get the last user attempt in a specific H5P activity.
+ *
+ * If no previous attempt exists, it generates a new one.
+ *
+ * @param stdClass $user a user record
+ * @param stdClass $cm a course_module record
+ * @return attempt|null a new attempt object or null if some problem accured
+ */
+ public static function last_attempt(stdClass $user, stdClass $cm): ?attempt {
+ global $DB;
+ $conditions = ['h5pactivityid' => $cm->instance, 'userid' => $user->id];
+ $records = $DB->get_records('h5pactivity_attempts', $conditions, 'attempt DESC', '*', 0, 1);
+ if (empty($records)) {
+ return self::new_attempt($user, $cm);
+ }
+ return new attempt(array_shift($records));
+ }
+
+ /**
+ * Wipe all attempt data for specific course_module and an optional user.
+ *
+ * @param stdClass $cm a course_module record
+ * @param stdClass $user a user record
+ */
+ public static function delete_all_attempts(stdClass $cm, stdClass $user = null): void {
+ global $DB;
+
+ $where = 'a.h5pactivityid = :h5pactivityid';
+ $conditions = ['h5pactivityid' => $cm->instance];
+ if (!empty($user)) {
+ $where .= ' AND a.userid = :userid';
+ $conditions['userid'] = $user->id;
+ }
+
+ $DB->delete_records_select('h5pactivity_attempts_results', "attemptid IN (
+ SELECT a.id
+ FROM {h5pactivity_attempts} a
+ WHERE $where)", $conditions);
+
+ $DB->delete_records('h5pactivity_attempts', $conditions);
+ }
+
+ /**
+ * Delete a specific attempt.
+ *
+ * @param attempt $attempt the attempt object to delete
+ */
+ public static function delete_attempt(attempt $attempt): void {
+ global $DB;
+ $attempt->delete_results();
+ $DB->delete_records('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+ }
+
+ /**
+ * Save a new result statement into the attempt.
+ *
+ * It also updates the rawscore and maxscore if necessary.
+ *
+ * @param statement $statement the xAPI statement object
+ * @param string $subcontent = '' optional subcontent identifier
+ * @return bool if it can save the statement into db
+ */
+ public function save_statement(statement $statement, string $subcontent = ''): bool {
+ global $DB;
+
+ // Check statement data.
+ $xapiobject = $statement->get_object();
+ if (empty($xapiobject)) {
+ return false;
+ }
+ $xapiresult = $statement->get_result();
+ $xapidefinition = $xapiobject->get_definition();
+ if (empty($xapidefinition) || empty($xapiresult)) {
+ return false;
+ }
+
+ $xapicontext = $statement->get_context();
+ if ($xapicontext) {
+ $context = $xapicontext->get_data();
+ } else {
+ $context = new stdClass();
+ }
+ $definition = $xapidefinition->get_data();
+ $result = $xapiresult->get_data();
+
+ // Insert attempt_results record.
+ $record = new stdClass();
+ $record->attemptid = $this->record->id;
+ $record->subcontent = $subcontent;
+ $record->timecreated = time();
+ $record->interactiontype = $definition->interactionType ?? 'other';
+ $record->description = $this->get_description_from_definition($definition);
+ $record->correctpattern = $this->get_correctpattern_from_definition($definition);
+ $record->response = $result->response ?? '';
+ $record->additionals = $this->get_additionals($definition, $context);
+ $record->rawscore = 0;
+ $record->maxscore = 0;
+ if (isset($result->score)) {
+ $record->rawscore = $result->score->raw ?? 0;
+ $record->maxscore = $result->score->max ?? 0;
+ }
+ if (!$DB->insert_record('h5pactivity_attempts_results', $record)) {
+ return false;
+ }
+
+ // If no subcontent provided, results are propagated to the attempt itself.
+ if (empty($subcontent) && $record->rawscore) {
+ $this->record->rawscore = $record->rawscore;
+ $this->record->maxscore = $record->maxscore;
+ }
+ // Refresh current attempt.
+ return $this->save();
+ }
+
+ /**
+ * Update the current attempt record into DB.
+ *
+ * @return bool true if update is succesful
+ */
+ public function save(): bool {
+ global $DB;
+ $this->record->timemodified = time();
+ return $DB->update_record('h5pactivity_attempts', $this->record);
+ }
+
+ /**
+ * Delete the current attempt results from the DB.
+ */
+ public function delete_results(): void {
+ global $DB;
+ $conditions = ['attemptid' => $this->record->id];
+ $DB->delete_records('h5pactivity_attempts_results', $conditions);
+ }
+
+ /**
+ * Return de number of results stored in this attempt.
+ *
+ * @return int the number of results stored in this attempt.
+ */
+ public function count_results(): int {
+ global $DB;
+ $conditions = ['attemptid' => $this->record->id];
+ return $DB->count_records('h5pactivity_attempts_results', $conditions);
+ }
+
+ /**
+ * Get additional data for some interaction types.
+ *
+ * @param stdClass $definition the statement object definition data
+ * @param stdClass $context the statement optional context
+ * @return string JSON encoded additional information
+ */
+ private function get_additionals(stdClass $definition, stdClass $context): string {
+ $additionals = [];
+ $interactiontype = $definition->interactionType ?? 'other';
+ switch ($interactiontype) {
+ case 'choice':
+ case 'sequencing':
+ $additionals['choices'] = $definition->choices ?? [];
+ break;
+
+ case 'matching':
+ $additionals['source'] = $definition->source ?? [];
+ $additionals['target'] = $definition->target ?? [];
+ break;
+
+ case 'likert':
+ $additionals['scale'] = $definition->scale ?? [];
+ break;
+
+ case 'performance':
+ $additionals['steps'] = $definition->steps ?? [];
+ break;
+ }
+
+ $additionals['extensions'] = $definition->extensions ?? new stdClass();
+
+ // Add context extensions.
+ $additionals['contextExtensions'] = $context->extensions ?? new stdClass();
+
+ if (empty($additionals)) {
+ return '';
+ }
+ return json_encode($additionals);
+ }
+
+ /**
+ * Extract the result description from statement object definition.
+ *
+ * In principle, H5P package can send a multilang description but the reality
+ * is that most activities only send the "en_US" description if any and the
+ * activity does not have any control over it.
+ *
+ * @param stdClass $definition the statement object definition
+ * @return string The available description if any
+ */
+ private function get_description_from_definition(stdClass $definition): string {
+ if (!isset($definition->description)) {
+ return '';
+ }
+ $translations = (array) $definition->description;
+ if (empty($translations)) {
+ return '';
+ }
+ // By default, H5P packages only send "en-US" descriptions.
+ return $translations['en-US'] ?? array_shift($translations);
+ }
+
+ /**
+ * Extract the correct pattern from statement object definition.
+ *
+ * The correct pattern depends on the type of content and the plugin
+ * has no control over it so we just store it in case that the statement
+ * data have it.
+ *
+ * @param stdClass $definition the statement object definition
+ * @return string The correct pattern if any
+ */
+ private function get_correctpattern_from_definition(stdClass $definition): string {
+ if (!isset($definition->correctResponsesPattern)) {
+ return '';
+ }
+ // Only arrays are allowed.
+ if (is_array($definition->correctResponsesPattern)) {
+ return json_encode($definition->correctResponsesPattern);
+ }
+ return '';
+ }
+
+ /**
+ * Return the attempt number.
+ *
+ * @return int the attempt number
+ */
+ public function get_attempt(): int {
+ return $this->record->attempt;
+ }
+
+ /**
+ * Return the attempt ID.
+ *
+ * @return int the attempt id
+ */
+ public function get_id(): int {
+ return $this->record->id;
+ }
+
+ /**
+ * Return the attempt user ID.
+ *
+ * @return int the attempt userid
+ */
+ public function get_userid(): int {
+ return $this->record->userid;
+ }
+
+ /**
+ * Return the attempt H5P activity ID.
+ *
+ * @return int the attempt userid
+ */
+ public function get_h5pactivityid(): int {
+ return $this->record->h5pactivityid;
+ }
+
+ /**
+ * Return the attempt maxscore.
+ *
+ * @return int the maxscore value
+ */
+ public function get_maxscore(): int {
+ return $this->record->maxscore;
+ }
+
+ /**
+ * Return the attempt rawscore.
+ *
+ * @return int the rawscore value
+ */
+ public function get_rawscore(): int {
+ return $this->record->maxscore;
+ }
+}
diff --git a/mod/h5pactivity/classes/xapi/handler.php b/mod/h5pactivity/classes/xapi/handler.php
new file mode 100644
index 00000000000..69667de339a
--- /dev/null
+++ b/mod/h5pactivity/classes/xapi/handler.php
@@ -0,0 +1,137 @@
+.
+
+/**
+ * The xapi_handler for xAPI statements.
+ *
+ * @package mod_h5pactivity
+ * @since Moodle 3.9
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\xapi;
+
+use mod_h5pactivity\local\attempt;
+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;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class xapi_handler for H5P statements.
+ *
+ * @package mod_h5pactivity
+ * @since Moodle 3.9
+ * @copyright 2020 Ferran Recio
+ */
+class handler extends handler_base {
+
+ /**
+ * Convert a statement object into a Moodle xAPI Event.
+ *
+ * If a statement is accepted by the xAPI webservice the component must provide
+ * an event to handle that statement, otherwise the statement will be rejected.
+ *
+ * @param statement $statement
+ * @return core\event\base|null a Moodle event to trigger
+ */
+ public function statement_to_event(statement $statement): ?event_base {
+
+ // Only process statements with results.
+ $xapiresult = $statement->get_result();
+ if (empty($xapiresult)) {
+ return null;
+ }
+
+ // Statements can contain any verb, for security reasons each
+ // plugin needs to filter it's own specific verbs. For now the only verbs the H5P
+ // plugin keeps track on are "answered" and "completed" because they are realted to grading.
+ // In the future this list can be increased to track more user interactions.
+ $validvalues = [
+ 'http://adlnet.gov/expapi/verbs/answered',
+ 'http://adlnet.gov/expapi/verbs/completed',
+ ];
+ $xapiverbid = $statement->get_verb_id();
+ if (!in_array($xapiverbid, $validvalues)) {
+ return null;
+ }
+
+ // Validate object.
+ $xapiobject = $statement->get_activity_id();
+
+ // H5P add some extra params to ID to define subcontents.
+ $parts = explode('?', $xapiobject, 2);
+ $contextid = array_shift($parts);
+ $subcontent = str_replace('subContentId=', '', array_shift($parts));
+ if (empty($contextid) || !is_numeric($contextid)) {
+ return null;
+ }
+ $context = \context::instance_by_id($contextid);
+ if (!$context instanceof \context_module) {
+ return null;
+ }
+
+ // As the activity does not accept group statement, the code can assume that the
+ // statement user is valid (otherwise the xAPI library will reject the statement).
+ $user = $statement->get_user();
+ if (!has_capability('mod/h5pactivity:view', $context, $user)) {
+ return null;
+ }
+ if (!has_capability('mod/h5pactivity:submit', $context, $user, false)) {
+ return null;
+ }
+
+ $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid, 0, false);
+ if (!$cm) {
+ return null;
+ }
+
+ // For now, attempts are only processed on a single batch starting with the final "completed"
+ // and "answered" statements (this could change in the future). This initial statement have no
+ // subcontent defined as they are the main finishing statement. For this reason, this statement
+ // indicates a new attempt creation. This way, simpler H5P activies like multichoice can generate
+ // an attempt each time the user answers while complex like question-set could group all questions
+ // in a single attempt (using subcontents).
+ if (empty($subcontent)) {
+ $attempt = attempt::new_attempt($user, $cm);
+ } else {
+ $attempt = attempt::last_attempt($user, $cm);
+ }
+ if (!$attempt) {
+ return null;
+ }
+ $result = $attempt->save_statement($statement, $subcontent);
+ if (!$result) {
+ return null;
+ }
+
+ // TODO: update grading if necessary.
+
+ // Convert into a Moodle event.
+ $minstatement = $statement->minify();
+ $params = [
+ 'other' => $minstatement,
+ 'context' => $context,
+ 'objectid' => $cm->instance,
+ 'userid' => $user->id,
+ ];
+ return statement_received::create($params);
+ }
+}
diff --git a/mod/h5pactivity/db/access.php b/mod/h5pactivity/db/access.php
index 26b21b33059..cef7abaea51 100644
--- a/mod/h5pactivity/db/access.php
+++ b/mod/h5pactivity/db/access.php
@@ -48,4 +48,12 @@ $capabilities = [
],
'clonepermissionsfrom' => 'moodle/course:manageactivities',
],
+
+ 'mod/h5pactivity:submit' => [
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_MODULE,
+ 'archetypes' => [
+ 'student' => CAP_ALLOW
+ ],
+ ],
];
diff --git a/mod/h5pactivity/db/install.xml b/mod/h5pactivity/db/install.xml
index a2461ef18fc..ac1fbde0b7e 100644
--- a/mod/h5pactivity/db/install.xml
+++ b/mod/h5pactivity/db/install.xml
@@ -1,5 +1,5 @@
-
@@ -9,8 +9,8 @@
-
-
+
+
@@ -21,5 +21,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/mod/h5pactivity/db/upgrade.php b/mod/h5pactivity/db/upgrade.php
new file mode 100644
index 00000000000..198cf66e80b
--- /dev/null
+++ b/mod/h5pactivity/db/upgrade.php
@@ -0,0 +1,138 @@
+.
+
+/**
+ * This file keeps track of upgrades to the h5pactivity module
+ *
+ * Sometimes, changes between versions involve
+ * alterations to database structures and other
+ * major things that may break installations.
+ *
+ * The upgrade function in this file will attempt
+ * to perform all the necessary actions to upgrade
+ * your older installation to the current version.
+ *
+ * If there's something it cannot do itself, it
+ * will tell you what you need to do.
+ *
+ * The commands in here will all be database-neutral,
+ * using the methods of database_manager class
+ *
+ * Please do not forget to use upgrade_set_timeout()
+ * before any action that may take longer time to finish.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * H5P activity module upgrade.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2017 Stephen Bourget
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Function to upgrade mod_h5pactivity.
+ * @param int $oldversion the version we are upgrading from
+ * @return bool result
+ */
+function xmldb_h5pactivity_upgrade($oldversion) {
+ global $DB;
+
+ $dbman = $DB->get_manager(); // Loads ddl manager and xmldb classes.
+
+ if ($oldversion < 2020032300) {
+
+ // Changing the default of field timecreated on table h5pactivity to drop it.
+ $table = new xmldb_table('h5pactivity');
+ $field = new xmldb_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'name');
+
+ // Launch change of default for field timecreated.
+ $dbman->change_field_default($table, $field);
+
+ // Changing the default of field timemodified on table h5pactivity to drop it.
+ $field = new xmldb_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'timecreated');
+
+ // Launch change of default for field timemodified.
+ $dbman->change_field_default($table, $field);
+
+ // Define table h5pactivity_attempts to be created.
+ $table = new xmldb_table('h5pactivity_attempts');
+
+ // Adding fields to table h5pactivity_attempts.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('h5pactivityid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('userid', XMLDB_TYPE_INTEGER, '20', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('attempt', XMLDB_TYPE_INTEGER, '6', null, XMLDB_NOTNULL, null, '1');
+ $table->add_field('rawscore', XMLDB_TYPE_INTEGER, '10', null, null, null, '0');
+ $table->add_field('maxscore', XMLDB_TYPE_INTEGER, '10', null, null, null, '0');
+
+ // Adding keys to table h5pactivity_attempts.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+ $table->add_key('fk_h5pactivityid', XMLDB_KEY_FOREIGN, ['h5pactivityid'], 'h5pactivity', ['id']);
+ $table->add_key('uq_activityuserattempt', XMLDB_KEY_UNIQUE, ['h5pactivityid', 'userid', 'attempt']);
+
+ // Adding indexes to table h5pactivity_attempts.
+ $table->add_index('timecreated', XMLDB_INDEX_NOTUNIQUE, ['timecreated']);
+ $table->add_index('h5pactivityid-timecreated', XMLDB_INDEX_NOTUNIQUE, ['h5pactivityid', 'timecreated']);
+ $table->add_index('h5pactivityid-userid', XMLDB_INDEX_NOTUNIQUE, ['h5pactivityid', 'userid']);
+
+ // Conditionally launch create table for h5pactivity_attempts.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // Define table h5pactivity_attempts_results to be created.
+ $table = new xmldb_table('h5pactivity_attempts_results');
+
+ // Adding fields to table h5pactivity_attempts_results.
+ $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+ $table->add_field('attemptid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('subcontent', XMLDB_TYPE_CHAR, '128', null, null, null, null);
+ $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+ $table->add_field('interactiontype', XMLDB_TYPE_CHAR, '128', null, null, null, null);
+ $table->add_field('description', XMLDB_TYPE_TEXT, null, null, null, null, null);
+ $table->add_field('correctpattern', XMLDB_TYPE_TEXT, null, null, null, null, null);
+ $table->add_field('response', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+ $table->add_field('additionals', XMLDB_TYPE_TEXT, null, null, null, null, null);
+ $table->add_field('rawscore', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+ $table->add_field('maxscore', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+
+ // Adding keys to table h5pactivity_attempts_results.
+ $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+ $table->add_key('fk_attemptid', XMLDB_KEY_FOREIGN, ['attemptid'], 'h5pactivity_attempts', ['id']);
+
+ // Adding indexes to table h5pactivity_attempts_results.
+ $table->add_index('attemptid-timecreated', XMLDB_INDEX_NOTUNIQUE, ['attemptid', 'timecreated']);
+
+ // Conditionally launch create table for h5pactivity_attempts_results.
+ if (!$dbman->table_exists($table)) {
+ $dbman->create_table($table);
+ }
+
+ // H5pactivity savepoint reached.
+ upgrade_mod_savepoint(true, 2020032300, 'h5pactivity');
+ }
+
+ return true;
+}
diff --git a/mod/h5pactivity/lang/en/h5pactivity.php b/mod/h5pactivity/lang/en/h5pactivity.php
index 73e54893287..6c720808b51 100644
--- a/mod/h5pactivity/lang/en/h5pactivity.php
+++ b/mod/h5pactivity/lang/en/h5pactivity.php
@@ -26,10 +26,13 @@
defined('MOODLE_INTERNAL') || die();
$string['areapackage'] = 'Package file';
+$string['attempt'] = 'Attempt';
+$string['deleteallattempts'] = 'Delete all H5P attempts';
$string['displayexport'] = 'Allow download';
$string['displayembed'] = 'Embed button';
$string['displaycopyright'] = 'Copyright button';
$string['h5pactivity:addinstance'] = 'Add a new H5P';
+$string['h5pactivity:submit'] = 'Submit H5P attempts';
$string['h5pactivity:view'] = 'View H5P';
$string['h5pactivityfieldset'] = 'H5P Settings';
$string['h5pactivityname'] = 'H5P';
@@ -39,9 +42,13 @@ $string['modulename'] = 'H5P activity';
$string['modulename_help'] = 'Use this module to use a H5P compatible content as a course activity.';
$string['modulename_link'] = 'mod/h5pactivity/view';
$string['modulenameplural'] = 'H5P activities';
+$string['myattempts'] = 'My attempts';
$string['package'] = 'Package file';
$string['package_help'] = 'The package file is a h5pfile containing H5P dynamic content.';
$string['page-mod-h5pactivity-x'] = 'Any H5P module page';
$string['pluginadministration'] = 'H5P administration';
$string['pluginname'] = 'H5P activity';
+$string['previewmode'] = 'This content is displayed in preview mode. No attempt tracking will be stored.';
$string['privacy:metadata'] = 'The H5P activity plugin does not store any personal data.';
+$string['statement_received'] = 'xAPI statement received';
+$string['view'] = 'View';
diff --git a/mod/h5pactivity/lib.php b/mod/h5pactivity/lib.php
index 1ebf7ba57c9..914d25dd8cc 100644
--- a/mod/h5pactivity/lib.php
+++ b/mod/h5pactivity/lib.php
@@ -80,6 +80,7 @@ function h5pactivity_add_instance(stdClass $data, mod_h5pactivity_mod_form $mfor
global $DB;
$data->timecreated = time();
+ $data->timemodified = $data->timecreated;
$cmid = $data->coursemodule;
$data->id = $DB->insert_record('h5pactivity', $data);
@@ -229,18 +230,65 @@ function h5pactivity_update_grades(stdClass $moduleinstance, int $userid = 0): v
'h5pactivity', $moduleinstance->id, 0, $grades);
}
+/**
+ * Implementation of the function for printing the form elements that control
+ * whether the course reset functionality affects the H5P activity.
+ *
+ * @param object $mform form passed by reference
+ */
+function h5pactivity_reset_course_form_definition(&$mform): void {
+ $mform->addElement('header', 'h5pactivityheader', get_string('modulenameplural', 'mod_h5pactivity'));
+ $mform->addElement('advcheckbox', 'reset_h5pactivity', get_string('deleteallattempts', 'mod_h5pactivity'));
+}
+
+/**
+ * Course reset form defaults.
+ *
+ * @param stdClass $course the course object
+ * @return array
+ */
+function h5pactivity_reset_course_form_defaults(stdClass $course): array {
+ return ['reset_h5pactivity' => 1];
+}
+
+
/**
* This function is used by the reset_course_userdata function in moodlelib.
- * This function will remove all assignment submissions and feedbacks in the database
+ *
+ * This function will remove all H5P attempts in the database
* and clean up any related data.
*
* @param stdClass $data the data submitted from the reset course.
- * @return array
+ * @return array of reseting status
*/
-function h5pactivity_reset_userdata($data) {
+function h5pactivity_reset_userdata(stdClass $data): array {
global $CFG, $DB;
- // TODO: When attempts are created this function will remove them.
- return [];
+ $componentstr = get_string('modulenameplural', 'mod_h5pactivity');
+ $status = [];
+ if (!empty($data->reset_h5pactivity)) {
+ $params = ['courseid' => $data->courseid];
+ $sql = "SELECT a.id FROM {h5pactivity} a WHERE a.course=:courseid";
+ if ($activities = $DB->get_records_sql($sql, $params)) {
+ foreach ($activities as $activity) {
+ $cm = get_coursemodule_from_instance('h5pactivity',
+ $activity->id,
+ $data->courseid,
+ false,
+ MUST_EXIST);
+ mod_h5pactivity\local\attempt::delete_all_attempts ($cm);
+ }
+ }
+ // Remove all grades from gradebook.
+ if (empty($data->reset_gradebook_grades)) {
+ h5pactivity_reset_gradebook($data->courseid, 'reset');
+ }
+ $status[] = [
+ 'component' => $componentstr,
+ 'item' => get_string('deleteallattempts', 'mod_h5pactivity'),
+ 'error' => false,
+ ];
+ }
+ return $status;
}
/**
@@ -254,7 +302,7 @@ function h5pactivity_reset_gradebook(int $courseid, string $type=''): void {
$sql = "SELECT a.*, cm.idnumber as cmidnumber, a.course as courseid
FROM {h5pactivity} a, {course_modules} cm, {modules} m
- WHERE m.name='h5pactivity' AND m.id=cm.module AND cm.instance=s.id AND s.course=?";
+ WHERE m.name='h5pactivity' AND m.id=cm.module AND cm.instance=a.id AND a.course=?";
if ($activities = $DB->get_records_sql($sql, [$courseid])) {
foreach ($activities as $activity) {
diff --git a/mod/h5pactivity/tests/behat/add_h5pactivity.feature b/mod/h5pactivity/tests/behat/add_h5pactivity.feature
index b7d5c00a33a..ab92d1e3fa8 100644
--- a/mod/h5pactivity/tests/behat/add_h5pactivity.feature
+++ b/mod/h5pactivity/tests/behat/add_h5pactivity.feature
@@ -20,7 +20,6 @@ Feature: Add H5P activity
And I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
-
@javascript
Scenario: Add a h5pactivity activity to a course
When I add a "H5P activity" to section "1"
diff --git a/mod/h5pactivity/tests/behat/sending_attempt.feature b/mod/h5pactivity/tests/behat/sending_attempt.feature
new file mode 100644
index 00000000000..d6e35f12660
--- /dev/null
+++ b/mod/h5pactivity/tests/behat/sending_attempt.feature
@@ -0,0 +1,56 @@
+@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe
+Feature: Do a H5P attempt
+ In order to let students do a H5P attempt
+ As a teacher
+ I need to list students attempts on the log report
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ | student1 | Student | 1 | student1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ | student1 | C1 | student |
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | moodle/h5p:updatelibraries | Allow | editingteacher | System | |
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+ And I add a "H5P activity" to section "1"
+ And I set the following fields to these values:
+ | Name | Awesome H5P package |
+ | Description | Description |
+ And I upload "h5p/tests/fixtures/multiple-choice-2-6.h5p" file to "Package file" filemanager
+
+ @javascript
+ Scenario: View an H5P as a teacher
+ When I click on "Save and display" "button"
+ And I wait until the page is ready
+ Then I should see "This content is displayed in preview mode"
+
+ @javascript
+ Scenario: To an attempts and check on course log report
+ When I click on "Save and return to course" "button"
+ And I log out
+ And I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Awesome H5P package"
+ And I wait until the page is ready
+ And I should not see "This content is displayed in preview mode"
+ And I switch to "h5p-player" class iframe
+ And I switch to "h5p-iframe" class iframe
+ And I click on "Correct one" "text" in the ".h5p-question-content" "css_element"
+ And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+ And I switch to the main frame
+ And I log out
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I navigate to course participants
+ And I follow "Student 1"
+ Then I follow "Today's logs"
+ And I should see "xAPI statement received"
diff --git a/mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php b/mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php
new file mode 100644
index 00000000000..c381eea9507
--- /dev/null
+++ b/mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php
@@ -0,0 +1,73 @@
+.
+
+/**
+ * Events test.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+use advanced_testcase;
+use context_course;
+use context_module;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * H5P activity events test cases.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_module_instance_list_viewed_testcase extends advanced_testcase {
+
+ /**
+ * Test course_module_instance_list_viewed event.
+ */
+ public function test_course_module_instance_list_viewed() {
+ // There is no proper API to call to trigger this event, so what we are
+ // doing here is simply making sure that the events returns the right information.
+
+ $this->resetAfterTest();
+
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $params = [
+ 'context' => context_course::instance($course->id)
+ ];
+ $event = course_module_instance_list_viewed::create($params);
+
+ // Triggering and capturing the event.
+ $sink = $this->redirectEvents();
+ $event->trigger();
+ $events = $sink->get_events();
+ $this->assertCount(1, $events);
+ $event = reset($events);
+
+ // Checking that the event contains the expected values.
+ $this->assertInstanceOf('\mod_h5pactivity\event\course_module_instance_list_viewed', $event);
+ $this->assertEquals(context_course::instance($course->id), $event->get_context());
+ $expected = [$course->id, 'h5pactivity', 'view all', 'index.php?id='.$course->id, ''];
+ $this->assertEventLegacyLogData($expected, $event);
+ $this->assertEventContextNotUsed($event);
+ }
+}
diff --git a/mod/h5pactivity/tests/events_test.php b/mod/h5pactivity/tests/event/course_module_viewed_test.php
similarity index 61%
rename from mod/h5pactivity/tests/events_test.php
rename to mod/h5pactivity/tests/event/course_module_viewed_test.php
index 43ba10a8f59..57d2fe613d4 100644
--- a/mod/h5pactivity/tests/events_test.php
+++ b/mod/h5pactivity/tests/event/course_module_viewed_test.php
@@ -22,6 +22,12 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+namespace mod_h5pactivity\event;
+
+use advanced_testcase;
+use context_course;
+use context_module;
+
defined('MOODLE_INTERNAL') || die();
/**
@@ -31,45 +37,7 @@ defined('MOODLE_INTERNAL') || die();
* @copyright 2020 Ferran Recio
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class mod_h5pactivity_events_testcase extends advanced_testcase {
-
- /**
- * Setup is called before calling test case.
- */
- public function setUp() {
- // Must be a non-guest user to create h5pactivities.
- $this->setAdminUser();
- }
-
- /**
- * Test course_module_instance_list_viewed event.
- */
- public function test_course_module_instance_list_viewed() {
- // There is no proper API to call to trigger this event, so what we are
- // doing here is simply making sure that the events returns the right information.
-
- $this->resetAfterTest();
-
- $course = $this->getDataGenerator()->create_course();
- $params = [
- 'context' => context_course::instance($course->id)
- ];
- $event = \mod_h5pactivity\event\course_module_instance_list_viewed::create($params);
-
- // Triggering and capturing the event.
- $sink = $this->redirectEvents();
- $event->trigger();
- $events = $sink->get_events();
- $this->assertCount(1, $events);
- $event = reset($events);
-
- // Checking that the event contains the expected values.
- $this->assertInstanceOf('\mod_h5pactivity\event\course_module_instance_list_viewed', $event);
- $this->assertEquals(context_course::instance($course->id), $event->get_context());
- $expected = [$course->id, 'h5pactivity', 'view all', 'index.php?id='.$course->id, ''];
- $this->assertEventLegacyLogData($expected, $event);
- $this->assertEventContextNotUsed($event);
- }
+class course_module_viewed_testcase extends advanced_testcase {
/**
* Test course_module_viewed event.
@@ -80,6 +48,8 @@ class mod_h5pactivity_events_testcase extends advanced_testcase {
$this->resetAfterTest();
+ $this->setAdminUser();
+
$course = $this->getDataGenerator()->create_course();
$activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course->id]);
@@ -87,7 +57,7 @@ class mod_h5pactivity_events_testcase extends advanced_testcase {
'context' => context_module::instance($activity->cmid),
'objectid' => $activity->id
];
- $event = \mod_h5pactivity\event\course_module_viewed::create($params);
+ $event = course_module_viewed::create($params);
// Triggering and capturing the event.
$sink = $this->redirectEvents();
diff --git a/mod/h5pactivity/tests/event/statement_received_test.php b/mod/h5pactivity/tests/event/statement_received_test.php
new file mode 100644
index 00000000000..17694600131
--- /dev/null
+++ b/mod/h5pactivity/tests/event/statement_received_test.php
@@ -0,0 +1,83 @@
+.
+
+/**
+ * Events test.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+use advanced_testcase;
+use context_course;
+use context_module;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * H5P activity events test cases.
+ *
+ * @package mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class statement_received_testcase extends advanced_testcase {
+
+ /**
+ * Test course_module_viewed event.
+ */
+ public function test_statement_received() {
+ global $USER;
+
+ $this->resetAfterTest();
+
+ $this->setAdminUser();
+
+ // Must be a non-guest user to create h5pactivities.
+ $this->setAdminUser();
+
+ // There is no proper API to call to trigger this event, so what we are
+ // doing here is simply making sure that the events returns the right information.
+
+ $course = $this->getDataGenerator()->create_course();
+ $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course->id]);
+
+ $params = [
+ 'context' => context_module::instance($activity->cmid),
+ 'objectid' => $activity->id
+ ];
+ $event = statement_received::create($params);
+
+ // Triggering and capturing the event.
+ $sink = $this->redirectEvents();
+ $event->trigger();
+ $events = $sink->get_events();
+ $this->assertCount(1, $events);
+ $event = reset($events);
+
+ // Checking that the event contains the expected values.
+ $this->assertInstanceOf('\mod_h5pactivity\event\statement_received', $event);
+ $this->assertEquals(context_module::instance($activity->cmid), $event->get_context());
+ $this->assertEquals($activity->id, $event->objectid);
+ $expected = [$course->id, 'h5pactivity', 'statement received',
+ 'grade.php?user=' . $USER->id, 0, $activity->cmid];
+ $this->assertEventLegacyLogData($expected, $event);
+ $this->assertEventContextNotUsed($event);
+ }
+}
diff --git a/mod/h5pactivity/tests/generator/lib.php b/mod/h5pactivity/tests/generator/lib.php
index 0bf6f9246b3..cbcf84d2818 100644
--- a/mod/h5pactivity/tests/generator/lib.php
+++ b/mod/h5pactivity/tests/generator/lib.php
@@ -45,7 +45,7 @@ class mod_h5pactivity_generator extends testing_module_generator {
* @return stdClass record from module-defined table with additional field
* cmid (corresponding id in course_modules table)
*/
- public function create_instance($record = null, array $options = null) {
+ public function create_instance($record = null, array $options = null): stdClass {
global $CFG, $USER;
// Ensure the record can be modified without affecting calling code.
$record = (object)(array)$record;
@@ -67,7 +67,7 @@ class mod_h5pactivity_generator extends testing_module_generator {
// The 'packagefile' value corresponds to the draft file area ID. If not specified, create from packagefilepath.
if (empty($record->packagefile)) {
if (!isloggedin() || isguestuser()) {
- throw new coding_exception('Scorm generator requires a current user');
+ throw new coding_exception('H5P activity generator requires a current user');
}
if (!file_exists($record->packagefilepath)) {
throw new coding_exception("File {$record->packagefilepath} does not exist");
@@ -88,4 +88,76 @@ class mod_h5pactivity_generator extends testing_module_generator {
// Do work to actually add the instance.
return parent::create_instance($record, (array)$options);
}
+
+ /**
+ * Creata a fake attempt
+ * @param stdClass $instance object returned from create_instance() call
+ * @param stdClass|array $record
+ * @return stdClass generated object
+ * @throws coding_exception if function is not implemented by module
+ */
+ public function create_content($instance, $record = []) {
+ global $DB, $USER;
+
+ $currenttime = time();
+ $cmid = $record['cmid'];
+ $userid = $record['userid'] ?? $USER->id;
+ $conditions = ['h5pactivityid' => $instance->id, 'userid' => $userid];
+ $attemptnum = $DB->count_records('h5pactivity_attempts', $conditions) + 1;
+ $attempt = (object)[
+ 'h5pactivityid' => $instance->id,
+ 'userid' => $userid,
+ 'timecreated' => $currenttime,
+ 'timemodified' => $currenttime,
+ 'attempt' => $attemptnum,
+ 'rawscore' => 3,
+ 'maxscore' => 5,
+ ];
+ $attempt->id = $DB->insert_record('h5pactivity_attempts', $attempt);
+
+ // Create 3 diferent tracking results.
+ $result = (object)[
+ 'attemptid' => $attempt->id,
+ 'subcontent' => '',
+ 'timecreated' => $currenttime,
+ 'interactiontype' => 'compound',
+ 'description' => 'description for '.$userid,
+ 'correctpattern' => '',
+ 'response' => '',
+ 'additionals' => '{"extensions":{"http:\/\/h5p.org\/x-api\/h5p-local-content-id":'.
+ $cmid.'},"contextExtensions":{}}',
+ 'rawscore' => 3,
+ 'maxscore' => 5,
+ ];
+ $DB->insert_record('h5pactivity_attempts_results', $result);
+
+ $result->subcontent = 'bd03477a-90a1-486d-890b-0657d6e80ffd';
+ $result->interactiontype = 'compound';
+ $result->response = '0[,]5[,]2[,]3';
+ $result->additionals = '{"choices":[{"id":"0","description":{"en-US":"Blueberry\n"}},'.
+ '{"id":"1","description":{"en-US":"Raspberry\n"}},{"id":"5","description":'.
+ '{"en-US":"Strawberry\n"}},{"id":"2","description":{"en-US":"Cloudberry\n"}},'.
+ '{"id":"3","description":{"en-US":"Halle Berry\n"}},'.
+ '{"id":"4","description":{"en-US":"Cocktail cherry\n"}}],'.
+ '"extensions":{"http:\/\/h5p.org\/x-api\/h5p-local-content-id":'.$cmid.
+ ',"http:\/\/h5p.org\/x-api\/h5p-subContentId":"'.$result->interactiontype.
+ '"},"contextExtensions":{}}';
+ $result->rawscore = 1;
+ $DB->insert_record('h5pactivity_attempts_results', $result);
+
+ $result->subcontent = '14fcc986-728b-47f3-915b-'.$userid;
+ $result->interactiontype = 'matching';
+ $result->response = '1[.]0[,]0[.]1[,]2[.]2';
+ $result->additionals = '{"source":[{"id":"0","description":{"en-US":"A berry"}}'.
+ ',{"id":"1","description":{"en-US":"An orange berry"}},'.
+ '{"id":"2","description":{"en-US":"A red berry"}}],'.
+ '"target":[{"id":"0","description":{"en-US":"Cloudberry"}},'.
+ '{"id":"1","description":{"en-US":"Blueberry"}},'.
+ '{"id":"2","description":{"en-US":"Redcurrant\n"}}],'.
+ '"contextExtensions":{}}';
+ $result->rawscore = 2;
+ $DB->insert_record('h5pactivity_attempts_results', $result);
+
+ return $attempt;
+ }
}
diff --git a/mod/h5pactivity/tests/local/attempt_test.php b/mod/h5pactivity/tests/local/attempt_test.php
new file mode 100644
index 00000000000..b4148066afd
--- /dev/null
+++ b/mod/h5pactivity/tests/local/attempt_test.php
@@ -0,0 +1,346 @@
+.
+
+/**
+ * mod_h5pactivity generator tests
+ *
+ * @package mod_h5pactivity
+ * @category test
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+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 stdClass;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Attempt tests class for mod_h5pactivity.
+ *
+ * @package mod_h5pactivity
+ * @category test
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class attempt_testcase extends \advanced_testcase {
+
+ /**
+ * Generate a scenario to run all tests.
+ * @return array course_modules, user record, course record
+ */
+ private function generate_testing_scenario(): array {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+ $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+ $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+ return [$cm, $student, $course];
+ }
+
+ /**
+ * Test for create_attempt method.
+ */
+ public function test_create_attempt() {
+
+ list($cm, $student) = $this->generate_testing_scenario();
+
+ // 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());
+
+ // Create a second attempt.
+ $attempt = attempt::new_attempt($student, $cm);
+ $this->assertEquals($student->id, $attempt->get_userid());
+ $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+ $this->assertEquals(2, $attempt->get_attempt());
+ }
+
+ /**
+ * Test for last_attempt method
+ */
+ public function test_last_attempt() {
+
+ list($cm, $student) = $this->generate_testing_scenario();
+
+ // Create first attempt.
+ $attempt = attempt::last_attempt($student, $cm);
+ $this->assertEquals($student->id, $attempt->get_userid());
+ $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+ $this->assertEquals(1, $attempt->get_attempt());
+ $lastid = $attempt->get_id();
+
+ // Get last attempt.
+ $attempt = attempt::last_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($lastid, $attempt->get_id());
+
+ // Now force a new attempt.
+ $attempt = attempt::new_attempt($student, $cm);
+ $this->assertEquals($student->id, $attempt->get_userid());
+ $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+ $this->assertEquals(2, $attempt->get_attempt());
+ $lastid = $attempt->get_id();
+
+ // Get last attempt.
+ $attempt = attempt::last_attempt($student, $cm);
+ $this->assertEquals($student->id, $attempt->get_userid());
+ $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+ $this->assertEquals(2, $attempt->get_attempt());
+ $this->assertEquals($lastid, $attempt->get_id());
+ }
+
+ /**
+ * Test saving statements.
+ *
+ * @dataProvider save_statement_data
+ * @param string $subcontent subcontent identifier
+ * @param bool $hasdefinition generate definition
+ * @param bool $hasresult generate result
+ * @param array $results 0 => insert ok, 1 => maxscore, 2 => rawscore, 3 => count
+ */
+ public function test_save_statement(string $subcontent, bool $hasdefinition, bool $hasresult, array $results) {
+
+ list($cm, $student) = $this->generate_testing_scenario();
+
+ $attempt = attempt::new_attempt($student, $cm);
+ $this->assertEquals(0, $attempt->get_maxscore());
+ $this->assertEquals(0, $attempt->get_rawscore());
+ $this->assertEquals(0, $attempt->count_results());
+
+ $statement = $this->generate_statement($hasdefinition, $hasresult);
+ $result = $attempt->save_statement($statement, $subcontent);
+ $this->assertEquals($results[0], $result);
+ $this->assertEquals($results[1], $attempt->get_maxscore());
+ $this->assertEquals($results[2], $attempt->get_rawscore());
+ $this->assertEquals($results[3], $attempt->count_results());
+ }
+
+ /**
+ * Data provider for data request creation tests.
+ *
+ * @return array
+ */
+ public function save_statement_data(): array {
+ return [
+ 'Statement without definition and result' => [
+ '', false, false, [false, 0, 0, 0]
+ ],
+ 'Statement with definition but no result' => [
+ '', true, false, [false, 0, 0, 0]
+ ],
+ 'Statement with result but no definition' => [
+ '', true, false, [false, 0, 0, 0]
+ ],
+ 'Statement subcontent without definition and result' => [
+ '111-222-333', false, false, [false, 0, 0, 0]
+ ],
+ 'Statement subcontent with definition but no result' => [
+ '111-222-333', true, false, [false, 0, 0, 0]
+ ],
+ 'Statement subcontent with result but no definition' => [
+ '111-222-333', true, false, [false, 0, 0, 0]
+ ],
+ 'Statement with definition, result but no subcontent' => [
+ '', true, true, [true, 2, 2, 1]
+ ],
+ 'Statement with definition, result and subcontent' => [
+ '111-222-333', true, true, [true, 0, 0, 1]
+ ],
+ ];
+ }
+
+ /**
+ * Test delete results from attempt.
+ */
+ public function test_delete_results() {
+
+ list($cm, $student) = $this->generate_testing_scenario();
+
+ $attempt = $this->generate_full_attempt($student, $cm);
+ $attempt->delete_results();
+ $this->assertEquals(0, $attempt->count_results());
+ }
+
+ /**
+ * Test delete attempt.
+ */
+ public function test_delete_attempt() {
+ global $DB;
+
+ list($cm, $student) = $this->generate_testing_scenario();
+
+ // Check no previous attempts are created.
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(0, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(0, $count);
+
+ // Generate one attempt.
+ $attempt1 = $this->generate_full_attempt($student, $cm);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(1, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(2, $count);
+
+ // Generate a second attempt.
+ $attempt2 = $this->generate_full_attempt($student, $cm);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(2, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(4, $count);
+
+ // Delete the first attempt.
+ attempt::delete_attempt($attempt1);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(1, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(2, $count);
+ $this->assertEquals(2, $attempt2->count_results());
+ }
+
+ /**
+ * Test delete all attempts.
+ *
+ * @dataProvider delete_all_attempts_data
+ * @param bool $hasstudent if user is specificed
+ * @param int[] 0-3 => statements count results, 4-5 => totals
+ */
+ public function test_delete_all_attempts(bool $hasstudent, array $results) {
+ global $DB;
+
+ list($cm, $student, $course) = $this->generate_testing_scenario();
+
+ // For this test we need extra activity and student.
+ $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+ $cm2 = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+ $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+ // Check no previous attempts are created.
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(0, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(0, $count);
+
+ // Generate some attempts attempt on both activities and students.
+ $attempts = [];
+ $attempts[] = $this->generate_full_attempt($student, $cm);
+ $attempts[] = $this->generate_full_attempt($student2, $cm);
+ $attempts[] = $this->generate_full_attempt($student, $cm2);
+ $attempts[] = $this->generate_full_attempt($student2, $cm2);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(4, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(8, $count);
+
+ // Delete all specified attempts.
+ $user = ($hasstudent) ? $student : null;
+ attempt::delete_all_attempts($cm, $user);
+
+ // Check data.
+ for ($i = 0; $i < 4; $i++) {
+ $count = $attempts[$i]->count_results();
+ $this->assertEquals($results[$i], $count);
+ }
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals($results[4], $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals($results[5], $count);
+ }
+
+ /**
+ * Data provider for data request creation tests.
+ *
+ * @return array
+ */
+ public function delete_all_attempts_data(): array {
+ return [
+ 'Delete all attempts from activity' => [
+ false, [0, 0, 2, 2, 2, 4]
+ ],
+ 'Delete all attempts from user' => [
+ true, [0, 2, 2, 2, 3, 6]
+ ],
+ ];
+ }
+
+ /**
+ * Generate a fake attempt with two results.
+ *
+ * @param stdClass $student a user record
+ * @param stdClass $cm a course_module record
+ * @return attempt
+ */
+ private function generate_full_attempt($student, $cm): attempt {
+ $attempt = attempt::new_attempt($student, $cm);
+ $this->assertEquals(0, $attempt->get_maxscore());
+ $this->assertEquals(0, $attempt->get_rawscore());
+ $this->assertEquals(0, $attempt->count_results());
+
+ $statement = $this->generate_statement(true, true);
+ $saveok = $attempt->save_statement($statement, '');
+ $this->assertTrue($saveok);
+ $saveok = $attempt->save_statement($statement, '111-222-333');
+ $this->assertTrue($saveok);
+ $this->assertEquals(2, $attempt->count_results());
+
+ return $attempt;
+ }
+
+ /**
+ * Return a xAPI partial statement with object defined.
+ * @param bool $hasdefinition if has to include definition
+ * @param bool $hasresult if has to include results
+ * @return statement
+ */
+ private function generate_statement(bool $hasdefinition, bool $hasresult): statement {
+ global $USER;
+
+ $statement = new statement();
+ $statement->set_actor(item_agent::create_from_user($USER));
+ $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+ $definition = null;
+ if ($hasdefinition) {
+ $definition = item_definition::create_from_data((object)[
+ 'interactionType' => 'compound',
+ 'correctResponsesPattern' => '1',
+ ]);
+ }
+ $statement->set_object(item_activity::create_from_id('something', $definition));
+ if ($hasresult) {
+ $statement->set_result(item::create_from_data((object)[
+ 'completion' => true,
+ 'success' => true,
+ 'score' => (object) ['min' => 0, 'max' => 2, 'raw' => 2, 'scaled' => 1],
+ ]));
+ }
+ return $statement;
+ }
+}
diff --git a/mod/h5pactivity/tests/xapi/handler_test.php b/mod/h5pactivity/tests/xapi/handler_test.php
new file mode 100644
index 00000000000..c661e24a523
--- /dev/null
+++ b/mod/h5pactivity/tests/xapi/handler_test.php
@@ -0,0 +1,329 @@
+.
+
+/**
+ * mod_h5pactivity generator tests
+ *
+ * @package mod_h5pactivity
+ * @category test
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\xapi;
+
+use \core_xapi\local\statement;
+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 context_module;
+use stdClass;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Attempt tests class for mod_h5pactivity.
+ *
+ * @package mod_h5pactivity
+ * @category test
+ * @copyright 2020 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class handler_testcase extends \advanced_testcase {
+
+ /**
+ * Generate a valid scenario for each tests.
+ *
+ * @return stdClass an object with all scenario data in it
+ */
+ private function generate_testing_scenario(): stdClass {
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $data = new stdClass();
+
+ $data->course = $this->getDataGenerator()->create_course();
+
+ // Generate 2 users, one enroled into course and one not.
+ $data->student = $this->getDataGenerator()->create_and_enrol($data->course, 'student');
+ $data->otheruser = $this->getDataGenerator()->create_user();
+
+ // H5P activity.
+ $data->activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $data->course]);
+ $data->context = context_module::instance($data->activity->cmid);
+
+ $data->xapihandler = handler::create('mod_h5pactivity');
+ $this->assertNotEmpty($data->xapihandler);
+ $this->assertInstanceOf('\mod_h5pactivity\xapi\handler', $data->xapihandler);
+
+ $this->setUser($data->student);
+
+ return $data;
+ }
+
+ /**
+ * Test for xapi_handler with valid statements.
+ */
+ public function test_xapi_handler() {
+ global $DB;
+
+ $data = $this->generate_testing_scenario();
+ $xapihandler = $data->xapihandler;
+ $context = $data->context;
+ $student = $data->student;
+ $otheruser = $data->otheruser;
+
+ // Check we have 0 entries in the attempts tables.
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(0, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(0, $count);
+
+ $statements = $this->generate_statements($context, $student);
+
+ // Insert first statement.
+ $event = $xapihandler->statement_to_event($statements[0]);
+ $this->assertNotNull($event);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(1, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(1, $count);
+
+ // Insert second statement.
+ $event = $xapihandler->statement_to_event($statements[1]);
+ $this->assertNotNull($event);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(1, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(2, $count);
+
+ // Insert again first statement.
+ $event = $xapihandler->statement_to_event($statements[0]);
+ $this->assertNotNull($event);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(2, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(3, $count);
+
+ // Insert again second statement.
+ $event = $xapihandler->statement_to_event($statements[1]);
+ $this->assertNotNull($event);
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(2, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(4, $count);
+ }
+
+ /**
+ * Testing wrong statements scenarios.
+ *
+ * @dataProvider xapi_handler_errors_data
+ * @param bool $hasverb valid verb
+ * @param bool $hasdefinition generate definition
+ * @param bool $hasresult generate result
+ * @param bool $hascontext valid context
+ * @param bool $hasuser valid user
+ * @param bool $generateattempt if generates an empty attempt
+ */
+ public function test_xapi_handler_errors(bool $hasverb, bool $hasdefinition, bool $hasresult,
+ bool $hascontext, bool $hasuser, bool $generateattempt) {
+ global $DB, $CFG;
+
+ $data = $this->generate_testing_scenario();
+ $xapihandler = $data->xapihandler;
+ $context = $data->context;
+ $student = $data->student;
+ $otheruser = $data->otheruser;
+
+ // Check we have 0 entries in the attempts tables.
+ $count = $DB->count_records('h5pactivity_attempts');
+ $this->assertEquals(0, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(0, $count);
+
+ $statement = new statement();
+ if ($hasverb) {
+ $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+ } else {
+ $statement->set_verb(item_verb::create_from_id('cook'));
+ }
+ $definition = null;
+ if ($hasdefinition) {
+ $definition = item_definition::create_from_data((object)[
+ 'interactionType' => 'compound',
+ 'correctResponsesPattern' => '1',
+ ]);
+ }
+ if ($hascontext) {
+ $statement->set_object(item_activity::create_from_id($context->id, $definition));
+ } else {
+ $statement->set_object(item_activity::create_from_id('paella', $definition));
+ }
+ if ($hasresult) {
+ $statement->set_result(item::create_from_data((object)[
+ 'completion' => true,
+ 'success' => true,
+ 'score' => (object) ['min' => 0, 'max' => 2, 'raw' => 2, 'scaled' => 1],
+ ]));
+ }
+ if ($hasuser) {
+ $statement->set_actor(item_agent::create_from_user($student));
+ } else {
+ $statement->set_actor(item_agent::create_from_user($otheruser));
+ }
+
+ $event = $xapihandler->statement_to_event($statement);
+ $this->assertNull($event);
+ // No enties should be generated.
+ $count = $DB->count_records('h5pactivity_attempts');
+ $attempts = ($generateattempt) ? 1 : 0;
+ $this->assertEquals($attempts, $count);
+ $count = $DB->count_records('h5pactivity_attempts_results');
+ $this->assertEquals(0, $count);
+ }
+
+ /**
+ * Data provider for data request creation tests.
+ *
+ * @return array
+ */
+ public function xapi_handler_errors_data(): array {
+ return [
+ // Invalid Definitions and results possibilities.
+ 'Invalid definition and result' => [
+ true, false, false, true, true, false
+ ],
+ 'Invalid result' => [
+ true, true, false, true, true, false
+ ],
+ 'Invalid definition (generate empty attempt)' => [
+ true, false, true, true, true, true
+ ],
+ // Invalid verb possibilities.
+ 'Invalid verb, definition and result' => [
+ false, false, false, true, true, false
+ ],
+ 'Invalid verb and result' => [
+ false, true, false, true, true, false
+ ],
+ 'Invalid verb and result' => [
+ false, false, true, true, true, false
+ ],
+ // Invalid context possibilities.
+ 'Invalid definition, result and context' => [
+ true, false, false, false, true, false
+ ],
+ 'Invalid result' => [
+ true, true, false, false, true, false
+ ],
+ 'Invalid result and context' => [
+ true, false, true, false, true, false
+ ],
+ 'Invalid verb, definition result and context' => [
+ false, false, false, false, true, false
+ ],
+ 'Invalid verb, result and context' => [
+ false, true, false, false, true, false
+ ],
+ 'Invalid verb, result and context' => [
+ false, false, true, false, true, false
+ ],
+ // Invalid user possibilities.
+ 'Invalid definition, result and user' => [
+ true, false, false, true, false, false
+ ],
+ 'Invalid result and user' => [
+ true, true, false, true, false, false
+ ],
+ 'Invalid definition and user' => [
+ true, false, true, true, false, false
+ ],
+ 'Invalid verb, definition, result and user' => [
+ false, false, false, true, false, false
+ ],
+ 'Invalid verb, result and user' => [
+ false, true, false, true, false, false
+ ],
+ 'Invalid verb, result and user' => [
+ false, false, true, true, false, false
+ ],
+ 'Invalid definition, result, context and user' => [
+ true, false, false, false, false, false
+ ],
+ 'Invalid result, context and user' => [
+ true, true, false, false, false, false
+ ],
+ 'Invalid definition, context and user' => [
+ true, false, true, false, false, false
+ ],
+ 'Invalid verb, definition, result, context and user' => [
+ false, false, false, false, false, false
+ ],
+ 'Invalid verb, result, context and user' => [
+ false, true, false, false, false, false
+ ],
+ 'Invalid verb, result, context and user' => [
+ false, false, true, false, false, false
+ ],
+ ];
+ }
+
+ /**
+ * Returns a basic xAPI statements simulating a H5P content.
+ *
+ * @param context_module $context activity context
+ * @param stdClass $user user record
+ * @return statement[] array of xAPI statements
+ */
+ private function generate_statements(context_module $context, stdClass $user): array {
+ $statements = [];
+
+ $statement = new statement();
+ $statement->set_actor(item_agent::create_from_user($user));
+ $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+ $definition = item_definition::create_from_data((object)[
+ 'interactionType' => 'compound',
+ 'correctResponsesPattern' => '1',
+ ]);
+ $statement->set_object(item_activity::create_from_id($context->id, $definition));
+ $statement->set_result(item::create_from_data((object)[
+ 'completion' => true,
+ 'success' => true,
+ 'score' => (object) ['min' => 0, 'max' => 2, 'raw' => 2, 'scaled' => 1],
+ ]));
+ $statements[] = $statement;
+
+ $statement = new statement();
+ $statement->set_actor(item_agent::create_from_user($user));
+ $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+ $definition = item_definition::create_from_data((object)[
+ 'interactionType' => 'matching',
+ 'correctResponsesPattern' => '1',
+ ]);
+ $statement->set_object(item_activity::create_from_id($context->id.'?subContentId=111-222-333', $definition));
+ $statement->set_result(item::create_from_data((object)[
+ 'completion' => true,
+ 'success' => true,
+ 'score' => (object) ['min' => 0, 'max' => 1, 'raw' => 0, 'scaled' => 0],
+ ]));
+ $statements[] = $statement;
+
+ return $statements;
+ }
+}
diff --git a/mod/h5pactivity/version.php b/mod/h5pactivity/version.php
index 3e72cfba6d8..a23f286eccf 100644
--- a/mod/h5pactivity/version.php
+++ b/mod/h5pactivity/version.php
@@ -25,5 +25,5 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020022501;
+$plugin->version = 2020032300;
$plugin->requires = 2020013000;
diff --git a/mod/h5pactivity/view.php b/mod/h5pactivity/view.php
index 03365527e51..be2c11d7574 100644
--- a/mod/h5pactivity/view.php
+++ b/mod/h5pactivity/view.php
@@ -62,13 +62,25 @@ $fileurl = moodle_url::make_pluginfile_url($file->get_contextid(), $file->get_co
$file->get_filename(), false);
$PAGE->set_url('/mod/h5pactivity/view.php', ['id' => $cm->id]);
-$PAGE->set_title(format_string($moduleinstance->name));
+
+$shortname = format_string($course->shortname, true, ['context' => $context]);
+$pagetitle = strip_tags($shortname.': '.format_string($moduleinstance->name));
+$PAGE->set_title(format_string($pagetitle));
+
$PAGE->set_heading(format_string($course->fullname));
$PAGE->set_context($context);
echo $OUTPUT->header();
+echo $OUTPUT->heading(format_string($moduleinstance->name));
-// TODO: add component to enable xAPI traking.
-echo \core_h5p\player::display($fileurl, $config, true);
+if (has_capability('mod/h5pactivity:submit', $context, null, false)) {
+ $trackcomponent = 'mod_h5pactivity';
+} else {
+ $trackcomponent = '';
+ $message = get_string('previewmode', 'mod_h5pactivity');
+ echo $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING);
+}
+
+echo \core_h5p\player::display($fileurl, $config, true, $trackcomponent);
echo $OUTPUT->footer();