diff --git a/admin/settings/courses.php b/admin/settings/courses.php index f98fc636723e1..c77469f4b504f 100644 --- a/admin/settings/courses.php +++ b/admin/settings/courses.php @@ -357,6 +357,12 @@ new lang_string('configgeneralcontentbankcontent', 'backup'), ['value' => 1, 'locked' => 0]) ); + $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_xapistate', + new lang_string('generalxapistate', 'backup'), + new lang_string('configgeneralxapistate', 'backup'), + ['value' => 1, 'locked' => 0]) + ); + $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_legacyfiles', new lang_string('generallegacyfiles', 'backup'), @@ -521,6 +527,12 @@ new lang_string('configgeneralcontentbankcontent', 'backup'), 1) ); + $temp->add(new admin_setting_configcheckbox( + 'backup/backup_auto_xapistate', + new lang_string('generalxapistate', 'backup'), + new lang_string('configgeneralxapistate', 'backup'), + 1) + ); $temp->add(new admin_setting_configcheckbox('backup/backup_auto_legacyfiles', new lang_string('generallegacyfiles', 'backup'), @@ -591,6 +603,9 @@ $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_contentbankcontent', new lang_string('generalcontentbankcontent', 'backup'), new lang_string('configrestorecontentbankcontent', 'backup'), array('value' => 1, 'locked' => 0))); + $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_xapistate', + new lang_string('generalxapistate', 'backup'), + new lang_string('configrestorexapistate', 'backup'), array('value' => 1, 'locked' => 0))); $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_legacyfiles', new lang_string('generallegacyfiles', 'backup'), new lang_string('configlegacyfiles', 'backup'), array('value' => 1, 'locked' => 0))); diff --git a/backup/moodle2/backup_activity_task.class.php b/backup/moodle2/backup_activity_task.class.php index f5a0348da00eb..b010069055206 100644 --- a/backup/moodle2/backup_activity_task.class.php +++ b/backup/moodle2/backup_activity_task.class.php @@ -204,6 +204,11 @@ public function build() { // Migrate the already exported inforef entries to final ones $this->add_step(new move_inforef_annotations_to_final('migrate_inforef')); + // Generate the xAPI state file (conditionally). + if ($this->get_setting_value('xapistate')) { + $this->add_step(new backup_xapistate_structure_step('activity_xapistate', 'xapistate.xml')); + } + // At the end, mark it as built $this->built = true; } diff --git a/backup/moodle2/backup_root_task.class.php b/backup/moodle2/backup_root_task.class.php index 673b58b9d4ead..79524fe60fab8 100644 --- a/backup/moodle2/backup_root_task.class.php +++ b/backup/moodle2/backup_root_task.class.php @@ -192,6 +192,12 @@ protected function define_settings() { $contentbank->set_ui(new backup_setting_ui_checkbox($contentbank, get_string('rootsettingcontentbankcontent', 'backup'))); $this->add_setting($contentbank); + // Define xAPI state inclusion setting. + $xapistate = new backup_xapistate_setting('xapistate', base_setting::IS_BOOLEAN, true); + $xapistate->set_ui(new backup_setting_ui_checkbox($xapistate, get_string('rootsettingxapistate', 'backup'))); + $this->add_setting($xapistate); + $users->add_dependency($xapistate); + // Define legacy file inclusion setting. $legacyfiles = new backup_generic_setting('legacyfiles', base_setting::IS_BOOLEAN, true); $legacyfiles->set_ui(new backup_setting_ui_checkbox($legacyfiles, get_string('rootsettinglegacyfiles', 'backup'))); diff --git a/backup/moodle2/backup_settingslib.php b/backup/moodle2/backup_settingslib.php index ca9224f17071d..6767d01f05712 100644 --- a/backup/moodle2/backup_settingslib.php +++ b/backup/moodle2/backup_settingslib.php @@ -211,3 +211,9 @@ class backup_activity_userinfo_setting extends activity_backup_setting {} */ class backup_contentbankcontent_setting extends backup_generic_setting { } + +/** + * Root setting to control if backup will include xAPI state or not. + */ +class backup_xapistate_setting extends backup_generic_setting { +} diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index a09c1dc0741f1..ccdd7b58bb081 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -2987,3 +2987,35 @@ protected function define_structure() { return $contents; } } + +/** + * Structure step in charge of constructing the xapistate.xml file for all the xAPI states found in a given context. + */ +class backup_xapistate_structure_step extends backup_structure_step { + + /** + * Define structure for content bank step + */ + protected function define_structure() { + + // Define each element separated. + $states = new backup_nested_element('states'); + $state = new backup_nested_element( + 'state', + ['id'], + ['component', 'userid', 'itemid', 'stateid', 'statedata', 'registration', 'timecreated', 'timemodified'] + ); + + // Build the tree. + $states->add_child($state); + + // Define sources. + $state->set_source_table('xapi_states', ['itemid' => backup::VAR_CONTEXTID]); + + // Define annotations. + $state->annotate_ids('user', 'userid'); + + // Return the root element (contents). + return $states; + } +} diff --git a/backup/moodle2/restore_activity_task.class.php b/backup/moodle2/restore_activity_task.class.php index 687a54508e9a6..90bcef913d7c3 100644 --- a/backup/moodle2/restore_activity_task.class.php +++ b/backup/moodle2/restore_activity_task.class.php @@ -200,6 +200,11 @@ public function build() { } } + // The xAPI state (conditionally). + if ($this->get_setting_value('xapistate')) { + $this->add_step(new restore_xapistate_structure_step('activity_xapistate', 'xapistate.xml')); + } + // At the end, mark it as built $this->built = true; } diff --git a/backup/moodle2/restore_root_task.class.php b/backup/moodle2/restore_root_task.class.php index 40345c12aadb6..107463f1c4fea 100644 --- a/backup/moodle2/restore_root_task.class.php +++ b/backup/moodle2/restore_root_task.class.php @@ -316,6 +316,18 @@ protected function define_settings() { $contents->get_ui()->set_changeable($changeable); $this->add_setting($contents); + // Define xAPI states. + $defaultvalue = false; + $changeable = false; + if (isset($rootsettings['xapistate']) && $rootsettings['xapistate']) { // Only enabled when available. + $defaultvalue = true; + $changeable = true; + } + $xapistate = new restore_xapistate_setting('xapistate', base_setting::IS_BOOLEAN, $defaultvalue); + $xapistate->set_ui(new backup_setting_ui_checkbox($xapistate, get_string('rootsettingxapistate', 'backup'))); + $xapistate->get_ui()->set_changeable($changeable); + $this->add_setting($xapistate); + // Include legacy files. $defaultvalue = true; $changeable = true; diff --git a/backup/moodle2/restore_settingslib.php b/backup/moodle2/restore_settingslib.php index fb9e064fcd288..cce77a98dece4 100644 --- a/backup/moodle2/restore_settingslib.php +++ b/backup/moodle2/restore_settingslib.php @@ -248,3 +248,9 @@ class restore_activity_userinfo_setting extends restore_activity_generic_setting */ class restore_contentbankcontent_setting extends restore_generic_setting { } + +/** + * Root setting to control if restore will create xAPI states or not. + */ +class restore_xapistate_setting extends restore_generic_setting { +} diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index ad500d034abca..f9ec9adccd086 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -4226,6 +4226,61 @@ protected function after_execute() { } } +/** + * This structure steps restores the xAPI states. + */ +class restore_xapistate_structure_step extends restore_structure_step { + + /** + * Define structure for xAPI state step + */ + protected function define_structure() { + return [new restore_path_element('xapistate', '/states/state')]; + } + + /** + * Define data processed for xAPI state. + * + * @param array|stdClass $data + */ + public function process_xapistate($data) { + global $DB; + + $data = (object)$data; + $oldid = $data->id; + $exists = false; + + $params = [ + 'component' => $data->component, + 'itemid' => $this->task->get_contextid(), + // Set stateid to 'restored', to let plugins identify the origin of this state is a backup. + 'stateid' => 'restored', + 'statedata' => $data->statedata, + 'registration' => $data->registration, + 'timecreated' => $data->timecreated, + 'timemodified' => time(), + ]; + + // Trying to map users. Users cannot always be mapped, for instance, when copying. + $params['userid'] = $this->get_mappingid('user', $data->userid); + if (!$params['userid']) { + // Leave the userid unchanged when we are restoring the same site. + if ($this->task->is_samesite()) { + $params['userid'] = $data->userid; + } + $filter = $params; + unset($filter['statedata']); + $exists = $DB->record_exists('xapi_states', $filter); + } + + if (!$exists && $params['userid']) { + // Only insert the record if the user exists or can be mapped. + $newitemid = $DB->insert_record('xapi_states', $params); + $this->set_mapping('xapi_states', $oldid, $newitemid, true); + } + } +} + /** * This structure steps restores one instance + positions of one block * Note: Positions corresponding to one existing context are restored diff --git a/backup/moodle2/tests/moodle2_test.php b/backup/moodle2/tests/moodle2_test.php index 778ef6156a749..4e438edfee7b2 100644 --- a/backup/moodle2/tests/moodle2_test.php +++ b/backup/moodle2/tests/moodle2_test.php @@ -18,6 +18,7 @@ use backup; use backup_controller; +use backup_setting; use restore_controller; use restore_dbops; @@ -463,9 +464,10 @@ public function test_restore_frontpage() { * @param \stdClass $course Course object to backup * @param int $newdate If non-zero, specifies custom date for new course * @param callable|null $inbetween If specified, function that is called before restore + * @param bool $userdata Whether the backup/restory must be with user data or not. * @return int ID of newly restored course */ - protected function backup_and_restore($course, $newdate = 0, $inbetween = null) { + protected function backup_and_restore($course, $newdate = 0, $inbetween = null, bool $userdata = false) { global $USER, $CFG; // Turn off file logging, otherwise it can't delete the file (Windows). @@ -476,6 +478,9 @@ protected function backup_and_restore($course, $newdate = 0, $inbetween = null) $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id); + $bc->get_plan()->get_setting('users')->set_status(backup_setting::NOT_LOCKED); + $bc->get_plan()->get_setting('users')->set_value($userdata); + $backupid = $bc->get_backupid(); $bc->execute_plan(); $bc->destroy(); @@ -493,6 +498,13 @@ protected function backup_and_restore($course, $newdate = 0, $inbetween = null) if ($newdate) { $rc->get_plan()->get_setting('course_startdate')->set_value($newdate); } + + $rc->get_plan()->get_setting('users')->set_status(backup_setting::NOT_LOCKED); + $rc->get_plan()->get_setting('users')->set_value($userdata); + if ($userdata) { + $rc->get_plan()->get_setting('xapistate')->set_value(true); + } + $this->assertTrue($rc->execute_precheck()); $rc->execute_plan(); $rc->destroy(); @@ -1090,4 +1102,75 @@ public function test_contentbank_content_backup() { $this->assertEquals(4, $DB->count_records('contentbank_content')); $this->assertEquals(2, $DB->count_records('contentbank_content', ['contextid' => $newcontext->id])); } + + /** + * Test the xAPI state through a backup and restore. + * + * @covers \backup_xapistate_structure_step + * @covers \restore_xapistate_structure_step + */ + public function test_xapistate_backup() { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]); + $this->setUser($user); + + /** @var \mod_h5pactivity_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity'); + + /** @var \core_h5p_generator $h5pgenerator */ + $h5pgenerator = $this->getDataGenerator()->get_plugin_generator('core_h5p'); + + // Add an attempt to the H5P activity. + $attemptinfo = [ + 'userid' => $user->id, + 'h5pactivityid' => $activity->id, + 'attempt' => 1, + 'interactiontype' => 'compound', + 'rawscore' => 2, + 'maxscore' => 2, + 'duration' => 1, + 'completion' => 1, + 'success' => 0, + ]; + $generator->create_attempt($attemptinfo); + + // Add also a xAPI state to the H5P activity. + $filerecord = [ + 'contextid' => \context_module::instance($activity->cmid)->id, + 'component' => 'mod_h5pactivity', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filepath' => '/', + 'filename' => 'dummy.h5p', + 'addxapistate' => true, + ]; + $h5pgenerator->generate_h5p_data(false, $filerecord); + + // Check the H5P activity exists and the attempt has been created. + $this->assertEquals(1, $DB->count_records('h5pactivity')); + $this->assertEquals(2, $DB->count_records('grade_items')); + $this->assertEquals(2, $DB->count_records('grade_grades')); + $this->assertEquals(1, $DB->count_records('xapi_states')); + + // Do backup and restore. + $this->setAdminUser(); + $newcourseid = $this->backup_and_restore($course, 0, null, true); + + // Confirm that values were transferred correctly into H5P activity on new course. + $this->assertEquals(2, $DB->count_records('h5pactivity')); + $this->assertEquals(4, $DB->count_records('grade_items')); + $this->assertEquals(4, $DB->count_records('grade_grades')); + $this->assertEquals(2, $DB->count_records('xapi_states')); + + $newactivity = $DB->get_record('h5pactivity', ['course' => $newcourseid]); + $cm = get_coursemodule_from_instance('h5pactivity', $newactivity->id); + $context = \context_module::instance($cm->id); + $this->assertEquals(1, $DB->count_records('xapi_states', ['itemid' => $context->id])); + } } diff --git a/backup/util/dbops/backup_controller_dbops.class.php b/backup/util/dbops/backup_controller_dbops.class.php index d6df561a00355..2bc367fd47614 100644 --- a/backup/util/dbops/backup_controller_dbops.class.php +++ b/backup/util/dbops/backup_controller_dbops.class.php @@ -567,6 +567,7 @@ public static function apply_config_defaults(backup_controller $controller) { 'backup_general_groups' => 'groups', 'backup_general_competencies' => 'competencies', 'backup_general_contentbankcontent' => 'contentbankcontent', + 'backup_general_xapistate' => 'xapistate', 'backup_general_legacyfiles' => 'legacyfiles' ); self::apply_admin_config_defaults($controller, $settings, true); @@ -616,6 +617,7 @@ public static function apply_config_defaults(backup_controller $controller) { 'backup_auto_groups' => 'groups', 'backup_auto_competencies' => 'competencies', 'backup_auto_contentbankcontent' => 'contentbankcontent', + 'backup_auto_xapistate' => 'xapistate', 'backup_auto_legacyfiles' => 'legacyfiles' ); self::apply_admin_config_defaults($controller, $settings, false); diff --git a/backup/util/dbops/restore_controller_dbops.class.php b/backup/util/dbops/restore_controller_dbops.class.php index 4cc7ee21851fe..9184943448555 100644 --- a/backup/util/dbops/restore_controller_dbops.class.php +++ b/backup/util/dbops/restore_controller_dbops.class.php @@ -160,6 +160,7 @@ public static function apply_config_defaults(restore_controller $controller) { 'restore_general_groups' => 'groups', 'restore_general_competencies' => 'competencies', 'restore_general_contentbankcontent' => 'contentbankcontent', + 'restore_general_xapistate' => 'xapistate', 'restore_general_legacyfiles' => 'legacyfiles' ); self::apply_admin_config_defaults($controller, $settings, true); diff --git a/backup/util/ui/tests/behat/backup_xapistate.feature b/backup/util/ui/tests/behat/backup_xapistate.feature new file mode 100644 index 0000000000000..e0c14348f426d --- /dev/null +++ b/backup/util/ui/tests/behat/backup_xapistate.feature @@ -0,0 +1,103 @@ +@core @core_backup @core_h5p @mod_h5pactivity @_switch_iframe @javascript +Feature: Backup xAPI states + In order to save and restore xAPI states + As an admin + I need to create backups with xAPI states and restore them + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + And the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + And the following "activity" exists: + | activity | h5pactivity | + | course | C1 | + | name | Awesome H5P package | + | packagefilepath | h5p/tests/fixtures/filltheblanks.h5p | + # Save state for the student user. + And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" + And I switch to the main frame + And I am on the "Course 1" course page + And I am on the "Awesome H5P package" "h5pactivity activity" page + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia" + And I log out + + Scenario: Content state is backup/restored when user data is included + # Backup and restore the course. + Given I log in as "admin" + And I backup "Course 1" course using this options: + | Confirmation | Filename | test_backup.mbz | + And I restore "test_backup.mbz" backup into a new course using this options: + | Settings | Include enrolled users | 1 | + | Schema | User data | 1 | + | Schema | Course name | Course 2 | + | Schema | Course short name | C2 | + # Login as student and confirm xAPI state has been restored. + When I am on the "Course 2" course page logged in as student1 + And I click on "Awesome H5P package" "link" in the "region-main" "region" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia" + + Scenario: Content state is not restored when user data is not included in the backup + # Backup course without user data and then restore it. + When I log in as "admin" + And I backup "Course 1" course using this options: + | Initial | Include enrolled users | 0 | + | Confirmation | Filename | test_backup.mbz | + And I restore "test_backup.mbz" backup into a new course using this options: + | Schema | Course name | Course 2 | + | Schema | Course short name | C2 | + # Enrol student to the new course. + And the following "course enrolments" exist: + | user | course | role | + | student1 | C2 | student | + # Login as student and confirm xAPI state hasn't been restored. + And I am on the "Course 2" course page logged in as student1 + And I click on "Awesome H5P package" "link" in the "region-main" "region" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" + + Scenario: Content state is not restored when user data is included in the backup but xAPI state is not restored + # Backup with user data and restore it without user data the course. + Given I log in as "admin" + And I backup "Course 1" course using this options: + | Confirmation | Filename | test_backup.mbz | + And I restore "test_backup.mbz" backup into a new course using this options: + | Settings | Include user's state in content such as H5P activities | 0 | + | Schema | Course name | Course 2 | + | Schema | Course short name | C2 | + # Login as student and confirm xAPI state hasn't been restored. + When I am on the "Course 2" course page logged in as student1 + And I click on "Awesome H5P package" "link" in the "region-main" "region" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" + + Scenario: Content state is not restored when it is not included explicitly in the backup + # Backup course with user data but without xAPI state and then restore it. + When I log in as "admin" + And I backup "Course 1" course using this options: + | Initial | Include user's state in content such as H5P activities | 0 | + | Confirmation | Filename | test_backup.mbz | + And I restore "test_backup.mbz" backup into a new course using this options: + | Schema | Course name | Course 2 | + | Schema | Course short name | C2 | + And I should see "Awesome H5P package" + # Login as student and confirm xAPI state hasn't been restored. + And I am on the "Course 2" course page logged in as student1 + And I click on "Awesome H5P package" "link" in the "region-main" "region" + And I switch to "h5p-player" class iframe + And I switch to "h5p-iframe" class iframe + Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" diff --git a/lang/en/backup.php b/lang/en/backup.php index 6e6b5c3dea4a7..1a243388658ca 100644 --- a/lang/en/backup.php +++ b/lang/en/backup.php @@ -139,6 +139,7 @@ $string['configgeneralpermissions'] = 'If enabled the role permissions will be imported. This may override existing permissions for enrolled users.'; $string['configgeneraluserscompletion'] = 'If enabled user completion information will be included in backups by default.'; $string['configgeneralusers'] = 'Sets the default for whether to include users in backups.'; +$string['configgeneralxapistate'] = 'Sets the default for including the user\'s state in content such as H5P activities in a backup.'; $string['configlegacyfiles'] = 'Sets the default for including legacy course files in a backup. Legacy course files are from versions of Moodle prior to 2.0.'; $string['configloglifetime'] = 'This specifies the length of time you want to keep backup logs information. Logs that are older than this age are automatically deleted. It is recommended to keep this value small, because backup logged information can be huge.'; $string['configrestoreactivities'] = 'Sets the default for restoring activities.'; @@ -157,6 +158,7 @@ $string['configrestorepermissions'] = 'If enabled the role permissions will be restored. This may override existing permissions for enrolled users.'; $string['configrestoreuserscompletion'] = 'If enabled user completion information will be restored by default if it was included in the backup.'; $string['configrestoreusers'] = 'Sets the default for whether to restore users if they were included in the backup.'; +$string['configrestorexapistate'] = 'Sets the default for restoring the user\'s state in content such as H5P activities.'; $string['confirmcancel'] = 'Cancel backup'; $string['confirmcancelrestore'] = 'Cancel restore'; $string['confirmcancelimport'] = 'Cancel import'; @@ -244,6 +246,7 @@ $string['generalsettings'] = 'General backup settings'; $string['generaluserscompletion'] = 'Include user completion information'; $string['generalusers'] = 'Include users'; +$string['generalxapistate'] = 'Include user\'s state in content such as H5P activities'; $string['hidetypes'] = 'Hide type options'; $string['importgeneralsettings'] = 'General import defaults'; $string['importgeneralmaxresults'] = 'Maximum number of courses listed for import'; @@ -372,6 +375,7 @@ $string['rootsettinggroups'] = 'Include groups and groupings'; $string['rootsettingimscc1'] = 'Convert to IMS Common Cartridge 1.0'; $string['rootsettingimscc11'] = 'Convert to IMS Common Cartridge 1.1'; +$string['rootsettingxapistate'] = 'Include user\'s state in content such as H5P activities'; $string['samesitenotification'] = 'This backup was created with only references to files, not the files themselves. Restoring will only work on this site.'; $string['sitecourseformatwarning'] = 'This is a site home backup. It can only be restored on the site home.'; $string['storagecourseonly'] = 'Course backup filearea';