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