MDL-67788 mod_h5pactivity: add xAPI attempts track to activity

This commit is contained in:
Ferran Recio
2020-04-14 16:58:10 +02:00
parent ad9589f64c
commit 0aeec79a2a
20 changed files with 1845 additions and 59 deletions
@@ -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 <[email protected]>
@@ -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 <[email protected]>
@@ -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 <[email protected]>
* @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 <[email protected]>
* @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'];
}
}
+367
View File
@@ -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 <[email protected]>
* @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 <[email protected]>
*/
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;
}
}
+137
View File
@@ -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 <[email protected]>
* @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 <[email protected]>
*/
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);
}
}
+8
View File
@@ -48,4 +48,12 @@ $capabilities = [
],
'clonepermissionsfrom' => 'moodle/course:manageactivities',
],
'mod/h5pactivity:submit' => [
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
'archetypes' => [
'student' => CAP_ALLOW
],
],
];
+48 -4
View 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"/>
@@ -21,5 +21,49 @@
<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>
+138
View File
@@ -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 <[email protected]>
* @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;
}
+7
View File
@@ -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';
+54 -6
View 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) {
@@ -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"
@@ -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"
@@ -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 <[email protected]>
* @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 <[email protected]>
* @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);
}
}
@@ -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 <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();
@@ -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 <[email protected]>
* @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 <[email protected]>
* @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);
}
}
+74 -2
View 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;
}
}
@@ -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 <[email protected]>
* @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 <[email protected]>
* @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;
}
}
+329
View File
@@ -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 <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_h5pactivity\xapi;
use \core_xapi\local\statement;
use \core_xapi\local\statement\item;
use \core_xapi\local\statement\item_agent;
use \core_xapi\local\statement\item_activity;
use \core_xapi\local\statement\item_definition;
use \core_xapi\local\statement\item_verb;
use context_module;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Attempt tests class for mod_h5pactivity.
*
* @package mod_h5pactivity
* @category test
* @copyright 2020 Ferran Recio <[email protected]>
* @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;
}
}
+1 -1
View File
@@ -25,5 +25,5 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'mod_h5pactivity';
$plugin->version = 2020022501;
$plugin->version = 2020032300;
$plugin->requires = 2020013000;
+15 -3
View 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));
// 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();