MDL-67788 mod_h5pactivity: add xAPI attempts track to activity
authorFerran Recio <ferran@moodle.com>
Fri, 27 Mar 2020 09:43:38 +0000 (10:43 +0100)
committerFerran Recio <ferran@moodle.com>
Tue, 14 Apr 2020 14:58:10 +0000 (16:58 +0200)
20 files changed:
mod/h5pactivity/classes/event/course_module_instance_list_viewed.php
mod/h5pactivity/classes/event/course_module_viewed.php
mod/h5pactivity/classes/event/statement_received.php [new file with mode: 0644]
mod/h5pactivity/classes/local/attempt.php [new file with mode: 0644]
mod/h5pactivity/classes/xapi/handler.php [new file with mode: 0644]
mod/h5pactivity/db/access.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php [new file with mode: 0644]
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/lib.php
mod/h5pactivity/tests/behat/add_h5pactivity.feature
mod/h5pactivity/tests/behat/sending_attempt.feature [new file with mode: 0644]
mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php [new file with mode: 0644]
mod/h5pactivity/tests/event/course_module_viewed_test.php [moved from mod/h5pactivity/tests/events_test.php with 61% similarity]
mod/h5pactivity/tests/event/statement_received_test.php [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/local/attempt_test.php [new file with mode: 0644]
mod/h5pactivity/tests/xapi/handler_test.php [new file with mode: 0644]
mod/h5pactivity/version.php
mod/h5pactivity/view.php

index f0a7204..8700085 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Plugin event classes are defined here.
+ * H5P Activity list viewed event.
  *
  * @package     mod_h5pactivity
  * @copyright   2020 Ferran Recio <ferran@moodle.com>
index 4dabab3..93604a6 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Plugin event classes are defined here.
+ * H5P activity viewed.
  *
  * @package     mod_h5pactivity
  * @copyright   2020 Ferran Recio <ferran@moodle.com>
diff --git a/mod/h5pactivity/classes/event/statement_received.php b/mod/h5pactivity/classes/event/statement_received.php
new file mode 100644 (file)
index 0000000..c6a3b64
--- /dev/null
@@ -0,0 +1,97 @@
+<?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/>.
+
+/**
+ * H5P activity send an xAPI tracking statement.
+ *
+ * @package     mod_h5pactivity
+ * @copyright   2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ * @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 (file)
index 0000000..8489003
--- /dev/null
@@ -0,0 +1,367 @@
+<?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/>.
+
+/**
+ * H5P activity attempt object
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ */
+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 (file)
index 0000000..69667de
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * The xapi_handler for xAPI statements.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ */
+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);
+    }
+}
index 26b21b3..cef7aba 100644 (file)
@@ -48,4 +48,12 @@ $capabilities = [
         ],
         'clonepermissionsfrom' => 'moodle/course:manageactivities',
     ],
+
+    'mod/h5pactivity:submit' => [
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => [
+            'student' => CAP_ALLOW
+        ],
+    ],
 ];
index a2461ef..ac1fbde 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/h5pactivity/db" VERSION="20200227" COMMENT="XMLDB file for Moodle mod_h5pactivity"
+<XMLDB PATH="mod/h5pactivity/db" VERSION="20200410" COMMENT="XMLDB file for Moodle mod_h5pactivity"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -9,8 +9,8 @@
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="course" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="ID of the course this activity is part of."/>
         <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" COMMENT="The name of the activity module instance"/>
-        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Timestamp of when the instance was added to the course."/>
-        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Timestamp of when the instance was last modified."/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when the instance was added to the course."/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when the instance was last modified."/>
         <FIELD NAME="intro" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Activity description."/>
         <FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The format of the intro field."/>
         <FIELD NAME="grade" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <KEY NAME="fk_course" TYPE="foreign" FIELDS="course" REFTABLE="course" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="h5pactivity_attempts" COMMENT="Users attempts inside H5P activities">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="h5pactivityid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="H5P activity ID"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="20" NOTNULL="true" SEQUENCE="false" COMMENT="Attempt user ID"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="attempt" TYPE="int" LENGTH="6" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Attempt number"/>
+        <FIELD NAME="rawscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="maxscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="fk_h5pactivityid" TYPE="foreign" FIELDS="h5pactivityid" REFTABLE="h5pactivity" REFFIELDS="id"/>
+        <KEY NAME="uq_activityuserattempt" TYPE="unique" FIELDS="h5pactivityid, userid, attempt" COMMENT="Ensure a user cannot repeat the same attempt on the same activity"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="timecreated" UNIQUE="false" FIELDS="timecreated"/>
+        <INDEX NAME="h5pactivityid-timecreated" UNIQUE="false" FIELDS="h5pactivityid, timecreated"/>
+        <INDEX NAME="h5pactivityid-userid" UNIQUE="false" FIELDS="h5pactivityid, userid"/>
+      </INDEXES>
+    </TABLE>
+    <TABLE NAME="h5pactivity_attempts_results" COMMENT="H5Pactivities_attempts tracking info">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="attemptid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="h5pactivity_attempts ID"/>
+        <FIELD NAME="subcontent" TYPE="char" LENGTH="128" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="interactiontype" TYPE="char" LENGTH="128" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="description" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="correctpattern" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Correct Pattern in xAPI format"/>
+        <FIELD NAME="response" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="User response data in xAPI format"/>
+        <FIELD NAME="additionals" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Extra subcontent information in JSON format"/>
+        <FIELD NAME="rawscore" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="maxscore" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="fk_attemptid" TYPE="foreign" FIELDS="attemptid" REFTABLE="h5pactivity_attempts" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="attemptid-timecreated" UNIQUE="false" FIELDS="attemptid, timecreated"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
-</XMLDB>
+</XMLDB>
\ No newline at end of file
diff --git a/mod/h5pactivity/db/upgrade.php b/mod/h5pactivity/db/upgrade.php
new file mode 100644 (file)
index 0000000..198cf66
--- /dev/null
@@ -0,0 +1,138 @@
+<?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/>.
+
+/**
+ * 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 <ferran@moodle.com>
+ * @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;
+}
index 73e5489..6c72080 100644 (file)
 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';
index 1ebf7ba..914d25d 100644 (file)
@@ -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) {
index b7d5c00..ab92d1e 100644 (file)
@@ -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 (file)
index 0000000..d6e35f1
--- /dev/null
@@ -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 (file)
index 0000000..c381eea
--- /dev/null
@@ -0,0 +1,73 @@
+<?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/>.
+
+/**
+ * Events test.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ * @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);
+    }
+}
  * @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 <ferran@moodle.com>
  * @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 (file)
index 0000000..1769460
--- /dev/null
@@ -0,0 +1,83 @@
+<?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/>.
+
+/**
+ * Events test.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ * @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);
+    }
+}
index 0bf6f92..cbcf84d 100644 (file)
@@ -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 (file)
index 0000000..b414806
--- /dev/null
@@ -0,0 +1,346 @@
+<?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/>.
+
+/**
+ * mod_h5pactivity generator tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ * @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 (file)
index 0000000..c661e24
--- /dev/null
@@ -0,0 +1,329 @@
+<?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/>.
+
+/**
+ * mod_h5pactivity generator tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @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 <ferran@moodle.com>
+ * @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;
+    }
+}
index 3e72cfb..a23f286 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020022501;
+$plugin->version = 2020032300;
 $plugin->requires = 2020013000;
index 0336552..be2c11d 100644 (file)
@@ -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));
+
+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);
+}
 
-// TODO: add component to enable xAPI traking.
-echo \core_h5p\player::display($fileurl, $config, true);
+echo \core_h5p\player::display($fileurl, $config, true, $trackcomponent);
 
 echo $OUTPUT->footer();