Merge branch 'MDL-67786-master' of git://github.com/aanabit/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 15 Apr 2020 21:45:48 +0000 (23:45 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 15 Apr 2020 21:45:48 +0000 (23:45 +0200)
95 files changed:
Gruntfile.js
admin/tool/dataprivacy/amd/build/myrequestactions.min.js
admin/tool/dataprivacy/amd/build/myrequestactions.min.js.map
admin/tool/dataprivacy/amd/src/myrequestactions.js
admin/tool/task/tests/behat/clear_fail_delay.feature
backup/util/dbops/restore_dbops.class.php
backup/util/dbops/tests/restore_dbops_test.php
blocks/activity_modules/block_activity_modules.php
calendar/amd/build/crud.min.js
calendar/amd/build/crud.min.js.map
calendar/amd/src/crud.js
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/src/local/activitychooser/selectors.js
course/classes/local/service/content_item_service.php
course/classes/output/recommendations/activity_list.php
course/recommendations.php
course/resources.php
course/templates/activity_list.mustache
course/templates/local/activitychooser/search.mustache
course/tests/behat/search_recommended_activities.feature [new file with mode: 0644]
course/tests/services_content_item_service_test.php
grade/import/lib.php
h5p/classes/player.php
h5p/embed.php
h5p/js/embed.js
h5p/js/h5p_overrides.js
h5p/tests/fixtures/multiple-choice-2-6.h5p [new file with mode: 0644]
lang/en/course.php
lib/amd/build/drawer.min.js
lib/amd/build/drawer.min.js.map
lib/amd/src/drawer.js
lib/moodlelib.php
lib/outputcomponents.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/repository.min.js
lib/table/amd/build/local/dynamic/repository.min.js.map
lib/table/amd/build/local/dynamic/selectors.min.js
lib/table/amd/build/local/dynamic/selectors.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/repository.js
lib/table/amd/src/local/dynamic/selectors.js
lib/table/classes/external/dynamic/fetch.php
lib/tablelib.php
lib/templates/drawer.mustache
lib/templates/paging_bar.mustache
lib/tests/moodlelib_test.php
mod/forum/amd/build/local/grades/grader.min.js
mod/forum/amd/build/local/grades/grader.min.js.map
mod/forum/amd/build/local/grades/local/grader/selectors.min.js
mod/forum/amd/build/local/grades/local/grader/selectors.min.js.map
mod/forum/amd/build/local/grades/local/grader/user_picker.min.js
mod/forum/amd/build/local/grades/local/grader/user_picker.min.js.map
mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js
mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js.map
mod/forum/amd/src/local/grades/grader.js
mod/forum/amd/src/local/grades/local/grader/selectors.js
mod/forum/amd/src/local/grades/local/grader/user_picker.js
mod/forum/amd/src/local/grades/local/grader/user_picker/selectors.js
mod/forum/lang/en/forum.php
mod/forum/templates/local/grades/grader.mustache
mod/forum/templates/local/grades/local/grader/content.mustache
mod/forum/templates/local/grades/local/grader/grading.mustache
mod/forum/templates/local/grades/local/grader/navigation.mustache
mod/forum/templates/local/grades/local/grader/user_picker.mustache
mod/forum/templates/local/grades/local/grader/user_picker/user.mustache
mod/h5pactivity/classes/event/course_module_instance_list_viewed.php
mod/h5pactivity/classes/event/course_module_viewed.php
mod/h5pactivity/classes/event/statement_received.php [new file with mode: 0644]
mod/h5pactivity/classes/local/attempt.php [new file with mode: 0644]
mod/h5pactivity/classes/privacy/provider.php
mod/h5pactivity/classes/xapi/handler.php [new file with mode: 0644]
mod/h5pactivity/db/access.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php [new file with mode: 0644]
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/lib.php
mod/h5pactivity/tests/behat/add_h5pactivity.feature
mod/h5pactivity/tests/behat/sending_attempt.feature [new file with mode: 0644]
mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php [new file with mode: 0644]
mod/h5pactivity/tests/event/course_module_viewed_test.php [moved from mod/h5pactivity/tests/events_test.php with 61% similarity]
mod/h5pactivity/tests/event/statement_received_test.php [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/local/attempt_test.php [new file with mode: 0644]
mod/h5pactivity/tests/privacy_test.php [new file with mode: 0644]
mod/h5pactivity/tests/xapi/handler_test.php [new file with mode: 0644]
mod/h5pactivity/version.php
mod/h5pactivity/view.php
report/log/classes/renderable.php
report/log/locallib.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css

index bd39185..59ebfbb 100644 (file)
@@ -536,12 +536,8 @@ module.exports = function(grunt) {
         formatter.printResults(results);
 
         // Report on the results.
-        // We exit 1 if there is at least one error, otherwise we exit cleanly.
-        if (results.some(result => result.errors.length > 0)) {
-            done(1);
-        } else {
-            done(0);
-        }
+        // The done function takes a bool whereby a falsey statement causes the task to fail.
+        done(results.every(result => result.errors.length === 0));
     };
 
     tasks.startup = function() {
index f510cd2..a6ba759 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/myrequestactions.min.js and b/admin/tool/dataprivacy/amd/build/myrequestactions.min.js differ
index 6d17dee..c9f78ed 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/myrequestactions.min.js.map and b/admin/tool/dataprivacy/amd/build/myrequestactions.min.js.map differ
index 5ec631e..54f94fb 100644 (file)
@@ -28,8 +28,9 @@ define([
     'core/str',
     'core/modal_factory',
     'core/modal_events',
-    'core/templates'],
-function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates) {
+    'core/templates',
+    'core/pending'],
+function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Pending) {
 
     /**
      * List of action selectors.
@@ -118,6 +119,7 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates) {
         });
 
         $(ACTIONS.CONTACT_DPO).click(function(e) {
+            var pendingPromise = new Pending('dataprivacy/crud:initModal:contactdpo');
             e.preventDefault();
 
             var replyToEmail = $(this).data('replytoemail');
@@ -146,9 +148,12 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates) {
                     type: ModalFactory.types.SAVE_CANCEL,
                     large: true
                 });
-            }).done(function(modal) {
+            }).then(function(modal) {
                 modal.setSaveButtonText(sendButtonText);
 
+                // Show the modal!
+                modal.show();
+
                 // Handle send event.
                 modal.getRoot().on(ModalEvents.save, function(e) {
                     var message = $('#message').val().trim();
@@ -169,9 +174,9 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates) {
                     modal.destroy();
                 });
 
-                // Show the modal!
-                modal.show();
-            }).fail(Notification.exception);
+                return;
+            }).then(pendingPromise.resolve)
+            .catch(Notification.exception);
         });
     };
 
index 643750b..ceee867 100644 (file)
@@ -23,7 +23,6 @@ Feature: Clear scheduled task fail delay
     And I should not see "Clear" in the "Send new user passwords" "table_row"
     And I should see "Send new user passwords" in the "tr.table-primary" "css_element"
 
-
   Scenario: Cancel clearing the fail delay
     When I click on "Clear" "text" in the "Send new user passwords" "table_row"
     And I press "Cancel"
index 0911c68..eef73fc 100644 (file)
@@ -1394,7 +1394,9 @@ abstract class restore_dbops {
             // Note: for DB deleted users md5(username) is stored *sometimes* in the email field,
             //       hence we are looking there for usernames if not empty. See delete_user()
             // If match by id and mnethost and user is deleted in DB and
-            // match by username LIKE 'backup_email.%' or by non empty email = md5(username) => ok, return target user
+            // match by username LIKE 'substring(backup_email).%' where the substr length matches the retained data in the
+            // username field (100 - (timestamp + 1) characters), or by non empty email = md5(username) => ok, return target user.
+            $usernamelookup = core_text::substr($user->email, 0, 89) . '.%';
             if ($rec = $DB->get_record_sql("SELECT *
                                               FROM {user} u
                                              WHERE id = ?
@@ -1407,13 +1409,14 @@ abstract class restore_dbops {
                                                        AND email = ?
                                                        )
                                                    )",
-                                           array($user->id, $user->mnethostid, $user->email.'.%', md5($user->username)))) {
+                                           array($user->id, $user->mnethostid, $usernamelookup, md5($user->username)))) {
                 return $rec; // Matching user, deleted in DB found, return it
             }
 
             // 1D - Handle users deleted in backup file and "alive" in DB
             // If match by id and mnethost and user is deleted in backup file
-            // and match by email = email_without_time(backup_email) => ok, return target user
+            // and match by substring(email) = email_without_time(backup_email) where the substr length matches the retained data
+            // in the username field (100 - (timestamp + 1) characters) => ok, return target user.
             if ($user->deleted) {
                 // Note: for DB deleted users email is stored in username field, hence we
                 //       are looking there for emails. See delete_user()
@@ -1423,7 +1426,7 @@ abstract class restore_dbops {
                                                   FROM {user} u
                                                  WHERE id = ?
                                                    AND mnethostid = ?
-                                                   AND UPPER(email) = UPPER(?)",
+                                                   AND " . $DB->sql_substr('UPPER(email)', 1, 89) . " = UPPER(?)",
                                                array($user->id, $user->mnethostid, $trimemail))) {
                     return $rec; // Matching user, deleted in backup file found, return it
                 }
@@ -1470,7 +1473,8 @@ abstract class restore_dbops {
             // Note: for DB deleted users md5(username) is stored *sometimes* in the email field,
             //       hence we are looking there for usernames if not empty. See delete_user()
             // 2B1 - If match by mnethost and user is deleted in DB and not empty email = md5(username) and
-            //       (by username LIKE 'backup_email.%' or non-zero firstaccess) => ok, return target user
+            //       (by username LIKE 'substring(backup_email).%' or non-zero firstaccess) => ok, return target user.
+            $usernamelookup = core_text::substr($user->email, 0, 89) . '.%';
             if ($rec = $DB->get_record_sql("SELECT *
                                               FROM {user} u
                                              WHERE mnethostid = ?
@@ -1484,14 +1488,15 @@ abstract class restore_dbops {
                                                        AND firstaccess = ?
                                                        )
                                                    )",
-                                           array($user->mnethostid, md5($user->username), $user->email.'.%', $user->firstaccess))) {
+                                           array($user->mnethostid, md5($user->username), $usernamelookup, $user->firstaccess))) {
                 return $rec; // Matching user found, return it
             }
 
             // 2B2 - If match by mnethost and user is deleted in DB and
-            //       username LIKE 'backup_email.%' and non-zero firstaccess) => ok, return target user
+            //       username LIKE 'substring(backup_email).%' and non-zero firstaccess) => ok, return target user
             //       (this covers situations where md5(username) wasn't being stored so we require both
             //        the email & non-zero firstaccess to match)
+            $usernamelookup = core_text::substr($user->email, 0, 89) . '.%';
             if ($rec = $DB->get_record_sql("SELECT *
                                               FROM {user} u
                                              WHERE mnethostid = ?
@@ -1499,13 +1504,13 @@ abstract class restore_dbops {
                                                AND UPPER(username) LIKE UPPER(?)
                                                AND firstaccess != 0
                                                AND firstaccess = ?",
-                                           array($user->mnethostid, $user->email.'.%', $user->firstaccess))) {
+                                           array($user->mnethostid, $usernamelookup, $user->firstaccess))) {
                 return $rec; // Matching user found, return it
             }
 
             // 2C - Handle users deleted in backup file and "alive" in DB
             // If match mnethost and user is deleted in backup file
-            // and match by email = email_without_time(backup_email) and non-zero firstaccess=> ok, return target user
+            // and match by substring(email) = email_without_time(backup_email) and non-zero firstaccess=> ok, return target user.
             if ($user->deleted) {
                 // Note: for DB deleted users email is stored in username field, hence we
                 //       are looking there for emails. See delete_user()
@@ -1514,7 +1519,7 @@ abstract class restore_dbops {
                 if ($rec = $DB->get_record_sql("SELECT *
                                                   FROM {user} u
                                                  WHERE mnethostid = ?
-                                                   AND UPPER(email) = UPPER(?)
+                                                   AND " . $DB->sql_substr('UPPER(email)', 1, 89) . " = UPPER(?)
                                                    AND firstaccess != 0
                                                    AND firstaccess = ?",
                                                array($user->mnethostid, $trimemail, $user->firstaccess))) {
index d0fd2cc..59c127b 100644 (file)
@@ -119,4 +119,246 @@ class restore_dbops_testcase extends advanced_testcase {
             $this->assertSame('Table "backup_ids_temp" does not exist', $e->getMessage());
         }
     }
+
+    /**
+     * Data provider for {@link test_precheck_user()}
+     */
+    public function precheck_user_provider() {
+
+        $emailmultiplier = [
+            'shortmail' => 'normalusername@example.com',
+            'longmail' => str_repeat('a', 100)  // It's not validated, hence any string is ok.
+        ];
+
+        $providercases = [];
+
+        foreach ($emailmultiplier as $emailk => $email) {
+            // Get the related cases.
+            $cases = $this->precheck_user_cases($email);
+            // Rename them (keys).
+            foreach ($cases as $key => $case) {
+                $providercases[$key . ' - ' . $emailk] = $case;
+            }
+        }
+
+        return $providercases;
+    }
+
+    /**
+     * Get all the cases implemented in {@link restore_dbops::precheck_users()}
+     *
+     * @param string $email
+     */
+    private function precheck_user_cases($email) {
+        global $CFG;
+
+        $baseuserarr = [
+            'username' => 'normalusername',
+            'email'    => $email,
+            'mnethostid' => $CFG->mnet_localhost_id,
+            'firstaccess' => 123456789,
+            'deleted'    => 0,
+            'forceemailcleanup' => false, // Hack to force the DB record to have empty mail.
+            'forceduplicateadminallowed' => false]; // Hack to enable import_general_duplicate_admin_allowed.
+
+        return [
+            // Cases with samesite = true.
+            'samesite match existing (1A)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => $baseuserarr,
+                'samesite' => true,
+                'outcome' => 'match'
+            ],
+            'samesite match existing anon (1B)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'username' => 'anon01']),
+                'backupuser' => array_merge($baseuserarr, [
+                    'id' => -1, 'username' => 'anon01', 'firstname' => 'anonfirstname01',
+                    'lastname' => 'anonlastname01', 'email' => 'anon01@doesntexist.invalid']),
+                'samesite' => true,
+                'outcome' => 'match'
+            ],
+            'samesite match existing deleted in db, alive in backup, by db username (1C)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'deleted' => 1]),
+                'backupuser' => array_merge($baseuserarr, [
+                    'username' => 'this_wont_match']),
+                'samesite' => true,
+                'outcome' => 'match'
+            ],
+            'samesite match existing deleted in db, alive in backup, by db email (1C)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'deleted' => 1]),
+                'backupuser' => array_merge($baseuserarr, [
+                    'email' => 'this_wont_match']),
+                'samesite' => true,
+                'outcome' => 'match'
+            ],
+            'samesite match existing alive in db, deleted in backup (1D)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'deleted' => 1]),
+                'samesite' => true,
+                'outcome' => 'match'
+            ],
+            'samesite conflict (1E)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, ['id' => -1]),
+                'samesite' => true,
+                'outcome' => false
+            ],
+            'samesite create user (1F)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'username' => 'newusername']),
+                'samesite' => false,
+                'outcome' => true
+            ],
+
+            // Cases with samesite = false.
+            'no samesite match existing, by db email (2A1)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'firstaccess' => 0]),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match existing, by db firstaccess (2A1)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'email' => 'this_wont_match@example.con']),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match existing anon (2A1 too)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'username' => 'anon01']),
+                'backupuser' => array_merge($baseuserarr, [
+                    'id' => -1, 'username' => 'anon01', 'firstname' => 'anonfirstname01',
+                    'lastname' => 'anonlastname01', 'email' => 'anon01@doesntexist.invalid']),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match dupe admin (2A2)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'username' => 'admin_old_site_id',
+                    'forceduplicateadminallowed' => true]),
+                'backupuser' => array_merge($baseuserarr, [
+                    'username' => 'admin']),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match existing deleted in db, alive in backup, by db username (2B1)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'deleted' => 1]),
+                'backupuser' => array_merge($baseuserarr, [
+                    'firstaccess' => 0]),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match existing deleted in db, alive in backup, by db firstaccess (2B1)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'deleted' => 1]),
+                'backupuser' => array_merge($baseuserarr, [
+                    'mail' => 'this_wont_match']),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match existing deleted in db, alive in backup (2B2)' => [
+                'dbuser' => array_merge($baseuserarr, [
+                    'deleted' => 1,
+                    'forceemailcleanup' => true]),
+                'backupuser' => $baseuserarr,
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite match existing alive in db, deleted in backup (2C)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'deleted' => 1]),
+                'samesite' => false,
+                'outcome' => 'match'
+            ],
+            'no samesite conflict (2D)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'email' => 'anotheruser@example.com', 'firstaccess' => 0]),
+                'samesite' => false,
+                'outcome' => false
+            ],
+            'no samesite create user (2E)' => [
+                'dbuser' => $baseuserarr,
+                'backupuser' => array_merge($baseuserarr, [
+                    'username' => 'newusername']),
+                'samesite' => false,
+                'outcome' => true
+            ],
+
+        ];
+    }
+
+    /**
+     * Test restore precheck_user method
+     *
+     * @dataProvider precheck_user_provider
+     * @covers restore_dbops::precheck_user()
+     *
+     * @param array $dbuser
+     * @param array $backupuser
+     * @param bool $samesite
+     * @param mixed $outcome
+     **/
+    public function test_precheck_user($dbuser, $backupuser, $samesite, $outcome) {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $dbuser = (object)$dbuser;
+        $backupuser = (object)$backupuser;
+
+        $siteid = null;
+
+        // If the backup user must be deleted, simulate it (by temp inserting to DB, deleting and fetching it back).
+        if ($backupuser->deleted) {
+            $backupuser->id = $DB->insert_record('user', array_merge((array)$backupuser, ['deleted' => 0]));
+            delete_user($backupuser);
+            $backupuser = $DB->get_record('user', ['id' => $backupuser->id]);
+            $DB->delete_records('user', ['id' => $backupuser->id]);
+            unset($backupuser->id);
+        }
+
+        // Create the db user, normally.
+        $dbuser->id = $DB->insert_record('user', array_merge((array)$dbuser, ['deleted' => 0]));
+        $backupuser->id = $backupuser->id ?? $dbuser->id;
+
+        // We may want to enable the import_general_duplicate_admin_allowed setting and look for old admin records.
+        if ($dbuser->forceduplicateadminallowed) {
+            set_config('import_general_duplicate_admin_allowed', true, 'backup');
+            $siteid = 'old_site_id';
+        }
+
+        // If the DB user must be deleted, do it and fetch it back.
+        if ($dbuser->deleted) {
+            delete_user($dbuser);
+            // We may want to clean the mail field (old behavior, not containing the current md5(username).
+            if ($dbuser->forceemailcleanup) {
+                $DB->set_field('user', 'email', '', ['id' => $dbuser->id]);
+            }
+        }
+
+        // Get the dbuser  record, because we may have changed it above.
+        $dbuser = $DB->get_record('user', ['id' => $dbuser->id]);
+
+        $method = (new ReflectionClass('restore_dbops'))->getMethod('precheck_user');
+        $method->setAccessible(true);
+        $result = $method->invoke(null, $backupuser, $samesite, $siteid);
+
+        if (is_bool($result)) {
+            $this->assertSame($outcome, $result);
+        } else {
+            $outcome = $dbuser; // Outcome is not bool, matching found, so it must be the dbuser,
+            // Just check ids, it means the expected match has been found in database.
+            $this->assertSame($outcome->id, $result->id);
+        }
+    }
 }
index af67de2..6d3a60c 100644 (file)
@@ -52,8 +52,8 @@ class block_activity_modules extends block_list {
         $archetypes = array();
 
         foreach($modinfo->cms as $cm) {
-            // Exclude activities which are not visible or have no link (=label)
-            if (!$cm->uservisible or !$cm->has_view()) {
+            // Exclude activities that aren't visible or have no view link (e.g. label). Account for folder being displayed inline.
+            if (!$cm->uservisible || (!$cm->has_view() && strcmp($cm->modname, 'folder') !== 0)) {
                 continue;
             }
             if (array_key_exists($cm->modname, $modfullnames)) {
index 54e4ac1..d44cd94 100644 (file)
Binary files a/calendar/amd/build/crud.min.js and b/calendar/amd/build/crud.min.js differ
index 283f9da..c6e0e09 100644 (file)
Binary files a/calendar/amd/build/crud.min.js.map and b/calendar/amd/build/crud.min.js.map differ
index b757df8..7853a67 100644 (file)
@@ -35,6 +35,7 @@ define([
     'core_calendar/events',
     'core_calendar/modal_delete',
     'core_calendar/selectors',
+    'core/pending',
 ],
 function(
     $,
@@ -49,7 +50,8 @@ function(
     CalendarRepository,
     CalendarEvents,
     ModalDelete,
-    CalendarSelectors
+    CalendarSelectors,
+    Pending
 ) {
 
     /**
@@ -101,13 +103,6 @@ function(
             );
         }
 
-        deletePromise.then(function(deleteModal) {
-            deleteModal.show();
-
-            return;
-        })
-        .fail(Notification.exception);
-
         var stringsPromise = Str.get_strings(deleteStrings);
 
         var finalPromise = $.when(stringsPromise, deletePromise)
@@ -118,27 +113,33 @@ function(
                 deleteModal.setSaveButtonText(strings[0]);
             }
 
+            deleteModal.show();
+
             deleteModal.getRoot().on(ModalEvents.save, function() {
+                var pendingPromise = new Pending('calendar/crud:initModal:deletedevent');
                 CalendarRepository.deleteEvent(eventId, false)
                     .then(function() {
                         $('body').trigger(CalendarEvents.deleted, [eventId, false]);
                         return;
                     })
+                    .then(pendingPromise.resolve)
                     .catch(Notification.exception);
             });
 
             deleteModal.getRoot().on(CalendarEvents.deleteAll, function() {
+                var pendingPromise = new Pending('calendar/crud:initModal:deletedallevent');
                 CalendarRepository.deleteEvent(eventId, true)
                     .then(function() {
                         $('body').trigger(CalendarEvents.deleted, [eventId, true]);
                         return;
                     })
+                    .then(pendingPromise.resolve)
                     .catch(Notification.exception);
             });
 
             return deleteModal;
         })
-        .fail(Notification.exception);
+        .catch(Notification.exception);
 
         return finalPromise;
     }
index bc9cee0..1fddca8 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js and b/course/amd/build/local/activitychooser/selectors.min.js differ
index f6b3784..bfa0617 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js.map and b/course/amd/build/local/activitychooser/selectors.min.js.map differ
index b8361e6..3a15f75 100644 (file)
@@ -83,7 +83,7 @@ export default {
         tab: 'a[data-toggle="tab"]',
         activetab: 'a[data-toggle="tab"][aria-selected="true"]',
         visibletabs: 'a[data-toggle="tab"]:not(.d-none)',
-        searchicon: '.searchbar-append .search-icon',
-        clearsearch: '.searchbar-append .clear'
+        searchicon: '.input-group-append .search-icon',
+        clearsearch: '.input-group-append .clear'
     },
 };
index 76aadb9..b1dfddd 100644 (file)
@@ -163,14 +163,44 @@ class content_item_service {
      * @return array the array of exported content items.
      */
     public function get_all_content_items(\stdClass $user): array {
-        global $PAGE;
         $allcontentitems = $this->repository->find_all();
 
+        return $this->export_content_items($user, $allcontentitems);
+    }
+
+    /**
+     * Get content items which name matches a certain pattern and may be added to courses,
+     * irrespective of course caps, for site admin views, etc.
+     *
+     * @param \stdClass $user The user object.
+     * @param string $pattern The search pattern.
+     * @return array The array of exported content items.
+     */
+    public function get_content_items_by_name_pattern(\stdClass $user, string $pattern): array {
+        $allcontentitems = $this->repository->find_all();
+
+        $filteredcontentitems = array_filter($allcontentitems, function($contentitem) use ($pattern) {
+            return preg_match("/$pattern/i", $contentitem->get_title()->get_value());
+        });
+
+        return $this->export_content_items($user, $filteredcontentitems);
+    }
+
+    /**
+     * Export content items.
+     *
+     * @param \stdClass $user The user object.
+     * @param array $contentitems The content items array.
+     * @return array The array of exported content items.
+     */
+    private function export_content_items(\stdClass $user, $contentitems) {
+        global $PAGE;
+
         // Export the objects to get the formatted objects for transfer/display.
         $favourites = $this->get_favourite_content_items_for_user($user);
         $recommendations = $this->get_recommendations();
         $ciexporter = new course_content_items_exporter(
-            $allcontentitems,
+            $contentitems,
             [
                 'context' => \context_system::instance(),
                 'favouriteitems' => $favourites,
index 0352734..13b2e74 100644 (file)
@@ -36,13 +36,18 @@ class activity_list implements \renderable, \templatable {
     /** @var array $modules activities to display in the recommendations page. */
     protected $modules;
 
+    /** @var string $searchquery The search query. */
+    protected $searchquery;
+
     /**
      * Constructor method.
      *
      * @param array $modules Activities to display
+     * @param string $searchquery The search query if present
      */
-    public function __construct(array $modules) {
+    public function __construct(array $modules, string $searchquery) {
         $this->modules = $modules;
+        $this->searchquery = $searchquery;
     }
 
     /**
@@ -63,6 +68,18 @@ class activity_list implements \renderable, \templatable {
             ];
         }, $this->modules);
 
-        return ['categories' => ['categoryname' => get_string('activities'), 'categorydata' => $info]];
+        return [
+            'categories' => [
+                [
+                    'categoryname' => get_string('activities'),
+                    'hascategorydata' => !empty($info),
+                    'categorydata' => $info
+                ]
+            ],
+            'search' => [
+                'query' => $this->searchquery,
+                'searchresultsnumber' => count($this->modules)
+            ]
+        ];
     }
 }
index f7c2d1d..56b30c3 100644 (file)
@@ -24,6 +24,8 @@
 
 require_once("../config.php");
 
+$search = optional_param('search', '', PARAM_TEXT);
+
 $context = context_system::instance();
 $url = new moodle_url('/course/recommendations.php');
 
@@ -45,9 +47,13 @@ echo $renderer->header();
 echo $renderer->heading(get_string('activitychooserrecommendations', 'course'));
 
 $manager = \core_course\local\factory\content_item_service_factory::get_content_item_service();
-$modules = $manager->get_all_content_items($USER);
+if (!empty($search)) {
+    $modules = $manager->get_content_items_by_name_pattern($USER, $search);
+} else {
+    $modules = $manager->get_all_content_items($USER);
+}
 
-$activitylist = new \core_course\output\recommendations\activity_list($modules);
+$activitylist = new \core_course\output\recommendations\activity_list($modules, $search);
 
 echo $renderer->render_activity_list($activitylist);
 
index cc05fd6..96325e6 100644 (file)
@@ -74,11 +74,8 @@ foreach ($modinfo->cms as $cm) {
     if (!in_array($cm->modname, $availableresources)) {
         continue;
     }
-    if (!$cm->uservisible) {
-        continue;
-    }
-    if (!$cm->has_view()) {
-        // Exclude label and similar
+    // Exclude activities that aren't visible or have no view link (e.g. label). Account for folder being displayed inline.
+    if (!$cm->uservisible || (!$cm->has_view() && strcmp($cm->modname, 'folder') !== 0)) {
         continue;
     }
     $cms[$cm->id] = $cm;
@@ -140,9 +137,11 @@ foreach ($cms as $cm) {
     }
 
     $class = $cm->visible ? '' : 'class="dimmed"'; // hidden modules are dimmed
+    $url = $cm->url ?: new moodle_url("/mod/{$cm->modname}/view.php", ['id' => $cm->id]);
+
     $table->data[] = array (
         $printsection,
-        "<a $class $extra href=\"".$cm->url."\">".$icon.$cm->get_formatted_name()."</a>",
+        "<a $class $extra href=\"" . $url ."\">" . $icon . $cm->get_formatted_name() . "</a>",
         $intro);
 }
 
index 43efcd4..1670b4b 100644 (file)
 
     No example given as the js will fire and create records from the template library page.
 }}
+{{#search}}
+    <form class="row">
+        <div class="input-group pt-4 pb-1 col-md-6">
+            <label for="search">
+                <span class="sr-only">{{#str}} searchactivitiesbyname, course {{/str}}</span>
+            </label>
+            <input type="text" name="search" id="search" class="form-control rounded-left" autocomplete="off"
+                   placeholder="{{#str}}search, core {{/str}}" {{#query}} value="{{query}}" autofocus {{/query}}
+            >
+            <div class="input-group-append">
+                <button type="submit" class="btn btn-outline-secondary rounded-right" type="button">
+                    <i class="icon fa fa-search fa-fw m-0" aria-hidden="true"></i>
+                    <span class="sr-only">{{#str}}submitsearch, course {{/str}}</span>
+                </button>
+          </div>
+        </div>
+    </form>
+    {{#query}}
+        <div class="pt-1 pb-1">
+            <span role="alert">{{#str}} searchresults, course, {{searchresultsnumber}} {{/str}}</span>
+        </div>
+    {{/query}}
+{{/search}}
 {{#categories}}
-<h3>{{categoryname}}</h3>
-<table class="table table-striped table-hover">
-    <thead>
-        <tr class="d-flex">
-            <th scope="col" class="col-7 c0">{{#str}}module, course{{/str}}</th>
-            <th scope="col" class="col-5 c1">{{#str}}recommend, course{{/str}}</th>
-        </tr>
-    </thead>
-    <tbody>
-        {{#categorydata}}
-        <tr class="d-flex">
-            <td class="col-7 c0"><span>{{{icon}}}</span>{{name}}</td>
-            {{#id}}
-            <td class="col-5 c1 colselect">
-            <input class="activity-recommend-checkbox" type="checkbox" aria-label="{{#str}}recommendcheckbox, course, {{name}}{{/str}}" data-area="{{componentname}}" data-id="{{id}}" {{#recommended}}checked="checked"{{/recommended}}  />
-            </td>
-            {{/id}}
-            {{^id}}
-            <td class="col-5"></td>
-            {{/id}}
-        </tr>
-        {{/categorydata}}
-    </tbody>
-</table>
+    {{#hascategorydata}}
+        <h3 class="pt-4">{{categoryname}}</h3>
+        <table class="table table-striped table-hover">
+            <thead>
+                <tr class="d-flex">
+                    <th scope="col" class="col-7 c0">{{#str}}module, course{{/str}}</th>
+                    <th scope="col" class="col-5 c1">{{#str}}recommend, course{{/str}}</th>
+                </tr>
+            </thead>
+            <tbody>
+                {{#categorydata}}
+                <tr class="d-flex">
+                    <td class="col-7 c0"><span>{{{icon}}}</span>{{name}}</td>
+                    {{#id}}
+                    <td class="col-5 c1 colselect">
+                    <input class="activity-recommend-checkbox" type="checkbox" aria-label="{{#str}}recommendcheckbox, course, {{name}}{{/str}}" data-area="{{componentname}}" data-id="{{id}}" {{#recommended}}checked="checked"{{/recommended}}  />
+                    </td>
+                    {{/id}}
+                    {{^id}}
+                    <td class="col-5"></td>
+                    {{/id}}
+                </tr>
+                {{/categorydata}}
+            </tbody>
+        </table>
+    {{/hascategorydata}}
 {{/categories}}
 {{#js}}
 require([
index b0e62f6..9283980 100644 (file)
     <input type="text"
            data-action="search"
            id="searchinput"
-           class="form-control form-control-lg searchinput px-3 py-2"
+           class="form-control searchinput h-auto border-right-0 rounded-left px-3 py-2"
            placeholder="{{#str}} search, core {{/str}}"
            name="search"
            autocomplete="off"
     >
-    <div class="searchbar-append d-flex px-3">
-        <div class="search-icon">
-            {{#pix}} a/search, core {{/pix}}
-        </div>
-        <div class="clear d-none">
-            <button class="btn p-0" data-action="clearsearch">
-                <span class="d-flex" aria-hidden="true">{{#pix}} e/cancel_solid_circle, core {{/pix}}</span>
-                <span class="sr-only">{{#str}} clearsearch, core {{/str}}</span>
-            </button>
+    <div class="input-group-append">
+        <div class="input-group-text border-left-0 rounded-right bg-transparent px-3 py-2">
+            <div class="search-icon">
+                {{#pix}} a/search, core {{/pix}}
+            </div>
+            <div class="clear d-none">
+                <button class="btn p-0" data-action="clearsearch">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} e/cancel_solid_circle, core {{/pix}}</span>
+                    <span class="sr-only">{{#str}} clearsearch, core {{/str}}</span>
+                </button>
+            </div>
         </div>
     </div>
 </div>
diff --git a/course/tests/behat/search_recommended_activities.feature b/course/tests/behat/search_recommended_activities.feature
new file mode 100644 (file)
index 0000000..3575aaf
--- /dev/null
@@ -0,0 +1,21 @@
+@core @core_course
+Feature: Search recommended activities
+  As an admin I am able to search for activities in the "Recommended activities" admin setting page
+
+  Scenario: Search results are returned if the search query matches any activity names
+    Given I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Courses > Recommended activities" in site administration
+    When I set the field "search" to "assign"
+    And I click on "Submit search" "button"
+    Then I should see "Search results: 1"
+    And "Assignment" "table_row" should exist
+    And "Book" "table_row" should not exist
+
+  Scenario: Search results are not returned if the search query does not match with any activity names
+    Given I log in as "admin"
+    And I am on site homepage
+    And I navigate to "Courses > Recommended activities" in site administration
+    When I set the field "search" to "random query"
+    And I click on "Submit search" "button"
+    Then I should see "Search results: 0"
index 32e76ce..51d2183 100644 (file)
@@ -134,6 +134,32 @@ class services_content_item_service_testcase extends \advanced_testcase {
         $this->assertContains('lti', array_column($allcontentitems, 'name'));
     }
 
+    /**
+     * Test confirming that content items which title match a certain pattern can be fetched irrespective of permissions.
+     */
+    public function test_get_content_items_by_name_pattern() {
+        $this->resetAfterTest();
+
+        // Create a user in a course.
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+
+        // Pattern that does exist.
+        $pattern1 = "assign";
+        // Pattern that does not exist.
+        $pattern2 = "random string";
+
+        $cis = new content_item_service(new content_item_readonly_repository());
+        $matchingcontentitems1 = $cis->get_content_items_by_name_pattern($user, $pattern1);
+        $matchingcontentitems2 = $cis->get_content_items_by_name_pattern($user, $pattern2);
+
+        // The pattern "assign" should return 1 content item ("Assignment").
+        $this->assertCount(1, $matchingcontentitems1);
+        $this->assertEquals("Assignment", $matchingcontentitems1[0]->title);
+        // The pattern "random string" should not return any content items.
+        $this->assertEmpty($matchingcontentitems2);
+    }
+
     /**
      * Test confirming that a content item can be added to a user's favourites.
      */
index 655c54b..b5d7b37 100644 (file)
@@ -112,7 +112,7 @@ function grade_import_commit($courseid, $importcode, $importfeedback=true, $verb
 
                 // make the grades array for update_grade
                 foreach ($grades as $grade) {
-                    if (!$importfeedback) {
+                    if (!$importfeedback || $grade->feedback === null) {
                         $grade->feedback = false; // ignore it
                     }
                     if ($grade->importonlyfeedback) {
index 9a86b44..3f664fa 100644 (file)
@@ -27,6 +27,7 @@ namespace core_h5p;
 defined('MOODLE_INTERNAL') || die();
 
 use core_h5p\local\library\autoloader;
+use core_xapi\local\statement\item_activity;
 
 /**
  * H5P player class, for displaying any local H5P content.
@@ -67,6 +68,11 @@ class player {
      */
     private $content;
 
+    /**
+     * @var string optional component name to send xAPI statements.
+     */
+    private $component;
+
     /**
      * @var string Type of embed object, div or iframe.
      */
@@ -98,8 +104,9 @@ class player {
      * @param string $url Local URL of the H5P file to display.
      * @param stdClass $config Configuration for H5P buttons.
      * @param bool $preventredirect Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions
+     * @param string $component optional moodle component to sent xAPI tracking
      */
-    public function __construct(string $url, \stdClass $config, bool $preventredirect = true) {
+    public function __construct(string $url, \stdClass $config, bool $preventredirect = true, string $component = '') {
         if (empty($url)) {
             throw new \moodle_exception('h5pinvalidurl', 'core_h5p');
         }
@@ -110,6 +117,8 @@ class player {
 
         $this->messages = new \stdClass();
 
+        $this->component = $component;
+
         // Create \core_h5p\core instance.
         $this->core = $this->factory->get_core();
 
@@ -129,14 +138,17 @@ class player {
      * @param string $url Local URL of the H5P file to display.
      * @param stdClass $config Configuration for H5P buttons.
      * @param bool $preventredirect Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions
+     * @param string $component optional moodle component to sent xAPI tracking
      *
      * @return string The embedable code to display a H5P file.
      */
-    public static function display(string $url, \stdClass $config, bool $preventredirect = true): string {
+    public static function display(string $url, \stdClass $config, bool $preventredirect = true,
+            string $component = ''): string {
         global $OUTPUT;
         $params = [
                 'url' => $url,
                 'preventredirect' => $preventredirect,
+                'component' => $component,
             ];
 
         $optparams = ['frame', 'export', 'embed', 'copyright'];
@@ -193,6 +205,7 @@ class player {
         $contenturl = \moodle_url::make_pluginfile_url($systemcontext->id, \core_h5p\file_storage::COMPONENT,
             \core_h5p\file_storage::CONTENT_FILEAREA, $this->h5pid, null, null);
         $exporturl = $this->get_export_settings($displayoptions[ core::DISPLAY_OPTION_DOWNLOAD ]);
+        $xapiobject = item_activity::create_from_id($this->context->id);
         $contentsettings = [
             'library'         => core::libraryToString($this->content['library']),
             'fullScreen'      => $this->content['library']['fullscreen'],
@@ -202,7 +215,7 @@ class player {
             'resizeCode'      => self::get_resize_code(),
             'title'           => $this->content['slug'],
             'displayOptions'  => $displayoptions,
-            'url'             => self::get_embed_url($this->url->out())->out(),
+            'url'             => $xapiobject->get_data()->id,
             'contentUrl'      => $contenturl->out(),
             'metadata'        => $this->content['metadata'],
             'contentUserData' => [0 => ['state' => '{}']]
@@ -698,7 +711,7 @@ class player {
      * @return array The settings.
      */
     private function get_core_settings(): array {
-        global $CFG;
+        global $CFG, $USER;
 
         $basepath = $CFG->wwwroot . '/';
         $systemcontext = \context_system::instance();
@@ -717,7 +730,7 @@ class player {
             'saveFreq' => false,
             'siteUrl' => $CFG->wwwroot,
             'l10n' => array('H5P' => $this->core->getLocalization()),
-            'user' => [],
+            'user' => ['name' => $USER->username, 'mail' => $USER->email],
             'hubIsEnabled' => false,
             'reportingIsEnabled' => false,
             'crossorigin' => null,
@@ -725,6 +738,7 @@ class player {
             'pluginCacheBuster' => $this->get_cache_buster(),
             'libraryUrl' => autoloader::get_h5p_core_library_url('js'),
             'moodleLibraryPaths' => $this->core->get_dependency_roots($this->h5pid),
+            'moodleComponent' => $this->component,
         );
 
         return $settings;
index d29ffc1..3601436 100644 (file)
@@ -36,9 +36,11 @@ $config->copyright = optional_param('copyright', 0, PARAM_INT);
 
 $preventredirect = optional_param('preventredirect', true, PARAM_BOOL);
 
+$component = optional_param('component', '', PARAM_COMPONENT);
+
 $PAGE->set_url(new \moodle_url('/h5p/embed.php', array('url' => $url)));
 try {
-    $h5pplayer = new \core_h5p\player($url, $config, $preventredirect);
+    $h5pplayer = new \core_h5p\player($url, $config, $preventredirect, $component);
     $messages = $h5pplayer->get_messages();
 
 } catch (\Exception $e) {
@@ -92,4 +94,4 @@ if (empty($messages->error) && empty($messages->exception)) {
     echo $OUTPUT->render_from_template('core_h5p/h5perror', $messages);
 }
 
-echo $OUTPUT->footer();
\ No newline at end of file
+echo $OUTPUT->footer();
index 6135db2..87cb996 100644 (file)
@@ -71,6 +71,27 @@ H5PEmbedCommunicator = (function() {
             // Parent origin can be anything.
             window.parent.postMessage(data, '*');
         };
+
+        /**
+         * Send a xAPI statement to LMS.
+         *
+         * @param {string} component
+         * @param {Object} statements
+         */
+        self.post = function(component, statements) {
+            require(['core/ajax'], function(ajax) {
+                var data = {
+                    component: component,
+                    requestjson: JSON.stringify(statements)
+                };
+                ajax.call([
+                   {
+                       methodname: 'core_xapi_statement_post',
+                       args: data
+                   }
+                ]);
+            });
+        };
     }
 
     return (window.postMessage && window.addEventListener ? new Communicator() : undefined);
@@ -150,6 +171,38 @@ document.onreadystatechange = function() {
         }, 0);
     });
 
+    // Get emitted xAPI data.
+    H5P.externalDispatcher.on('xAPI', function(event) {
+        var moodlecomponent = H5P.getMoodleComponent();
+        if (moodlecomponent == undefined) {
+            return;
+        }
+        // Skip malformed events.
+        var hasStatement = event && event.data && event.data.statement;
+        if (!hasStatement) {
+            return;
+        }
+
+        var statement = event.data.statement;
+        var validVerb = statement.verb && statement.verb.id;
+        if (!validVerb) {
+            return;
+        }
+
+        var isCompleted = statement.verb.id === 'http://adlnet.gov/expapi/verbs/answered'
+                    || statement.verb.id === 'http://adlnet.gov/expapi/verbs/completed';
+
+        var isChild = statement.context && statement.context.contextActivities &&
+        statement.context.contextActivities.parent &&
+        statement.context.contextActivities.parent[0] &&
+        statement.context.contextActivities.parent[0].id;
+
+        if (isCompleted && !isChild) {
+            var statements = H5P.getXAPIStatements(this.contentId, statement);
+            H5PEmbedCommunicator.post(moodlecomponent, statements);
+        }
+    });
+
     // Trigger initial resize for instance.
     H5P.trigger(instance, 'resize');
 };
index 10679e0..eea05b4 100644 (file)
@@ -7,3 +7,43 @@ H5P.getLibraryPath = function (library) {
     }
     return H5P._getLibraryPath(library);
 };
+H5P.findInstanceFromId = function (contentId) {
+    if (!contentId) {
+        return H5P.instances[0];
+    }
+    if (H5P.instances !== undefined) {
+        for (var i = 0; i < H5P.instances.length; i++) {
+            if (H5P.instances[i].contentId === contentId) {
+                return H5P.instances[i];
+            }
+        }
+    }
+    return undefined;
+};
+H5P.getXAPIStatements = function (contentId, statement) {
+    var statements = [];
+    var instance = H5P.findInstanceFromId(contentId);
+    if (!instance){
+        return statements;
+    }
+    if (instance.getXAPIData == undefined) {
+        var xAPIData = {
+            statement: statement
+        };
+    } else {
+        var xAPIData = instance.getXAPIData();
+    }
+    if (xAPIData.statement != undefined) {
+        statements.push(xAPIData.statement);
+    }
+    if (xAPIData.children != undefined) {
+        statements = statements.concat(xAPIData.children.map(a => a.statement));
+    }
+    return statements;
+};
+H5P.getMoodleComponent = function () {
+    if (H5PIntegration.moodleComponent) {
+        return H5PIntegration.moodleComponent;
+    }
+    return undefined;
+};
diff --git a/h5p/tests/fixtures/multiple-choice-2-6.h5p b/h5p/tests/fixtures/multiple-choice-2-6.h5p
new file mode 100644 (file)
index 0000000..7e7c215
Binary files /dev/null and b/h5p/tests/fixtures/multiple-choice-2-6.h5p differ
index ed272bb..c373515 100644 (file)
@@ -67,6 +67,9 @@ $string['privacy:metadata:completionsummary'] = 'The course contains completion
 $string['privacy:metadata:favouritessummary'] = 'The course contains information relating to the course being starred by the user.';
 $string['recommend'] = 'Recommend';
 $string['recommendcheckbox'] = 'Recommend activity: {$a}';
+$string['searchactivitiesbyname'] = 'Search for activities by name';
+$string['searchresults'] = 'Search results: {$a}';
+$string['submitsearch'] = 'Submit search';
 $string['studentsatriskincourse'] = 'Students at risk in {$a} course';
 $string['studentsatriskinfomessage'] = 'Hi {$a->userfirstname},
 <p>Students in the {$a->coursename} course have been identified as being at risk.</p>';
index 0f035c1..141e339 100644 (file)
Binary files a/lib/amd/build/drawer.min.js and b/lib/amd/build/drawer.min.js differ
index 9f97f5b..267e20a 100644 (file)
Binary files a/lib/amd/build/drawer.min.js.map and b/lib/amd/build/drawer.min.js.map differ
index a24cd30..3c77606 100644 (file)
@@ -32,7 +32,7 @@ import DrawerEvents from 'core/drawer_events';
 const show = (root) => {
     root.removeClass('hidden');
     root.attr('aria-expanded', true);
-    root.attr('aria-hidden', false);
+    root.removeAttr('aria-hidden');
     root.focus();
 
     PubSub.publish(DrawerEvents.DRAWER_SHOWN, root);
index c068e0e..97f5335 100644 (file)
@@ -4302,7 +4302,13 @@ function delete_user(stdClass $user) {
 
     // Generate username from email address, or a fake email.
     $delemail = !empty($user->email) ? $user->email : $user->username . '.' . $user->id . '@unknownemail.invalid';
-    $delname = clean_param($delemail . "." . time(), PARAM_USERNAME);
+
+    $deltime = time();
+    $deltimelength = core_text::strlen((string) $deltime);
+
+    // Max username length is 100 chars. Select up to limit - (length of current time + 1 [period character]) from users email.
+    $delname = clean_param($delemail, PARAM_USERNAME);
+    $delname = core_text::substr($delname, 0, 100 - ($deltimelength + 1)) . ".{$deltime}";
 
     // Workaround for bulk deletes of users with the same email address.
     while ($DB->record_exists('user', array('username' => $delname))) { // No need to use mnethostid here.
@@ -4317,7 +4323,7 @@ function delete_user(stdClass $user) {
     $updateuser->email        = md5($user->username);// Store hash of username, useful importing/restoring users.
     $updateuser->idnumber     = '';                  // Clear this field to free it up.
     $updateuser->picture      = 0;
-    $updateuser->timemodified = time();
+    $updateuser->timemodified = $deltime;
 
     // Don't trigger update event, as user is being deleted.
     user_update_user($updateuser, false, false);
index fa9a76f..f33fda9 100644 (file)
@@ -3085,6 +3085,7 @@ class paging_bar implements renderable, templatable {
         $data->label = get_string('page');
         $data->pages = [];
         $data->haspages = $this->totalcount > $this->perpage;
+        $data->pagesize = $this->perpage;
 
         if (!$data->haspages) {
             return $data;
index 2a30ca5..26453f9 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index a6d77b2..15c863a 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index 849507d..439fe85 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js and b/lib/table/amd/build/local/dynamic/repository.min.js differ
index 88acdf0..4806057 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js.map and b/lib/table/amd/build/local/dynamic/repository.min.js.map differ
index 7e047e0..f4dc44a 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/selectors.min.js and b/lib/table/amd/build/local/dynamic/selectors.min.js differ
index 525cf30..73059fc 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/selectors.min.js.map and b/lib/table/amd/build/local/dynamic/selectors.min.js.map differ
index 4cddc4d..5f11a38 100644 (file)
@@ -75,6 +75,8 @@ export const refreshTableContent = tableRoot => {
             filters: filterset.filters,
             firstinitial: tableRoot.dataset.tableFirstInitial,
             lastinitial: tableRoot.dataset.tableLastInitial,
+            pageNumber: tableRoot.dataset.tablePageNumber,
+            pageSize: tableRoot.dataset.tablePageSize,
         }
     )
     .then(data => {
@@ -92,6 +94,8 @@ export const updateTable = (tableRoot, {
     filters = null,
     firstInitial = null,
     lastInitial = null,
+    pageNumber = null,
+    pageSize = null,
 } = {}, refreshContent = true) => {
     checkTableIsDynamic(tableRoot);
 
@@ -110,6 +114,14 @@ export const updateTable = (tableRoot, {
         tableRoot.dataset.tableLastInitial = lastInitial;
     }
 
+    if (pageNumber !== null) {
+        tableRoot.dataset.tablePageNumber = pageNumber;
+    }
+
+    if (pageSize !== null) {
+        tableRoot.dataset.tablePageSize = pageSize;
+    }
+
     // Update filters.
     if (filters) {
         tableRoot.dataset.tableFilters = JSON.stringify(filters);
@@ -146,6 +158,28 @@ export const setFilters = (tableRoot, filters, refreshContent = true) =>
 export const setSortOrder = (tableRoot, sortBy, sortOrder, refreshContent = true) =>
     updateTable(tableRoot, {sortBy, sortOrder}, refreshContent);
 
+/**
+ * Set the page number.
+ *
+ * @param {HTMLElement} tableRoot
+ * @param {String} pageNumber
+ * @param {Bool} refreshContent
+ * @returns {Promise}
+ */
+export const setPageNumber = (tableRoot, pageNumber, refreshContent = true) =>
+    updateTable(tableRoot, {pageNumber}, refreshContent);
+
+/**
+ * Set the page size.
+ *
+ * @param {HTMLElement} tableRoot
+ * @param {Number} pageSize
+ * @param {Bool} refreshContent
+ * @returns {Promise}
+ */
+export const setPageSize = (tableRoot, pageSize, refreshContent = true) =>
+    updateTable(tableRoot, {pageSize, pageNumber: 0}, refreshContent);
+
 /**
  * Update the first initial to show.
  *
@@ -205,5 +239,12 @@ export const init = () => {
 
             setLastInitial(tableRoot, lastInitialLink.dataset.initial);
         }
+
+        const pageItem = e.target.closest(Selectors.paginationBar.links.pageItem);
+        if (pageItem) {
+            e.preventDefault();
+
+            setPageNumber(tableRoot, pageItem.dataset.pageNumber);
+        }
     });
 };
index 6743775..5ef99c4 100644 (file)
@@ -33,6 +33,8 @@ import {call as fetchMany} from 'core/ajax';
  * @param {Object} filters The filters to apply when searching
  * @param {String} firstinitial The first name initial to filter on
  * @param {String} lastinitial The last name initial to filter on
+ * @param {String} pageNumber The page number
+ * @param {Number} pageSize The page size
  * @param {Number} params parameters to request table
  * @return {Promise} Resolved with requested table view
  */
@@ -43,6 +45,8 @@ export const fetch = (handler, uniqueid, {
         filters = {},
         firstinitial = null,
         lastinitial = null,
+        pageNumber = null,
+        pageSize = null,
     } = {}
 ) => {
     return fetchMany([{
@@ -56,6 +60,8 @@ export const fetch = (handler, uniqueid, {
             filters,
             firstinitial,
             lastinitial,
+            pagenumber: pageNumber,
+            pagesize: pageSize,
         },
     }])[0];
 };
index 6803571..118f328 100644 (file)
@@ -36,4 +36,9 @@ export default {
             lastInitial: '.lastinitial [data-initial]',
         },
     },
+    paginationBar: {
+        links: {
+            pageItem: '.pagination [data-page-number]'
+        }
+    },
 };
index b029ff5..194822f 100644 (file)
@@ -98,6 +98,18 @@ class fetch extends external_api {
                 VALUE_REQUIRED,
                 null
             ),
+            'pagenumber' => new external_value(
+                PARAM_INT,
+                'The page number',
+                VALUE_REQUIRED,
+                null
+            ),
+            'pagesize' => new external_value(
+                PARAM_INT,
+                'The number of records per page',
+                VALUE_REQUIRED,
+                null
+            ),
         ]);
     }
 
@@ -112,6 +124,9 @@ class fetch extends external_api {
      * @param string $jointype The join type.
      * @param string $firstinitial The first name initial to filter on
      * @param string $lastinitial The last name initial to filter on
+     * @param int $pagenumber The page number.
+     * @param int $pagesize The number of records.
+     * @param string $jointype The join type.
      *
      * @return array
      */
@@ -123,7 +138,9 @@ class fetch extends external_api {
         ?array $filters = null,
         ?string $jointype = null,
         ?string $firstinitial = null,
-        ?string $lastinitial = null
+        ?string $lastinitial = null,
+        ?int $pagenumber = null,
+        ?int $pagesize = null
     ) {
 
         global $PAGE;
@@ -141,6 +158,8 @@ class fetch extends external_api {
             'jointype' => $jointype,
             'firstinitial' => $firstinitial,
             'lastinitial' => $lastinitial,
+            'pagenumber' => $pagenumber,
+            'pagesize' => $pagesize,
         ] = self::validate_parameters(self::execute_parameters(), [
             'handler' => $handler,
             'uniqueid' => $uniqueid,
@@ -150,6 +169,8 @@ class fetch extends external_api {
             'jointype' => $jointype,
             'firstinitial' => $firstinitial,
             'lastinitial' => $lastinitial,
+            'pagenumber' => $pagenumber,
+            'pagesize' => $pagesize,
         ]);
 
         $filterset = new \core_user\table\participants_filterset();
@@ -173,13 +194,20 @@ class fetch extends external_api {
             $instance->set_last_initial($lastinitial);
         }
 
-        $context = $instance->get_context();
+        if ($pagenumber !== null) {
+            $instance->set_page_number($pagenumber);
+        }
 
+        if ($pagesize === null) {
+            $pagesize = 20;
+        }
+
+        $context = $instance->get_context();
         self::validate_context($context);
         $PAGE->set_url($instance->get_base_url());
 
         ob_start();
-        $instance->out(20, true);
+        $instance->out($pagesize, true);
         $participanttablehtml = ob_get_contents();
         ob_end_clean();
 
index 0203543..3f79a59 100644 (file)
@@ -549,7 +549,10 @@ class flexible_table {
             $this->baseurl = $PAGE->url;
         }
 
-        $this->currpage = optional_param($this->request[TABLE_VAR_PAGE], 0, PARAM_INT);
+        if ($this->currpage == null) {
+            $this->currpage = optional_param($this->request[TABLE_VAR_PAGE], 0, PARAM_INT);
+        }
+
         $this->setup = true;
 
         // Always introduce the "flexible" class for the table if not specified
@@ -1406,6 +1409,15 @@ class flexible_table {
         $this->ilast = $initial;
     }
 
+    /**
+     * Set the page number.
+     *
+     * @param int $pagenumber The page number.
+     */
+    public function set_page_number(int $pagenumber): void {
+        $this->currpage = $pagenumber - 1;
+    }
+
     /**
      * Generate the HTML for the sort icon. This is a helper method used by {@link sort_link()}.
      * @param bool $isprimary whether an icon is needed (it is only needed for the primary sort column.)
@@ -1505,6 +1517,8 @@ class flexible_table {
                 'data-table-sort-order' => $sortdata['sortorder'],
                 'data-table-first-initial' => $this->prefs['i_first'],
                 'data-table-last-initial' => $this->prefs['i_last'],
+                'data-table-page-number' => $this->currpage + 1,
+                'data-table-page-size' => $this->pagesize,
             ]);
         }
 
@@ -1810,6 +1824,7 @@ class table_sql extends flexible_table {
             $this->define_columns(array_keys((array)$onerow));
             $this->define_headers(array_keys((array)$onerow));
         }
+        $this->pagesize = $pagesize;
         $this->setup();
         $this->query_db($pagesize, $useinitialsbar);
         $this->build_table();
index e21fd2f..4daa4df 100644 (file)
@@ -35,7 +35,7 @@
     id="{{$drawerid}}drawer-{{uniqid}}{{/drawerid}}"
     class="{{$drawerclasses}}{{/drawerclasses}} drawer bg-white {{^show}}hidden{{/show}}"
     aria-expanded="{{#show}}true{{/show}}{{^show}}false{{/show}}"
-    aria-hidden="{{#show}}false{{/show}}{{^show}}true{{/show}}"
+    {{^show}}aria-hidden="true"{{/show}}
     data-region="right-hand-drawer"
     role="region"
     tabindex="-1"
index 735b5cf..56a793c 100644 (file)
@@ -1,8 +1,8 @@
 {{#haspages}}
     <nav aria-label="{{label}}" class="pagination pagination-centered justify-content-center">
-        <ul class="mt-1 pagination ">
+        <ul class="mt-1 pagination " data-page-size="{{pagesize}}">
             {{#previous}}
-                <li class="page-item">
+                <li class="page-item" data-page-number="{{page}}">
                     <a href="{{url}}" class="page-link" aria-label="Previous">
                         <span aria-hidden="true">&laquo;</span>
                         <span class="sr-only">{{#str}}previous{{/str}}</span>
                 </li>
             {{/previous}}
             {{#first}}
-                <li class="page-item">
+                <li class="page-item" data-page-number="{{page}}">
                     <a href="{{url}}" class="page-link">{{page}}</a>
                 </li>
-                <li class="page-item disabled">
+                <li class="page-item disabled" data-page-number="{{page}}">
                     <span class="page-link">&hellip;</a>
                 </li>
             {{/first}}
             {{#pages}}
-                <li class="page-item {{#active}}active{{/active}}">
+                <li class="page-item {{#active}}active{{/active}}" data-page-number="{{page}}">
                     <a href="{{#url}}{{.}}{{/url}}{{^url}}#{{/url}}" class="page-link">
                         {{page}}
                         {{#active}}
                 </li>
             {{/pages}}
             {{#last}}
-                <li class="page-item disabled">
+                <li class="page-item disabled" data-page-number="{{page}}">
                     <span class="page-link">&hellip;</a>
                 </li>
-                <li class="page-item">
+                <li class="page-item" data-page-number="{{page}}">
                     <a href="{{url}}" class="page-link">{{page}}</a>
                 </li>
             {{/last}}
             {{#next}}
-                <li class="page-item">
+                <li class="page-item" data-page-number="{{page}}">
                     <a href="{{url}}" class="page-link" aria-label="Next">
                         <span aria-hidden="true">&raquo;</span>
                         <span class="sr-only">{{#str}}next{{/str}}</span>
index 4140074..9922acb 100644 (file)
@@ -2465,6 +2465,59 @@ class core_moodlelib_testcase extends advanced_testcase {
         $this->resetDebugging();
     }
 
+    /**
+     * Test deletion of user with long username
+     */
+    public function test_delete_user_long_username() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // For users without an e-mail, one will be created during deletion using {$username}.{$id}@unknownemail.invalid format.
+        $user = $this->getDataGenerator()->create_user([
+            'username' => str_repeat('a', 75),
+            'email' => '',
+        ]);
+
+        delete_user($user);
+
+        // The username for the deleted user shouldn't exceed 100 characters.
+        $usernamedeleted = $DB->get_field('user', 'username', ['id' => $user->id]);
+        $this->assertEquals(100, core_text::strlen($usernamedeleted));
+
+        $timestrlength = core_text::strlen((string) time());
+
+        // It should start with the user name, and end with the current time.
+        $this->assertStringStartsWith("{$user->username}.{$user->id}@", $usernamedeleted);
+        $this->assertRegExp('/\.\d{' . $timestrlength . '}$/', $usernamedeleted);
+    }
+
+    /**
+     * Test deletion of user with long email address
+     */
+    public function test_delete_user_long_email() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create user with 90 character email address.
+        $user = $this->getDataGenerator()->create_user([
+            'email' => str_repeat('a', 78) . '@example.com',
+        ]);
+
+        delete_user($user);
+
+        // The username for the deleted user shouldn't exceed 100 characters.
+        $usernamedeleted = $DB->get_field('user', 'username', ['id' => $user->id]);
+        $this->assertEquals(100, core_text::strlen($usernamedeleted));
+
+        $timestrlength = core_text::strlen((string) time());
+
+        // Max username length is 100 chars. Select up to limit - (length of current time + 1 [period character]) from users email.
+        $expectedemail = core_text::substr($user->email, 0, 100 - ($timestrlength + 1));
+        $this->assertRegExp('/^' . preg_quote($expectedemail) . '\.\d{' . $timestrlength . '}$/', $usernamedeleted);
+    }
+
     /**
      * Test function convert_to_array()
      */
index 42f26d6..e2d5d5d 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/grader.min.js and b/mod/forum/amd/build/local/grades/grader.min.js differ
index 76138fc..ff2c5fc 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/grader.min.js.map and b/mod/forum/amd/build/local/grades/grader.min.js.map differ
index fee7890..8812577 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/local/grader/selectors.min.js and b/mod/forum/amd/build/local/grades/local/grader/selectors.min.js differ
index bcd975c..a7dcd1e 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/local/grader/selectors.min.js.map and b/mod/forum/amd/build/local/grades/local/grader/selectors.min.js.map differ
index 7eb4cbd..51468f9 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/local/grader/user_picker.min.js and b/mod/forum/amd/build/local/grades/local/grader/user_picker.min.js differ
index 3c17998..e59ddf6 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/local/grader/user_picker.min.js.map and b/mod/forum/amd/build/local/grades/local/grader/user_picker.min.js.map differ
index 99f20c9..df7e533 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js and b/mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js differ
index 9c9dddc..da9f5c8 100644 (file)
Binary files a/mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js.map and b/mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js.map differ
index 6302914..06bda81 100644 (file)
@@ -35,6 +35,8 @@ import {debounce} from 'core/utils';
 import {fillInitialValues} from 'core_grades/grades/grader/gradingpanel/comparison';
 import * as Modal from 'core/modal_factory';
 import * as ModalEvents from 'core/modal_events';
+import {subscribe} from 'core/pubsub';
+import DrawerEvents from 'core/drawer_events';
 
 const templateNames = {
     grader: {
@@ -157,6 +159,16 @@ const showUserSearchInput = (toggleSearchButton, searchContainer, searchInput) =
     toggleSearchButton.setAttribute('aria-expanded', 'true');
     toggleSearchButton.classList.add('expand');
     toggleSearchButton.classList.remove('collapse');
+
+    // Hide the grading info container from screen reader.
+    const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
+    gradingInfoContainer.setAttribute('aria-hidden', 'true');
+
+    // Hide the collapse grading drawer button from screen reader.
+    const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);
+    collapseGradingDrawer.setAttribute('aria-hidden', 'true');
+    collapseGradingDrawer.setAttribute('tabindex', '-1');
+
     searchInput.focus();
 };
 
@@ -173,6 +185,16 @@ const hideUserSearchInput = (toggleSearchButton, searchContainer, searchInput) =
     toggleSearchButton.classList.add('collapse');
     toggleSearchButton.classList.remove('expand');
     toggleSearchButton.focus();
+
+    // Show the grading info container to screen reader.
+    const gradingInfoContainer = searchContainer.parentElement.querySelector(Selectors.regions.gradingInfoContainer);
+    gradingInfoContainer.removeAttribute('aria-hidden');
+
+    // Show the collapse grading drawer button from screen reader.
+    const collapseGradingDrawer = searchContainer.parentElement.querySelector(Selectors.buttons.collapseGradingDrawer);
+    collapseGradingDrawer.removeAttribute('aria-hidden');
+    collapseGradingDrawer.setAttribute('tabindex', '0');
+
     searchInput.value = '';
 };
 
@@ -278,6 +300,35 @@ const registerEventListeners = (graderLayout, userPicker, saveGradeFunction, use
         const users = searchForUsers(userList, searchInput.value);
         renderSearchResults(searchResultsContainer, users);
     }, 300));
+
+    // Remove the right margin of the content container when the grading panel is hidden so that it expands to full-width.
+    subscribe(DrawerEvents.DRAWER_HIDDEN, (drawerRoot) => {
+        const gradingPanel = drawerRoot[0];
+        if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {
+            setContentContainerMargin(graderContainer, 0);
+        }
+    });
+
+    // Bring back the right margin of the content container when the grading panel is shown to give space for the grading panel.
+    subscribe(DrawerEvents.DRAWER_SHOWN, (drawerRoot) => {
+        const gradingPanel = drawerRoot[0];
+        if (gradingPanel.querySelector(Selectors.regions.gradingPanel)) {
+            setContentContainerMargin(graderContainer, gradingPanel.offsetWidth);
+        }
+    });
+};
+
+/**
+ * Adjusts the right margin of the content container.
+ *
+ * @param {HTMLElement} graderContainer The container for the grader app.
+ * @param {Number} rightMargin The right margin value.
+ */
+const setContentContainerMargin = (graderContainer, rightMargin) => {
+    const contentContainer = graderContainer.querySelector(Selectors.regions.moduleContainer);
+    if (contentContainer) {
+        contentContainer.style.marginRight = `${rightMargin}px`;
+    }
 };
 
 /**
index b2bd51f..a52b110 100644 (file)
@@ -36,14 +36,17 @@ export default {
     buttons: {
         toggleFullscreen: getDataSelector('action', 'togglefullscreen'),
         closeGrader: getDataSelector('action', 'closegrader'),
+        collapseGradingDrawer: getDataSelector('action', 'collapse-grading-drawer'),
         saveGrade: getDataSelector('action', 'savegrade'),
         selectUser: getDataSelector('action', 'select-user'),
         toggleSearch: getDataSelector('action', 'toggle-search')
     },
     regions: {
         bodyContainer: getDataSelector('region', 'body-container'),
+        moduleContainer: getDataSelector('region', 'module_content_container'),
         moduleReplace: getDataSelector('region', 'module_content'),
         pickerRegion: getDataSelector('region', 'user_picker'),
+        gradingInfoContainer: getDataSelector('region', 'grading-info-container'),
         gradingPanel: getDataSelector('region', 'grade'),
         gradingPanelContainer: getDataSelector('region', 'grading-panel-container'),
         gradingPanelErrors: getDataSelector('region', 'grade-errors'),
index e094674..ba209a4 100644 (file)
@@ -24,6 +24,7 @@
 
 import Templates from 'core/templates';
 import Selectors from './user_picker/selectors';
+import {get_string as getString} from 'core/str';
 
 const templatePath = 'mod_forum/local/grades/local/grader';
 
@@ -111,6 +112,10 @@ class UserPicker {
         const [{html, js}] = await Promise.all([this.renderUserChange(user), this.showUserCallback(user)]);
         const userRegion = this.root.querySelector(Selectors.regions.userRegion);
         Templates.replaceNodeContents(userRegion, html, js);
+
+        // Update the hidden now-grading region so screen readers can announce the user that's currently being graded.
+        const currentUserRegion = this.root.querySelector(Selectors.regions.currentUser);
+        currentUserRegion.textContent = await getString('nowgradinguser', 'mod_forum', user.fullname);
     }
 
     /**
index 035240f..0ef8405 100644 (file)
@@ -24,6 +24,7 @@
 
 export default {
     regions: {
+        currentUser: '[data-region="user_picker/current_user"]',
         userRegion: '[data-region="user_picker/user"]',
     },
     actions: {
index ae4692a..fd08a5d 100644 (file)
@@ -24,6 +24,7 @@
  */
 
 $string['activityoverview'] = 'There are new forum posts';
+$string['actionsforgraderinterface'] = 'Actions for the grader interface';
 $string['actionsforpost'] = 'Actions for post';
 $string['addanewdiscussion'] = 'Add a new discussion topic';
 $string['addanewquestion'] = 'Add a new question';
@@ -102,6 +103,7 @@ $string['clicktosubscribe'] = 'You are not subscribed to this discussion. Click
 $string['clicktounfavourite'] = 'You have starred this discussion. Click to unstar.';
 $string['clicktofavourite'] = 'You have not starred this discussion. Click to star.';
 $string['close'] = 'Close';
+$string['closegrader'] = 'Close grader';
 $string['completiondiscussions'] = 'Student must create discussions:';
 $string['completiondiscussionsdesc'] = 'Student must create at least {$a} discussion(s)';
 $string['completiondiscussionsgroup'] = 'Require discussions';
@@ -299,6 +301,8 @@ $string['forum:exportdiscussion'] = 'Export whole discussion';
 $string['forum:exportforum'] = 'Export forum';
 $string['forum:exportownpost'] = 'Export own post';
 $string['forum:exportpost'] = 'Export post';
+$string['forumgradingnavigation'] = 'Forum grading navigation';
+$string['forumgradingpanel'] = 'Forum grading panel';
 $string['forumintro'] = 'Description';
 $string['forum:managesubscriptions'] = 'Manage subscribers';
 $string['forum:movediscussions'] = 'Move discussions';
@@ -443,7 +447,7 @@ $string['namenews_help'] = 'The course announcements forum is a special forum fo
 $string['namesocial'] = 'Social forum';
 $string['nameteacher'] = 'Teacher forum';
 $string['nextdiscussiona'] = 'Next discussion: {$a}';
-$string['nextuser'] = 'Next user';
+$string['nextuser'] = 'Save changes and proceed to the next user';
 $string['newforumposts'] = 'New forum posts';
 $string['noattachments'] = 'There are no attachments to this post';
 $string['nodiscussions'] = 'There are no discussion topics yet in this forum';
@@ -522,7 +526,7 @@ $string['poststo'] = 'Posts to';
 $string['posttoforum'] = 'Post to forum';
 $string['postupdated'] = 'Your post was updated';
 $string['potentialsubscribers'] = 'Potential subscribers';
-$string['previoususer'] = 'Previous user';
+$string['previoususer'] = 'Save changes and proceed to the previous user';
 $string['privacy:digesttypenone'] = 'We do not hold any data relating to a preferred forum digest type for this forum.';
 $string['privacy:digesttypepreference'] = 'You have chosen to receive the following forum digest type: "{$a->type}".';
 $string['privacy:discussionsubscriptionpreference'] = 'You have chosen the following discussion subscription preference for this forum: "{$a->preference}"';
@@ -729,6 +733,7 @@ $string['unsubscribeshort'] = 'Unsubscribe';
 $string['useexperimentalui'] = 'Use experimental nested discussion view';
 $string['usermarksread'] = 'Manual message read marking';
 $string['usernavigation'] = 'User navigation';
+$string['usersforumposts'] = 'User\'s forum posts';
 $string['unpindiscussion'] = 'Unpin this discussion';
 $string['viewalldiscussions'] = 'View all discussions';
 $string['viewparentpost'] = 'View parent post';
@@ -750,6 +755,7 @@ $string['gradeusers'] = 'Grade users';
 $string['graded'] = 'Graded';
 $string['gradedby'] = 'Graded by';
 $string['notgraded'] = 'Not graded';
+$string['nowgradinguser'] = 'Now grading {$a}';
 $string['gradeforrating'] = 'Grade for rating: {$a->str_long_grade}';
 $string['gradeforratinghidden'] = 'Grade for rating hidden';
 $string['gradeforwholeforum'] = 'Grade for forum: {$a->str_long_grade}';
index 7f81561..2eef2d4 100644 (file)
@@ -44,8 +44,8 @@
     {{> mod_forum/local/grades/local/grader/navigation }}
 
     <div class="d-flex flex-grow-1 h-100 position-relative">
-        {{#drawer}}{{> mod_forum/local/grades/local/grader/grading }}{{/drawer}}
         {{> mod_forum/local/grades/local/grader/content }}
+        {{#drawer}}{{> mod_forum/local/grades/local/grader/grading }}{{/drawer}}
     </div>
 </div>
 {{#js}}
index 6f24231..944fbe9 100644 (file)
@@ -30,7 +30,7 @@
     {
     }
 }}
-<div class="grader-module-content w-100 h-100">
+<div class="grader-module-content w-100 h-100" data-region="module_content_container" role="region" aria-label="{{#str}} usersforumposts, mod_forum {{/str}}">
     <div data-region="module_content" class="grader-module-content-display">
         {{> mod_forum/local/grades/local/grader/module_content_placeholder }}
     </div>
index 706c74a..61c7c5e 100644 (file)
     {{$drawerid}}grading-drawer-{{uniqid}}{{/drawerid}}
     {{$drawerclasses}}grader-grading-panel flex-shrink-0{{/drawerclasses}}
     {{$drawercontent}}
-        <div class="h-100 w-100 bg-white d-flex flex-column">
+        <div class="h-100 w-100 bg-white d-flex flex-column" role="region" aria-label="{{#str}} forumgradingpanel, mod_forum {{/str}}">
             <div class="flex-shrink-0 d-flex flex-column">
                 <div class="header-container bg-light">
+                    <div class="info-container d-flex align-items-center" data-region="grading-info-container">
+                        <button
+                            class="btn btn-icon icon-size-3 icon-no-margin colour-inherit"
+                            data-action="collapse-grading-drawer"
+                            aria-controls="grading-drawer-{{uniqid}}"
+                            aria-expanded="true"
+                            title="{{#str}} closebuttontitle, core {{/str}}"
+                            aria-label="{{#str}} hidegraderpanel, mod_forum {{/str}}"
+                        >
+                            <span class="dir-ltr-hide">{{#pix}} t/left, core {{/pix}}</span>
+                            <span class="dir-rtl-hide">{{#pix}} t/right, core {{/pix}}</span>
+                        </button>
+                        <div class="ml-auto mr-auto text-center" data-region="status-container">
+                            {{> mod_forum/local/grades/local/grader/status_placeholder }}
+                        </div>
+                    </div>
                     <div
                         id="searchbox-{{uniqid}}"
                         class="user-search-container d-flex flex-grow-1 align-items-center collapsed"
                                 aria-controls="searchbox-{{uniqid}}"
                                 data-action="toggle-search"
                             >
-                                <div class="expanded-icon">
-                                    <span aria-hidden="true">{{#pix}} e/cancel, core, {{#str}} hideusersearch, mod_forum {{/str}} {{/pix}}</span>
-                                    <span class="sr-only">{{#str}} hideusersearch, mod_forum {{/str}}</span>
+                                <div class="expanded-icon" data-region="user-search-icon-hide">
+                                    <span>{{#pix}} e/cancel, core, {{#str}} hideusersearch, mod_forum {{/str}} {{/pix}}</span>
                                 </div>
-                                <div class="collapsed-icon">
-                                    <span aria-hidden="true">{{#pix}} i/search, core, {{#str}} showusersearch, mod_forum {{/str}} {{/pix}}</span>
-                                    <span class="sr-only">{{#str}} showusersearch, mod_forum {{/str}}</span>
+                                <div class="collapsed-icon" data-region="user-search-icon-show">
+                                    <span>{{#pix}} i/search, core, {{#str}} showusersearch, mod_forum {{/str}} {{/pix}}</span>
                                 </div>
                             </button>
                         </div>
                     </div>
-                    <div class="info-container d-flex align-items-center">
-                        <button
-                            class="btn btn-icon icon-size-3 icon-no-margin colour-inherit"
-                            data-action="collapse-grading-drawer"
-                            aria-controls="grading-drawer-{{uniqid}}"
-                            aria-expanded="true"
-                            title="{{#str}} closebuttontitle, core {{/str}}"
-                        >
-                            <span class="dir-ltr-hide">{{#pix}} t/left, core {{/pix}}</span>
-                            <span class="dir-rtl-hide">{{#pix}} t/right, core {{/pix}}</span>
-                        </button>
-                        <div class="ml-auto mr-auto text-center" data-region="status-container">
-                            {{> mod_forum/local/grades/local/grader/status_placeholder }}
-                        </div>
-                    </div>
                 </div>
                 <div class="border-bottom px-3 pt-2" data-region="user_picker">
                     {{> mod_forum/local/grades/local/grader/user_picker_placeholder }}
index 6008663..44bf942 100644 (file)
@@ -34,7 +34,7 @@
         "moduleName": "Chef the Forum"
     }
 }}
-<nav id="nav-container-{{uniqid}}" class="grader-grading_navigation navbar">
+<nav id="nav-container-{{uniqid}}" class="grader-grading_navigation navbar" aria-label="{{#str}} forumgradingnavigation, mod_forum {{/str}}">
     <div class="d-none d-sm-flex align-items-center">
         <a href="{{{courseUrl}}}" class="btn btn-link px-2 colour-inherit">
             <h5 class="d-inline px-0 mb-0">{{courseName}}</h5>
@@ -61,7 +61,7 @@
         </button>
         <button
             class="btn btn-icon icon-no-margin drawer-button mr-1"
-            aria-label="Open or close grader panel"
+            aria-label="{{#str}} hidegraderpanel, mod_forum {{/str}}"
             data-action="collapse-grading-drawer"
             aria-controls="grading-drawer-{{uniqid}}"
             aria-expanded="true"
index d81753c..43b5386 100644 (file)
 }}
 
 <div class="d-flex align-items-center user-picker-container mb-2 py-2">
+    <div aria-live="polite" data-region="user_picker/current_user"  class="sr-only"></div>
     <div class="d-flex align-items-center" data-region="user_picker/user"></div>
     <div class="ml-auto flex-shrink-0">
         <nav aria-label="{{#str}} usernavigation, mod_forum {{/str}}">
             <ul class="pagination mb-0">
                 <li class="page-item">
-                    <a
+                    <button
                         class="page-link icon-no-margin p-0 text-reset icon-size-3"
                         href="#"
-                        aria-label="{{#str}} previous {{/str}}"
+                        aria-label="{{#str}} previoususer, mod_forum {{/str}}"
                         data-action="change-user"
                         data-direction="-1"
                         title="{{#str}} previoususer, mod_forum {{/str}}"
                     >
                         <span class="dir-ltr-hide">{{#pix}} i/next, core {{/pix}}</span>
                         <span class="dir-rtl-hide">{{#pix}} i/previous, core {{/pix}}</span>
-                    </a>
+                    </button>
                 </li>
                 <li class="page-item">
-                    <a
+                    <button
                         class="page-link icon-no-margin p-0 text-reset icon-size-3 ml-2"
                         href="#"
-                        aria-label="{{#str}} next {{/str}}"
+                        aria-label="{{#str}} nextuser, mod_forum {{/str}}"
                         data-action="change-user"
                         data-direction="1"
                         title="{{#str}} nextuser, mod_forum {{/str}}"
                     >
                         <span class="dir-ltr-hide">{{#pix}} i/previous, core {{/pix}}</span>
                         <span class="dir-rtl-hide">{{#pix}} i/next, core {{/pix}}</span>
-                    </a>
+                    </button>
                 </li>
             </ul>
         </nav>
index 144f2e9..ef04cfe 100644 (file)
@@ -42,8 +42,7 @@
 <img
     class="rounded-circle userpicture mr-2"
     src="{{.}}"
-    alt="{{#str}}pictureof, moodle, {{fullname}}{{/str}}"
-    title="{{#str}}pictureof, moodle, {{fullname}}{{/str}}"
+    aria-hidden="true"
 >
 {{/profileimage}}
 <div>
index f0a7204..8700085 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Plugin event classes are defined here.
+ * H5P Activity list viewed event.
  *
  * @package     mod_h5pactivity
  * @copyright   2020 Ferran Recio <ferran@moodle.com>
index 4dabab3..93604a6 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Plugin event classes are defined here.
+ * H5P activity viewed.
  *
  * @package     mod_h5pactivity
  * @copyright   2020 Ferran Recio <ferran@moodle.com>
diff --git a/mod/h5pactivity/classes/event/statement_received.php b/mod/h5pactivity/classes/event/statement_received.php
new file mode 100644 (file)
index 0000000..c6a3b64
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * H5P activity send an xAPI tracking statement.
+ *
+ * @package     mod_h5pactivity
+ * @copyright   2020 Ferran Recio <ferran@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The statement_received event class.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class statement_received extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init(): void {
+        $this->data['objecttable'] = 'h5pactivity';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('statement_received', 'mod_h5pactivity');
+    }
+
+    /**
+     * Replace add_to_log() statement.
+     *
+     * @return array of parameters to be passed to legacy add_to_log() function.
+     */
+    protected function get_legacy_logdata() {
+        return [$this->courseid, 'h5pactivity', 'statement received', 'grade.php?user=' . $this->userid,
+            0, $this->contextinstanceid];
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with the id '$this->userid' send a tracking statement " .
+                "for a H5P activity with the course module id '$this->contextinstanceid'.";
+    }
+
+    /**
+     * Get URL related to the action
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/mod/h5pactivity/grade.php',
+                ['id' => $this->contextinstanceid, 'user' => $this->userid]);
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return ['db' => 'h5pactivity', 'restore' => 'h5pactivity'];
+    }
+}
diff --git a/mod/h5pactivity/classes/local/attempt.php b/mod/h5pactivity/classes/local/attempt.php
new file mode 100644 (file)
index 0000000..8489003
--- /dev/null
@@ -0,0 +1,367 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * H5P activity attempt object
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use stdClass;
+use core_xapi\local\statement;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class attempt for H5P activity
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ */
+class attempt {
+
+    /** @var stdClass the h5pactivity_attempts record. */
+    private $record;
+
+    /**
+     * Create a new attempt object.
+     *
+     * @param stdClass $record the h5pactivity_attempts record
+     */
+    protected function __construct(stdClass $record) {
+        $this->record = $record;
+        $this->results = null;
+    }
+
+    /**
+     * Create a new user attempt in a specific H5P activity.
+     *
+     * @param stdClass $user a user record
+     * @param stdClass $cm a course_module record
+     * @return attempt|null a new attempt object or null if fail
+     */
+    public static function new_attempt(stdClass $user, stdClass $cm): ?attempt {
+        global $DB;
+        $record = new stdClass();
+        $record->h5pactivityid = $cm->instance;
+        $record->userid = $user->id;
+        $record->timecreated = time();
+        $record->timemodified = $record->timecreated;
+        $record->rawscore = 0;
+        $record->maxscore = 0;
+
+        // Get last attempt number.
+        $conditions = ['h5pactivityid' => $cm->instance, 'userid' => $user->id];
+        $countattempts = $DB->count_records('h5pactivity_attempts', $conditions);
+        $record->attempt = $countattempts + 1;
+
+        $record->id = $DB->insert_record('h5pactivity_attempts', $record);
+        if (!$record->id) {
+            return null;
+        }
+        return new attempt($record);
+    }
+
+    /**
+     * Get the last user attempt in a specific H5P activity.
+     *
+     * If no previous attempt exists, it generates a new one.
+     *
+     * @param stdClass $user a user record
+     * @param stdClass $cm a course_module record
+     * @return attempt|null a new attempt object or null if some problem accured
+     */
+    public static function last_attempt(stdClass $user, stdClass $cm): ?attempt {
+        global $DB;
+        $conditions = ['h5pactivityid' => $cm->instance, 'userid' => $user->id];
+        $records = $DB->get_records('h5pactivity_attempts', $conditions, 'attempt DESC', '*', 0, 1);
+        if (empty($records)) {
+            return self::new_attempt($user, $cm);
+        }
+        return new attempt(array_shift($records));
+    }
+
+    /**
+     * Wipe all attempt data for specific course_module and an optional user.
+     *
+     * @param stdClass $cm a course_module record
+     * @param stdClass $user a user record
+     */
+    public static function delete_all_attempts(stdClass $cm, stdClass $user = null): void {
+        global $DB;
+
+        $where = 'a.h5pactivityid = :h5pactivityid';
+        $conditions = ['h5pactivityid' => $cm->instance];
+        if (!empty($user)) {
+            $where .= ' AND a.userid = :userid';
+            $conditions['userid'] = $user->id;
+        }
+
+        $DB->delete_records_select('h5pactivity_attempts_results', "attemptid IN (
+                SELECT a.id
+                FROM {h5pactivity_attempts} a
+                WHERE $where)", $conditions);
+
+        $DB->delete_records('h5pactivity_attempts', $conditions);
+    }
+
+    /**
+     * Delete a specific attempt.
+     *
+     * @param attempt $attempt the attempt object to delete
+     */
+    public static function delete_attempt(attempt $attempt): void {
+        global $DB;
+        $attempt->delete_results();
+        $DB->delete_records('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+    }
+
+    /**
+     * Save a new result statement into the attempt.
+     *
+     * It also updates the rawscore and maxscore if necessary.
+     *
+     * @param statement $statement the xAPI statement object
+     * @param string $subcontent = '' optional subcontent identifier
+     * @return bool if it can save the statement into db
+     */
+    public function save_statement(statement $statement, string $subcontent = ''): bool {
+        global $DB;
+
+        // Check statement data.
+        $xapiobject = $statement->get_object();
+        if (empty($xapiobject)) {
+            return false;
+        }
+        $xapiresult = $statement->get_result();
+        $xapidefinition = $xapiobject->get_definition();
+        if (empty($xapidefinition) || empty($xapiresult)) {
+            return false;
+        }
+
+        $xapicontext = $statement->get_context();
+        if ($xapicontext) {
+            $context = $xapicontext->get_data();
+        } else {
+            $context = new stdClass();
+        }
+        $definition = $xapidefinition->get_data();
+        $result = $xapiresult->get_data();
+
+        // Insert attempt_results record.
+        $record = new stdClass();
+        $record->attemptid = $this->record->id;
+        $record->subcontent = $subcontent;
+        $record->timecreated = time();
+        $record->interactiontype = $definition->interactionType ?? 'other';
+        $record->description = $this->get_description_from_definition($definition);
+        $record->correctpattern = $this->get_correctpattern_from_definition($definition);
+        $record->response = $result->response ?? '';
+        $record->additionals = $this->get_additionals($definition, $context);
+        $record->rawscore = 0;
+        $record->maxscore = 0;
+        if (isset($result->score)) {
+            $record->rawscore = $result->score->raw ?? 0;
+            $record->maxscore = $result->score->max ?? 0;
+        }
+        if (!$DB->insert_record('h5pactivity_attempts_results', $record)) {
+            return false;
+        }
+
+        // If no subcontent provided, results are propagated to the attempt itself.
+        if (empty($subcontent) && $record->rawscore) {
+            $this->record->rawscore = $record->rawscore;
+            $this->record->maxscore = $record->maxscore;
+        }
+        // Refresh current attempt.
+        return $this->save();
+    }
+
+    /**
+     * Update the current attempt record into DB.
+     *
+     * @return bool true if update is succesful
+     */
+    public function save(): bool {
+        global $DB;
+        $this->record->timemodified = time();
+        return $DB->update_record('h5pactivity_attempts', $this->record);
+    }
+
+    /**
+     * Delete the current attempt results from the DB.
+     */
+    public function delete_results(): void {
+        global $DB;
+        $conditions = ['attemptid' => $this->record->id];
+        $DB->delete_records('h5pactivity_attempts_results', $conditions);
+    }
+
+    /**
+     * Return de number of results stored in this attempt.
+     *
+     * @return int the number of results stored in this attempt.
+     */
+    public function count_results(): int {
+        global $DB;
+        $conditions = ['attemptid' => $this->record->id];
+        return $DB->count_records('h5pactivity_attempts_results', $conditions);
+    }
+
+    /**
+     * Get additional data for some interaction types.
+     *
+     * @param stdClass $definition the statement object definition data
+     * @param stdClass $context the statement optional context
+     * @return string JSON encoded additional information
+     */
+    private function get_additionals(stdClass $definition, stdClass $context): string {
+        $additionals = [];
+        $interactiontype = $definition->interactionType ?? 'other';
+        switch ($interactiontype) {
+            case 'choice':
+            case 'sequencing':
+                $additionals['choices'] = $definition->choices ?? [];
+            break;
+
+            case 'matching':
+                $additionals['source'] = $definition->source ?? [];
+                $additionals['target'] = $definition->target ?? [];
+            break;
+
+            case 'likert':
+                $additionals['scale'] = $definition->scale ?? [];
+            break;
+
+            case 'performance':
+                $additionals['steps'] = $definition->steps ?? [];
+            break;
+        }
+
+        $additionals['extensions'] = $definition->extensions ?? new stdClass();
+
+        // Add context extensions.
+        $additionals['contextExtensions'] = $context->extensions ?? new stdClass();
+
+        if (empty($additionals)) {
+            return '';
+        }
+        return json_encode($additionals);
+    }
+
+    /**
+     * Extract the result description from statement object definition.
+     *
+     * In principle, H5P package can send a multilang description but the reality
+     * is that most activities only send the "en_US" description if any and the
+     * activity does not have any control over it.
+     *
+     * @param stdClass $definition the statement object definition
+     * @return string The available description if any
+     */
+    private function get_description_from_definition(stdClass $definition): string {
+        if (!isset($definition->description)) {
+            return '';
+        }
+        $translations = (array) $definition->description;
+        if (empty($translations)) {
+            return '';
+        }
+        // By default, H5P packages only send "en-US" descriptions.
+        return $translations['en-US'] ?? array_shift($translations);
+    }
+
+    /**
+     * Extract the correct pattern from statement object definition.
+     *
+     * The correct pattern depends on the type of content and the plugin
+     * has no control over it so we just store it in case that the statement
+     * data have it.
+     *
+     * @param stdClass $definition the statement object definition
+     * @return string The correct pattern if any
+     */
+    private function get_correctpattern_from_definition(stdClass $definition): string {
+        if (!isset($definition->correctResponsesPattern)) {
+            return '';
+        }
+        // Only arrays are allowed.
+        if (is_array($definition->correctResponsesPattern)) {
+            return json_encode($definition->correctResponsesPattern);
+        }
+        return '';
+    }
+
+    /**
+     * Return the attempt number.
+     *
+     * @return int the attempt number
+     */
+    public function get_attempt(): int {
+        return $this->record->attempt;
+    }
+
+    /**
+     * Return the attempt ID.
+     *
+     * @return int the attempt id
+     */
+    public function get_id(): int {
+        return $this->record->id;
+    }
+
+    /**
+     * Return the attempt user ID.
+     *
+     * @return int the attempt userid
+     */
+    public function get_userid(): int {
+        return $this->record->userid;
+    }
+
+    /**
+     * Return the attempt H5P activity ID.
+     *
+     * @return int the attempt userid
+     */
+    public function get_h5pactivityid(): int {
+        return $this->record->h5pactivityid;
+    }
+
+    /**
+     * Return the attempt maxscore.
+     *
+     * @return int the maxscore value
+     */
+    public function get_maxscore(): int {
+        return $this->record->maxscore;
+    }
+
+    /**
+     * Return the attempt rawscore.
+     *
+     * @return int the rawscore value
+     */
+    public function get_rawscore(): int {
+        return $this->record->maxscore;
+    }
+}
index afb38de..702ba9b 100644 (file)
 
 namespace mod_h5pactivity\privacy;
 
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\helper;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\userlist;
+use core_privacy\local\request\writer;
+use stdClass;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -33,7 +43,11 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2020 Ferran Recio <ferran@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\null_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\plugin\provider {
+
     /**
      * Get the language string identifier with the component's language
      * file to explain why this plugin stores no data.
@@ -43,4 +57,263 @@ class provider implements \core_privacy\local\metadata\null_provider {
     public static function get_reason() : string {
         return 'privacy:metadata';
     }
+
+    /**
+     * Return the fields which contain personal data.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table('h5pactivity_attempts', [
+                'userid' => 'privacy:metadata:userid',
+                'attempt' => 'privacy:metadata:attempt',
+                'timecreated' => 'privacy:metadata:timecreated',
+                'timemodified' => 'privacy:metadata:timemodified',
+                'rawscore' => 'privacy:metadata:rawscore',
+            ], 'privacy:metadata:xapi_track');
+
+        $collection->add_database_table('h5pactivity_attempts_results', [
+                'attempt' => 'privacy:metadata:attempt',
+                'timecreated' => 'privacy:metadata:timecreated',
+                'rawscore' => 'privacy:metadata:rawscore',
+            ], 'privacy:metadata:xapi_track_results');
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $sql = "SELECT ctx.id
+                  FROM {h5pactivity_attempts} ss
+                  JOIN {modules} m
+                    ON m.name = :activityname
+                  JOIN {course_modules} cm
+                    ON cm.instance = ss.h5pactivityid
+                   AND cm.module = m.id
+                  JOIN {context} ctx
+                    ON ctx.instanceid = cm.id
+                   AND ctx.contextlevel = :modlevel
+                 WHERE ss.userid = :userid";
+
+        $params = ['activityname' => 'h5pactivity', 'modlevel' => CONTEXT_MODULE, 'userid' => $userid];
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!is_a($context, \context_module::class)) {
+            return;
+        }
+
+        $sql = "SELECT ss.userid
+                  FROM {h5pactivity_attempts} ss
+                  JOIN {modules} m
+                    ON m.name = 'h5pactivity'
+                  JOIN {course_modules} cm
+                    ON cm.instance = ss.h5pactivityid
+                   AND cm.module = m.id
+                  JOIN {context} ctx
+                    ON ctx.instanceid = cm.id
+                   AND ctx.contextlevel = :modlevel
+                 WHERE ctx.id = :contextid";
+
+        $params = ['modlevel' => CONTEXT_MODULE, 'contextid' => $context->id];
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        // Remove contexts different from CONTEXT_MODULE.
+        $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                $carry[] = $context->id;
+            }
+            return $carry;
+        }, []);
+
+        if (empty($contexts)) {
+            return;
+        }
+
+        $user = $contextlist->get_user();
+        $userid = $user->id;
+        // Get H5P attempts data.
+        foreach ($contexts as $contextid) {
+            $context = \context::instance_by_id($contextid);
+            $data = helper::get_context_data($context, $user);
+            writer::with_context($context)->export_data([], $data);
+            helper::export_context_files($context, $user);
+        }
+
+        // Get attempts track data.
+        list($insql, $inparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED);
+        $sql = "SELECT har.id,
+                       ha.attempt,
+                       har.description,
+                       har.interactiontype,
+                       har.response,
+                       har.additionals,
+                       har.rawscore,
+                       har.maxscore,
+                       har.timecreated,
+                       ctx.id as contextid
+                  FROM {h5pactivity_attempts_results} har
+                  JOIN {h5pactivity_attempts} ha
+                    ON har.attemptid = ha.id
+                  JOIN {course_modules} cm
+                    ON cm.instance = ha.h5pactivityid
+                  JOIN {context} ctx
+                    ON ctx.instanceid = cm.id
+                 WHERE ctx.id $insql
+                   AND ha.userid = :userid";
+        $params = array_merge($inparams, ['userid' => $userid]);
+
+        $alldata = [];
+        $attemptsdata = $DB->get_recordset_sql($sql, $params);
+        foreach ($attemptsdata as $track) {
+            $alldata[$track->contextid][$track->attempt][] = (object)[
+                    'description' => $track->description,
+                    'response' => $track->response,
+                    'interactiontype' => $track->interactiontype,
+                    'additionals' => $track->additionals,
+                    'rawscore' => $track->rawscore,
+                    'maxscore' => $track->maxscore,
+                    'timecreated' => transform::datetime($track->timecreated),
+                ];
+        }
+        $attemptsdata->close();
+
+        // The result data is organised in:
+        // {Course name}/{H5P activity name}/{My attempts}/{Attempt X}/data.json
+        // where X is the attempt number.
+        array_walk($alldata, function($attemptsdata, $contextid) {
+            $context = \context::instance_by_id($contextid);
+            array_walk($attemptsdata, function($data, $attempt) use ($context) {
+                $subcontext = [
+                    get_string('myattempts', 'mod_h5pactivity'),
+                    get_string('attempt', 'mod_h5pactivity'). " $attempt"
+                ];
+                writer::with_context($context)->export_data(
+                    $subcontext,
+                    (object)['results' => $data]
+                );
+            });
+        });
+    }
+
+    /**
+     * Delete all user data which matches the specified context.
+     *
+     * @param context $context A user context.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        // This should not happen, but just in case.
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+
+        $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid);
+        if (!$cm) {
+            // Only h5pactivity module will be handled.
+            return;
+        }
+
+        self::delete_all_attempts($cm);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+
+        foreach ($contextlist as $context) {
+            if ($context->contextlevel != CONTEXT_MODULE) {
+                continue;
+            }
+
+            $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid);
+            if (!$cm) {
+                // Only h5pactivity module will be handled.
+                continue;
+            }
+
+            $user = $contextlist->get_user();
+
+            self::delete_all_attempts($cm, $user);
+        }
+    }
+
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+
+        $context = $userlist->get_context();
+
+        if (!is_a($context, \context_module::class)) {
+            return;
+        }
+
+        $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid);
+        if (!$cm) {
+            // Only h5pactivity module will be handled.
+            return;
+        }
+
+        $userids = $userlist->get_userids();
+
+        foreach ($userids as $userid) {
+            self::delete_all_attempts ($cm, (object)['id' => $userid]);
+        }
+    }
+
+    /**
+     * 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
+     */
+    private 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);
+    }
 }
diff --git a/mod/h5pactivity/classes/xapi/handler.php b/mod/h5pactivity/classes/xapi/handler.php
new file mode 100644 (file)
index 0000000..69667de
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The xapi_handler for xAPI statements.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\xapi;
+
+use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\event\statement_received;
+use core_xapi\local\statement;
+use core_xapi\handler as handler_base;
+use core\event\base as event_base;
+use context_module;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class xapi_handler for H5P statements.
+ *
+ * @package mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ */
+class handler extends handler_base {
+
+    /**
+     * Convert a statement object into a Moodle xAPI Event.
+     *
+     * If a statement is accepted by the xAPI webservice the component must provide
+     * an event to handle that statement, otherwise the statement will be rejected.
+     *
+     * @param statement $statement
+     * @return core\event\base|null a Moodle event to trigger
+     */
+    public function statement_to_event(statement $statement): ?event_base {
+
+        // Only process statements with results.
+        $xapiresult = $statement->get_result();
+        if (empty($xapiresult)) {
+            return null;
+        }
+
+        // Statements can contain any verb, for security reasons each
+        // plugin needs to filter it's own specific verbs. For now the only verbs the H5P
+        // plugin keeps track on are "answered" and "completed" because they are realted to grading.
+        // In the future this list can be increased to track more user interactions.
+        $validvalues = [
+                'http://adlnet.gov/expapi/verbs/answered',
+                'http://adlnet.gov/expapi/verbs/completed',
+            ];
+        $xapiverbid = $statement->get_verb_id();
+        if (!in_array($xapiverbid, $validvalues)) {
+            return null;
+        }
+
+        // Validate object.
+        $xapiobject = $statement->get_activity_id();
+
+        // H5P add some extra params to ID to define subcontents.
+        $parts = explode('?', $xapiobject, 2);
+        $contextid = array_shift($parts);
+        $subcontent = str_replace('subContentId=', '', array_shift($parts));
+        if (empty($contextid) || !is_numeric($contextid)) {
+            return null;
+        }
+        $context = \context::instance_by_id($contextid);
+        if (!$context instanceof \context_module) {
+            return null;
+        }
+
+        // As the activity does not accept group statement, the code can assume that the
+        // statement user is valid (otherwise the xAPI library will reject the statement).
+        $user = $statement->get_user();
+        if (!has_capability('mod/h5pactivity:view', $context, $user)) {
+            return null;
+        }
+        if (!has_capability('mod/h5pactivity:submit', $context, $user, false)) {
+            return null;
+        }
+
+        $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid, 0, false);
+        if (!$cm) {
+            return null;
+        }
+
+        // For now, attempts are only processed on a single batch starting with the final "completed"
+        // and "answered" statements (this could change in the future). This initial statement have no
+        // subcontent defined as they are the main finishing statement. For this reason, this statement
+        // indicates a new attempt creation. This way, simpler H5P activies like multichoice can generate
+        // an attempt each time the user answers while complex like question-set could group all questions
+        // in a single attempt (using subcontents).
+        if (empty($subcontent)) {
+            $attempt = attempt::new_attempt($user, $cm);
+        } else {
+            $attempt = attempt::last_attempt($user, $cm);
+        }
+        if (!$attempt) {
+            return null;
+        }
+        $result = $attempt->save_statement($statement, $subcontent);
+        if (!$result) {
+            return null;
+        }
+
+        // TODO: update grading if necessary.
+
+        // Convert into a Moodle event.
+        $minstatement = $statement->minify();
+        $params = [
+            'other' => $minstatement,
+            'context' => $context,
+            'objectid' => $cm->instance,
+            'userid' => $user->id,
+        ];
+        return statement_received::create($params);
+    }
+}
index 26b21b3..cef7aba 100644 (file)
@@ -48,4 +48,12 @@ $capabilities = [
         ],
         'clonepermissionsfrom' => 'moodle/course:manageactivities',
     ],
+
+    'mod/h5pactivity:submit' => [
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => [
+            'student' => CAP_ALLOW
+        ],
+    ],
 ];
index a2461ef..ac1fbde 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/h5pactivity/db" VERSION="20200227" COMMENT="XMLDB file for Moodle mod_h5pactivity"
+<XMLDB PATH="mod/h5pactivity/db" VERSION="20200410" COMMENT="XMLDB file for Moodle mod_h5pactivity"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -9,8 +9,8 @@
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="course" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="ID of the course this activity is part of."/>
         <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" COMMENT="The name of the activity module instance"/>
-        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Timestamp of when the instance was added to the course."/>
-        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Timestamp of when the instance was last modified."/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when the instance was added to the course."/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when the instance was last modified."/>
         <FIELD NAME="intro" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Activity description."/>
         <FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The format of the intro field."/>
         <FIELD NAME="grade" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <KEY NAME="fk_course" TYPE="foreign" FIELDS="course" REFTABLE="course" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="h5pactivity_attempts" COMMENT="Users attempts inside H5P activities">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="h5pactivityid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="H5P activity ID"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="20" NOTNULL="true" SEQUENCE="false" COMMENT="Attempt user ID"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="attempt" TYPE="int" LENGTH="6" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Attempt number"/>
+        <FIELD NAME="rawscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="maxscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="fk_h5pactivityid" TYPE="foreign" FIELDS="h5pactivityid" REFTABLE="h5pactivity" REFFIELDS="id"/>
+        <KEY NAME="uq_activityuserattempt" TYPE="unique" FIELDS="h5pactivityid, userid, attempt" COMMENT="Ensure a user cannot repeat the same attempt on the same activity"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="timecreated" UNIQUE="false" FIELDS="timecreated"/>
+        <INDEX NAME="h5pactivityid-timecreated" UNIQUE="false" FIELDS="h5pactivityid, timecreated"/>
+        <INDEX NAME="h5pactivityid-userid" UNIQUE="false" FIELDS="h5pactivityid, userid"/>
+      </INDEXES>
+    </TABLE>
+    <TABLE NAME="h5pactivity_attempts_results" COMMENT="H5Pactivities_attempts tracking info">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="attemptid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="h5pactivity_attempts ID"/>
+        <FIELD NAME="subcontent" TYPE="char" LENGTH="128" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="interactiontype" TYPE="char" LENGTH="128" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="description" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="correctpattern" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Correct Pattern in xAPI format"/>
+        <FIELD NAME="response" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="User response data in xAPI format"/>
+        <FIELD NAME="additionals" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Extra subcontent information in JSON format"/>
+        <FIELD NAME="rawscore" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="maxscore" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="fk_attemptid" TYPE="foreign" FIELDS="attemptid" REFTABLE="h5pactivity_attempts" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="attemptid-timecreated" UNIQUE="false" FIELDS="attemptid, timecreated"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
-</XMLDB>
+</XMLDB>
\ No newline at end of file
diff --git a/mod/h5pactivity/db/upgrade.php b/mod/h5pactivity/db/upgrade.php
new file mode 100644 (file)
index 0000000..198cf66
--- /dev/null
@@ -0,0 +1,138 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file keeps track of upgrades to the h5pactivity module
+ *
+ * Sometimes, changes between versions involve
+ * alterations to database structures and other
+ * major things that may break installations.
+ *
+ * The upgrade function in this file will attempt
+ * to perform all the necessary actions to upgrade
+ * your older installation to the current version.
+ *
+ * If there's something it cannot do itself, it
+ * will tell you what you need to do.
+ *
+ * The commands in here will all be database-neutral,
+ * using the methods of database_manager class
+ *
+ * Please do not forget to use upgrade_set_timeout()
+ * before any action that may take longer time to finish.
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * H5P activity module upgrade.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2017 Stephen Bourget
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Function to upgrade mod_h5pactivity.
+ * @param int $oldversion the version we are upgrading from
+ * @return bool result
+ */
+function xmldb_h5pactivity_upgrade($oldversion) {
+    global $DB;
+
+    $dbman = $DB->get_manager(); // Loads ddl manager and xmldb classes.
+
+    if ($oldversion < 2020032300) {
+
+        // Changing the default of field timecreated on table h5pactivity to drop it.
+        $table = new xmldb_table('h5pactivity');
+        $field = new xmldb_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'name');
+
+        // Launch change of default for field timecreated.
+        $dbman->change_field_default($table, $field);
+
+        // Changing the default of field timemodified on table h5pactivity to drop it.
+        $field = new xmldb_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null, 'timecreated');
+
+        // Launch change of default for field timemodified.
+        $dbman->change_field_default($table, $field);
+
+        // Define table h5pactivity_attempts to be created.
+        $table = new xmldb_table('h5pactivity_attempts');
+
+        // Adding fields to table h5pactivity_attempts.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('h5pactivityid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '20', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('attempt', XMLDB_TYPE_INTEGER, '6', null, XMLDB_NOTNULL, null, '1');
+        $table->add_field('rawscore', XMLDB_TYPE_INTEGER, '10', null, null, null, '0');
+        $table->add_field('maxscore', XMLDB_TYPE_INTEGER, '10', null, null, null, '0');
+
+        // Adding keys to table h5pactivity_attempts.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('fk_h5pactivityid', XMLDB_KEY_FOREIGN, ['h5pactivityid'], 'h5pactivity', ['id']);
+        $table->add_key('uq_activityuserattempt', XMLDB_KEY_UNIQUE, ['h5pactivityid', 'userid', 'attempt']);
+
+        // Adding indexes to table h5pactivity_attempts.
+        $table->add_index('timecreated', XMLDB_INDEX_NOTUNIQUE, ['timecreated']);
+        $table->add_index('h5pactivityid-timecreated', XMLDB_INDEX_NOTUNIQUE, ['h5pactivityid', 'timecreated']);
+        $table->add_index('h5pactivityid-userid', XMLDB_INDEX_NOTUNIQUE, ['h5pactivityid', 'userid']);
+
+        // Conditionally launch create table for h5pactivity_attempts.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Define table h5pactivity_attempts_results to be created.
+        $table = new xmldb_table('h5pactivity_attempts_results');
+
+        // Adding fields to table h5pactivity_attempts_results.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('attemptid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('subcontent', XMLDB_TYPE_CHAR, '128', null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('interactiontype', XMLDB_TYPE_CHAR, '128', null, null, null, null);
+        $table->add_field('description', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('correctpattern', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('response', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('additionals', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('rawscore', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('maxscore', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+
+        // Adding keys to table h5pactivity_attempts_results.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('fk_attemptid', XMLDB_KEY_FOREIGN, ['attemptid'], 'h5pactivity_attempts', ['id']);
+
+        // Adding indexes to table h5pactivity_attempts_results.
+        $table->add_index('attemptid-timecreated', XMLDB_INDEX_NOTUNIQUE, ['attemptid', 'timecreated']);
+
+        // Conditionally launch create table for h5pactivity_attempts_results.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // H5pactivity savepoint reached.
+        upgrade_mod_savepoint(true, 2020032300, 'h5pactivity');
+    }
+
+    return true;
+}
index 73e5489..a20f998 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 $string['areapackage'] = 'Package file';
+$string['attempt'] = 'Attempt';
+$string['deleteallattempts'] = 'Delete all H5P attempts';
 $string['displayexport'] = 'Allow download';
 $string['displayembed'] = 'Embed button';
 $string['displaycopyright'] = 'Copyright button';
 $string['h5pactivity:addinstance'] = 'Add a new H5P';
+$string['h5pactivity:submit'] = 'Submit H5P attempts';
 $string['h5pactivity:view'] = 'View H5P';
 $string['h5pactivityfieldset'] = 'H5P Settings';
 $string['h5pactivityname'] = 'H5P';
@@ -39,9 +42,19 @@ $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['privacy:metadata'] = 'The H5P activity plugin does not store any personal data.';
+$string['previewmode'] = 'This content is displayed in preview mode. No attempt tracking will be stored.';
+$string['privacy:metadata:attempt'] = 'The attempt number';
+$string['privacy:metadata:rawscore'] = 'The score obtained';
+$string['privacy:metadata:timecreated'] = 'The time when the tracked element was created';
+$string['privacy:metadata:timemodified'] = 'The last time element was tracked';
+$string['privacy:metadata:userid'] = 'The ID of the user who accessed the H5P activity';
+$string['privacy:metadata:xapi_track'] = 'Attempt tracking information';
+$string['privacy:metadata:xapi_track_results'] = 'Attempt results tracking information';
+$string['statement_received'] = 'xAPI statement received';
+$string['view'] = 'View';
index 1ebf7ba..914d25d 100644 (file)
@@ -80,6 +80,7 @@ function h5pactivity_add_instance(stdClass $data, mod_h5pactivity_mod_form $mfor
     global $DB;
 
     $data->timecreated = time();
+    $data->timemodified = $data->timecreated;
     $cmid = $data->coursemodule;
 
     $data->id = $DB->insert_record('h5pactivity', $data);
@@ -229,18 +230,65 @@ function h5pactivity_update_grades(stdClass $moduleinstance, int $userid = 0): v
             'h5pactivity', $moduleinstance->id, 0, $grades);
 }
 
+/**
+ * Implementation of the function for printing the form elements that control
+ * whether the course reset functionality affects the H5P activity.
+ *
+ * @param object $mform form passed by reference
+ */
+function h5pactivity_reset_course_form_definition(&$mform): void {
+    $mform->addElement('header', 'h5pactivityheader', get_string('modulenameplural', 'mod_h5pactivity'));
+    $mform->addElement('advcheckbox', 'reset_h5pactivity', get_string('deleteallattempts', 'mod_h5pactivity'));
+}
+
+/**
+ * Course reset form defaults.
+ *
+ * @param stdClass $course the course object
+ * @return array
+ */
+function h5pactivity_reset_course_form_defaults(stdClass $course): array {
+    return ['reset_h5pactivity' => 1];
+}
+
+
 /**
  * This function is used by the reset_course_userdata function in moodlelib.
- * This function will remove all assignment submissions and feedbacks in the database
+ *
+ * This function will remove all H5P attempts in the database
  * and clean up any related data.
  *
  * @param stdClass $data the data submitted from the reset course.
- * @return array
+ * @return array of reseting status
  */
-function h5pactivity_reset_userdata($data) {
+function h5pactivity_reset_userdata(stdClass $data): array {
     global $CFG, $DB;
-    // TODO: When attempts are created this function will remove them.
-    return [];
+    $componentstr = get_string('modulenameplural', 'mod_h5pactivity');
+    $status = [];
+    if (!empty($data->reset_h5pactivity)) {
+        $params = ['courseid' => $data->courseid];
+        $sql = "SELECT a.id FROM {h5pactivity} a WHERE a.course=:courseid";
+        if ($activities = $DB->get_records_sql($sql, $params)) {
+            foreach ($activities as $activity) {
+                $cm = get_coursemodule_from_instance('h5pactivity',
+                                                     $activity->id,
+                                                     $data->courseid,
+                                                     false,
+                                                     MUST_EXIST);
+                mod_h5pactivity\local\attempt::delete_all_attempts ($cm);
+            }
+        }
+        // Remove all grades from gradebook.
+        if (empty($data->reset_gradebook_grades)) {
+            h5pactivity_reset_gradebook($data->courseid, 'reset');
+        }
+        $status[] = [
+            'component' => $componentstr,
+            'item' => get_string('deleteallattempts', 'mod_h5pactivity'),
+            'error' => false,
+        ];
+    }
+    return $status;
 }
 
 /**
@@ -254,7 +302,7 @@ function h5pactivity_reset_gradebook(int $courseid, string $type=''): void {
 
     $sql = "SELECT a.*, cm.idnumber as cmidnumber, a.course as courseid
               FROM {h5pactivity} a, {course_modules} cm, {modules} m
-             WHERE m.name='h5pactivity' AND m.id=cm.module AND cm.instance=s.id AND s.course=?";
+             WHERE m.name='h5pactivity' AND m.id=cm.module AND cm.instance=a.id AND a.course=?";
 
     if ($activities = $DB->get_records_sql($sql, [$courseid])) {
         foreach ($activities as $activity) {
index b7d5c00..ab92d1e 100644 (file)
@@ -20,7 +20,6 @@ Feature: Add H5P activity
     And I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
 
-
   @javascript
   Scenario: Add a h5pactivity activity to a course
     When I add a "H5P activity" to section "1"
diff --git a/mod/h5pactivity/tests/behat/sending_attempt.feature b/mod/h5pactivity/tests/behat/sending_attempt.feature
new file mode 100644 (file)
index 0000000..d6e35f1
--- /dev/null
@@ -0,0 +1,56 @@
+@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe
+Feature: Do a H5P attempt
+  In order to let students do a H5P attempt
+  As a teacher
+  I need to list students attempts on the log report
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And the following "permission overrides" exist:
+      | capability                 | permission | role           | contextlevel | reference |
+      | moodle/h5p:updatelibraries | Allow      | editingteacher | System       |           |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "H5P activity" to section "1"
+    And I set the following fields to these values:
+      | Name        | Awesome H5P package |
+      | Description | Description         |
+    And I upload "h5p/tests/fixtures/multiple-choice-2-6.h5p" file to "Package file" filemanager
+
+  @javascript
+  Scenario: View an H5P as a teacher
+    When I click on "Save and display" "button"
+    And I wait until the page is ready
+    Then I should see "This content is displayed in preview mode"
+
+  @javascript
+  Scenario: To an attempts and check on course log report
+    When I click on "Save and return to course" "button"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I wait until the page is ready
+    And I should not see "This content is displayed in preview mode"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I click on "Correct one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I switch to the main frame
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I follow "Student 1"
+    Then I follow "Today's logs"
+    And I should see "xAPI statement received"
diff --git a/mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php b/mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php
new file mode 100644 (file)
index 0000000..c381eea
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Events test.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+use advanced_testcase;
+use context_course;
+use context_module;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * H5P activity events test cases.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_module_instance_list_viewed_testcase extends advanced_testcase {
+
+    /**
+     * Test course_module_instance_list_viewed event.
+     */
+    public function test_course_module_instance_list_viewed() {
+        // There is no proper API to call to trigger this event, so what we are
+        // doing here is simply making sure that the events returns the right information.
+
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $params = [
+            'context' => context_course::instance($course->id)
+        ];
+        $event = course_module_instance_list_viewed::create($params);
+
+        // Triggering and capturing the event.
+        $sink = $this->redirectEvents();
+        $event->trigger();
+        $events = $sink->get_events();
+        $this->assertCount(1, $events);
+        $event = reset($events);
+
+        // Checking that the event contains the expected values.
+        $this->assertInstanceOf('\mod_h5pactivity\event\course_module_instance_list_viewed', $event);
+        $this->assertEquals(context_course::instance($course->id), $event->get_context());
+        $expected = [$course->id, 'h5pactivity', 'view all', 'index.php?id='.$course->id, ''];
+        $this->assertEventLegacyLogData($expected, $event);
+        $this->assertEventContextNotUsed($event);
+    }
+}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+namespace mod_h5pactivity\event;
+
+use advanced_testcase;
+use context_course;
+use context_module;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -31,45 +37,7 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2020 Ferran Recio <ferran@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class mod_h5pactivity_events_testcase extends advanced_testcase {
-
-    /**
-     * Setup is called before calling test case.
-     */
-    public function setUp() {
-        // Must be a non-guest user to create h5pactivities.
-        $this->setAdminUser();
-    }
-
-    /**
-     * Test course_module_instance_list_viewed event.
-     */
-    public function test_course_module_instance_list_viewed() {
-        // There is no proper API to call to trigger this event, so what we are
-        // doing here is simply making sure that the events returns the right information.
-
-        $this->resetAfterTest();
-
-        $course = $this->getDataGenerator()->create_course();
-        $params = [
-            'context' => context_course::instance($course->id)
-        ];
-        $event = \mod_h5pactivity\event\course_module_instance_list_viewed::create($params);
-
-        // Triggering and capturing the event.
-        $sink = $this->redirectEvents();
-        $event->trigger();
-        $events = $sink->get_events();
-        $this->assertCount(1, $events);
-        $event = reset($events);
-
-        // Checking that the event contains the expected values.
-        $this->assertInstanceOf('\mod_h5pactivity\event\course_module_instance_list_viewed', $event);
-        $this->assertEquals(context_course::instance($course->id), $event->get_context());
-        $expected = [$course->id, 'h5pactivity', 'view all', 'index.php?id='.$course->id, ''];
-        $this->assertEventLegacyLogData($expected, $event);
-        $this->assertEventContextNotUsed($event);
-    }
+class course_module_viewed_testcase extends advanced_testcase {
 
     /**
      * Test course_module_viewed event.
@@ -80,6 +48,8 @@ class mod_h5pactivity_events_testcase extends advanced_testcase {
 
         $this->resetAfterTest();
 
+        $this->setAdminUser();
+
         $course = $this->getDataGenerator()->create_course();
         $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course->id]);
 
@@ -87,7 +57,7 @@ class mod_h5pactivity_events_testcase extends advanced_testcase {
             'context' => context_module::instance($activity->cmid),
             'objectid' => $activity->id
         ];
-        $event = \mod_h5pactivity\event\course_module_viewed::create($params);
+        $event = course_module_viewed::create($params);
 
         // Triggering and capturing the event.
         $sink = $this->redirectEvents();
diff --git a/mod/h5pactivity/tests/event/statement_received_test.php b/mod/h5pactivity/tests/event/statement_received_test.php
new file mode 100644 (file)
index 0000000..1769460
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Events test.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+use advanced_testcase;
+use context_course;
+use context_module;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * H5P activity events test cases.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class statement_received_testcase extends advanced_testcase {
+
+    /**
+     * Test course_module_viewed event.
+     */
+    public function test_statement_received() {
+        global $USER;
+
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Must be a non-guest user to create h5pactivities.
+        $this->setAdminUser();
+
+        // There is no proper API to call to trigger this event, so what we are
+        // doing here is simply making sure that the events returns the right information.
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course->id]);
+
+        $params = [
+            'context' => context_module::instance($activity->cmid),
+            'objectid' => $activity->id
+        ];
+        $event = statement_received::create($params);
+
+        // Triggering and capturing the event.
+        $sink = $this->redirectEvents();
+        $event->trigger();
+        $events = $sink->get_events();
+        $this->assertCount(1, $events);
+        $event = reset($events);
+
+        // Checking that the event contains the expected values.
+        $this->assertInstanceOf('\mod_h5pactivity\event\statement_received', $event);
+        $this->assertEquals(context_module::instance($activity->cmid), $event->get_context());
+        $this->assertEquals($activity->id, $event->objectid);
+        $expected = [$course->id, 'h5pactivity', 'statement received',
+            'grade.php?user=' . $USER->id, 0, $activity->cmid];
+        $this->assertEventLegacyLogData($expected, $event);
+        $this->assertEventContextNotUsed($event);
+    }
+}
index 0bf6f92..cbcf84d 100644 (file)
@@ -45,7 +45,7 @@ class mod_h5pactivity_generator extends testing_module_generator {
      * @return stdClass record from module-defined table with additional field
      *     cmid (corresponding id in course_modules table)
      */
-    public function create_instance($record = null, array $options = null) {
+    public function create_instance($record = null, array $options = null): stdClass {
         global $CFG, $USER;
         // Ensure the record can be modified without affecting calling code.
         $record = (object)(array)$record;
@@ -67,7 +67,7 @@ class mod_h5pactivity_generator extends testing_module_generator {
         // The 'packagefile' value corresponds to the draft file area ID. If not specified, create from packagefilepath.
         if (empty($record->packagefile)) {
             if (!isloggedin() || isguestuser()) {
-                throw new coding_exception('Scorm generator requires a current user');
+                throw new coding_exception('H5P activity generator requires a current user');
             }
             if (!file_exists($record->packagefilepath)) {
                 throw new coding_exception("File {$record->packagefilepath} does not exist");
@@ -88,4 +88,76 @@ class mod_h5pactivity_generator extends testing_module_generator {
         // Do work to actually add the instance.
         return parent::create_instance($record, (array)$options);
     }
+
+    /**
+     * Creata a fake attempt
+     * @param stdClass $instance object returned from create_instance() call
+     * @param stdClass|array $record
+     * @return stdClass generated object
+     * @throws coding_exception if function is not implemented by module
+     */
+    public function create_content($instance, $record = []) {
+        global $DB, $USER;
+
+        $currenttime = time();
+        $cmid = $record['cmid'];
+        $userid = $record['userid'] ?? $USER->id;
+        $conditions = ['h5pactivityid' => $instance->id, 'userid' => $userid];
+        $attemptnum = $DB->count_records('h5pactivity_attempts', $conditions) + 1;
+        $attempt = (object)[
+                'h5pactivityid' => $instance->id,
+                'userid' => $userid,
+                'timecreated' => $currenttime,
+                'timemodified' => $currenttime,
+                'attempt' => $attemptnum,
+                'rawscore' => 3,
+                'maxscore' => 5,
+            ];
+        $attempt->id = $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        // Create 3 diferent tracking results.
+        $result = (object)[
+                'attemptid' => $attempt->id,
+                'subcontent' => '',
+                'timecreated' => $currenttime,
+                'interactiontype' => 'compound',
+                'description' => 'description for '.$userid,
+                'correctpattern' => '',
+                'response' => '',
+                'additionals' => '{"extensions":{"http:\/\/h5p.org\/x-api\/h5p-local-content-id":'.
+                        $cmid.'},"contextExtensions":{}}',
+                'rawscore' => 3,
+                'maxscore' => 5,
+            ];
+        $DB->insert_record('h5pactivity_attempts_results', $result);
+
+        $result->subcontent = 'bd03477a-90a1-486d-890b-0657d6e80ffd';
+        $result->interactiontype = 'compound';
+        $result->response = '0[,]5[,]2[,]3';
+        $result->additionals = '{"choices":[{"id":"0","description":{"en-US":"Blueberry\n"}},'.
+                '{"id":"1","description":{"en-US":"Raspberry\n"}},{"id":"5","description":'.
+                '{"en-US":"Strawberry\n"}},{"id":"2","description":{"en-US":"Cloudberry\n"}},'.
+                '{"id":"3","description":{"en-US":"Halle Berry\n"}},'.
+                '{"id":"4","description":{"en-US":"Cocktail cherry\n"}}],'.
+                '"extensions":{"http:\/\/h5p.org\/x-api\/h5p-local-content-id":'.$cmid.
+                ',"http:\/\/h5p.org\/x-api\/h5p-subContentId":"'.$result->interactiontype.
+                '"},"contextExtensions":{}}';
+        $result->rawscore = 1;
+        $DB->insert_record('h5pactivity_attempts_results', $result);
+
+        $result->subcontent = '14fcc986-728b-47f3-915b-'.$userid;
+        $result->interactiontype = 'matching';
+        $result->response = '1[.]0[,]0[.]1[,]2[.]2';
+        $result->additionals = '{"source":[{"id":"0","description":{"en-US":"A berry"}}'.
+                ',{"id":"1","description":{"en-US":"An orange berry"}},'.
+                '{"id":"2","description":{"en-US":"A red berry"}}],'.
+                '"target":[{"id":"0","description":{"en-US":"Cloudberry"}},'.
+                '{"id":"1","description":{"en-US":"Blueberry"}},'.
+                '{"id":"2","description":{"en-US":"Redcurrant\n"}}],'.
+                '"contextExtensions":{}}';
+        $result->rawscore = 2;
+        $DB->insert_record('h5pactivity_attempts_results', $result);
+
+        return $attempt;
+    }
 }
diff --git a/mod/h5pactivity/tests/local/attempt_test.php b/mod/h5pactivity/tests/local/attempt_test.php
new file mode 100644 (file)
index 0000000..b414806
--- /dev/null
@@ -0,0 +1,346 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * mod_h5pactivity generator tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use \core_xapi\local\statement;
+use \core_xapi\local\statement\item;
+use \core_xapi\local\statement\item_agent;
+use \core_xapi\local\statement\item_activity;
+use \core_xapi\local\statement\item_definition;
+use \core_xapi\local\statement\item_verb;
+use stdClass;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Attempt tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class attempt_testcase extends \advanced_testcase {
+
+    /**
+     * Generate a scenario to run all tests.
+     * @return array course_modules, user record, course record
+     */
+    private function generate_testing_scenario(): array {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        return [$cm, $student, $course];
+    }
+
+    /**
+     * Test for create_attempt method.
+     */
+    public function test_create_attempt() {
+
+        list($cm, $student) = $this->generate_testing_scenario();
+
+        // Create first attempt.
+        $attempt = attempt::new_attempt($student, $cm);
+        $this->assertEquals($student->id, $attempt->get_userid());
+        $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+        $this->assertEquals(1, $attempt->get_attempt());
+
+        // Create a second attempt.
+        $attempt = attempt::new_attempt($student, $cm);
+        $this->assertEquals($student->id, $attempt->get_userid());
+        $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+        $this->assertEquals(2, $attempt->get_attempt());
+    }
+
+    /**
+     * Test for last_attempt method
+     */
+    public function test_last_attempt() {
+
+        list($cm, $student) = $this->generate_testing_scenario();
+
+        // Create first attempt.
+        $attempt = attempt::last_attempt($student, $cm);
+        $this->assertEquals($student->id, $attempt->get_userid());
+        $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+        $this->assertEquals(1, $attempt->get_attempt());
+        $lastid = $attempt->get_id();
+
+        // Get last attempt.
+        $attempt = attempt::last_attempt($student, $cm);
+        $this->assertEquals($student->id, $attempt->get_userid());
+        $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+        $this->assertEquals(1, $attempt->get_attempt());
+        $this->assertEquals($lastid, $attempt->get_id());
+
+        // Now force a new attempt.
+        $attempt = attempt::new_attempt($student, $cm);
+        $this->assertEquals($student->id, $attempt->get_userid());
+        $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+        $this->assertEquals(2, $attempt->get_attempt());
+        $lastid = $attempt->get_id();
+
+        // Get last attempt.
+        $attempt = attempt::last_attempt($student, $cm);
+        $this->assertEquals($student->id, $attempt->get_userid());
+        $this->assertEquals($cm->instance, $attempt->get_h5pactivityid());
+        $this->assertEquals(2, $attempt->get_attempt());
+        $this->assertEquals($lastid, $attempt->get_id());
+    }
+
+    /**
+     * Test saving statements.
+     *
+     * @dataProvider save_statement_data
+     * @param string $subcontent subcontent identifier
+     * @param bool $hasdefinition generate definition
+     * @param bool $hasresult generate result
+     * @param array $results 0 => insert ok, 1 => maxscore, 2 => rawscore, 3 => count
+     */
+    public function test_save_statement(string $subcontent, bool $hasdefinition, bool $hasresult, array $results) {
+
+        list($cm, $student) = $this->generate_testing_scenario();
+
+        $attempt = attempt::new_attempt($student, $cm);
+        $this->assertEquals(0, $attempt->get_maxscore());
+        $this->assertEquals(0, $attempt->get_rawscore());
+        $this->assertEquals(0, $attempt->count_results());
+
+        $statement = $this->generate_statement($hasdefinition, $hasresult);
+        $result = $attempt->save_statement($statement, $subcontent);
+        $this->assertEquals($results[0], $result);
+        $this->assertEquals($results[1], $attempt->get_maxscore());
+        $this->assertEquals($results[2], $attempt->get_rawscore());
+        $this->assertEquals($results[3], $attempt->count_results());
+    }
+
+    /**
+     * Data provider for data request creation tests.
+     *
+     * @return array
+     */
+    public function save_statement_data(): array {
+        return [
+            'Statement without definition and result' => [
+                '', false, false, [false, 0, 0, 0]
+            ],
+            'Statement with definition but no result' => [
+                '', true, false, [false, 0, 0, 0]
+            ],
+            'Statement with result but no definition' => [
+                '', true, false, [false, 0, 0, 0]
+            ],
+            'Statement subcontent without definition and result' => [
+                '111-222-333', false, false, [false, 0, 0, 0]
+            ],
+            'Statement subcontent with definition but no result' => [
+                '111-222-333', true, false, [false, 0, 0, 0]
+            ],
+            'Statement subcontent with result but no definition' => [
+                '111-222-333', true, false, [false, 0, 0, 0]
+            ],
+            'Statement with definition, result but no subcontent' => [
+                '', true, true, [true, 2, 2, 1]
+            ],
+            'Statement with definition, result and subcontent' => [
+                '111-222-333', true, true, [true, 0, 0, 1]
+            ],
+        ];
+    }
+
+    /**
+     * Test delete results from attempt.
+     */
+    public function test_delete_results() {
+
+        list($cm, $student) = $this->generate_testing_scenario();
+
+        $attempt = $this->generate_full_attempt($student, $cm);
+        $attempt->delete_results();
+        $this->assertEquals(0, $attempt->count_results());
+    }
+
+    /**
+     * Test delete attempt.
+     */
+    public function test_delete_attempt() {
+        global $DB;
+
+        list($cm, $student) = $this->generate_testing_scenario();
+
+        // Check no previous attempts are created.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(0, $count);
+
+        // Generate one attempt.
+        $attempt1 = $this->generate_full_attempt($student, $cm);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(1, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(2, $count);
+
+        // Generate a second attempt.
+        $attempt2 = $this->generate_full_attempt($student, $cm);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(4, $count);
+
+        // Delete the first attempt.
+        attempt::delete_attempt($attempt1);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(1, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(2, $count);
+        $this->assertEquals(2, $attempt2->count_results());
+    }
+
+    /**
+     * Test delete all attempts.
+     *
+     * @dataProvider delete_all_attempts_data
+     * @param bool $hasstudent if user is specificed
+     * @param int[] 0-3 => statements count results, 4-5 => totals
+     */
+    public function test_delete_all_attempts(bool $hasstudent, array $results) {
+        global $DB;
+
+        list($cm, $student, $course) = $this->generate_testing_scenario();
+
+        // For this test we need extra activity and student.
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm2 = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Check no previous attempts are created.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(0, $count);
+
+        // Generate some attempts attempt on both activities and students.
+        $attempts = [];
+        $attempts[] = $this->generate_full_attempt($student, $cm);
+        $attempts[] = $this->generate_full_attempt($student2, $cm);
+        $attempts[] = $this->generate_full_attempt($student, $cm2);
+        $attempts[] = $this->generate_full_attempt($student2, $cm2);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(4, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(8, $count);
+
+        // Delete all specified attempts.
+        $user = ($hasstudent) ? $student : null;
+        attempt::delete_all_attempts($cm, $user);
+
+        // Check data.
+        for ($i = 0; $i < 4; $i++) {
+            $count = $attempts[$i]->count_results();
+            $this->assertEquals($results[$i], $count);
+        }
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals($results[4], $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals($results[5], $count);
+    }
+
+    /**
+     * Data provider for data request creation tests.
+     *
+     * @return array
+     */
+    public function delete_all_attempts_data(): array {
+        return [
+            'Delete all attempts from activity' => [
+                false, [0, 0, 2, 2, 2, 4]
+            ],
+            'Delete all attempts from user' => [
+                true, [0, 2, 2, 2, 3, 6]
+            ],
+        ];
+    }
+
+    /**
+     * Generate a fake attempt with two results.
+     *
+     * @param stdClass $student a user record
+     * @param stdClass $cm a course_module record
+     * @return attempt
+     */
+    private function generate_full_attempt($student, $cm): attempt {
+        $attempt = attempt::new_attempt($student, $cm);
+        $this->assertEquals(0, $attempt->get_maxscore());
+        $this->assertEquals(0, $attempt->get_rawscore());
+        $this->assertEquals(0, $attempt->count_results());
+
+        $statement = $this->generate_statement(true, true);
+        $saveok = $attempt->save_statement($statement, '');
+        $this->assertTrue($saveok);
+        $saveok = $attempt->save_statement($statement, '111-222-333');
+        $this->assertTrue($saveok);
+        $this->assertEquals(2, $attempt->count_results());
+
+        return $attempt;
+    }
+
+    /**
+     * Return a xAPI partial statement with object defined.
+     * @param bool $hasdefinition if has to include definition
+     * @param bool $hasresult if has to include results
+     * @return statement
+     */
+    private function generate_statement(bool $hasdefinition, bool $hasresult): statement {
+        global $USER;
+
+        $statement = new statement();
+        $statement->set_actor(item_agent::create_from_user($USER));
+        $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+        $definition = null;
+        if ($hasdefinition) {
+            $definition = item_definition::create_from_data((object)[
+                'interactionType' => 'compound',
+                'correctResponsesPattern' => '1',
+            ]);
+        }
+        $statement->set_object(item_activity::create_from_id('something', $definition));
+        if ($hasresult) {
+            $statement->set_result(item::create_from_data((object)[
+                'completion' => true,
+                'success' => true,
+                'score' => (object) ['min' => 0, 'max' => 2, 'raw' => 2, 'scaled' => 1],
+            ]));
+        }
+        return $statement;
+    }
+}
diff --git a/mod/h5pactivity/tests/privacy_test.php b/mod/h5pactivity/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..3359618
--- /dev/null
@@ -0,0 +1,333 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * mod_h5pactivity privacy tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\approved_userlist;
+use \core_privacy\local\request\writer;
+use \core_privacy\tests\provider_testcase;
+
+/**
+ * Privacy tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_h5pactivity_privacy_testcase extends provider_testcase {
+
+    /** @var stdClass User without any attempt. */
+    protected $student0;
+
+    /** @var stdClass User with some attempt. */
+    protected $student1;
+
+    /** @var stdClass User with some attempt. */
+    protected $student2;
+
+    /** @var context context_module of the H5P activity. */
+    protected $context;
+
+    /**
+     * Test getting the context for the user ID related to this plugin.
+     */
+    public function test_get_contexts_for_userid() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $this->h5pactivity_setup_test_scenario_data();
+
+        // The student0 hasn't any attempt.
+        $contextlist = provider::get_contexts_for_userid($this->student0->id);
+        $this->assertCount(0, (array) $contextlist->get_contextids());
+
+        // The student1 has data in the mod_h5pactivity context.
+        $contextlist = provider::get_contexts_for_userid($this->student1->id);
+        $this->assertCount(1, (array) $contextlist->get_contextids());
+        $this->assertContains($this->context->id, $contextlist->get_contextids());
+    }
+
+    /**
+     * Test getting the user IDs for the context related to this plugin.
+     */
+    public function test_get_users_in_context() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $this->h5pactivity_setup_test_scenario_data();
+        $component = 'mod_h5pactivity';
+
+        $userlist = new \core_privacy\local\request\userlist($this->context, $component);
+        provider::get_users_in_context($userlist);
+
+        // Students 1 and 2 have attempts in the H5P context, student 0 does not.
+        $this->assertCount(2, $userlist);
+
+        $expected = [$this->student1->id, $this->student2->id];
+        $actual = $userlist->get_userids();
+        sort($expected);
+        sort($actual);
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * Test that data is exported correctly for this plugin.
+     */
+    public function test_export_user_data() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $this->h5pactivity_setup_test_scenario_data();
+        $component = 'mod_h5pactivity';
+
+        // Validate exported data for student0 (without any attempt).
+        $this->setUser($this->student0);
+        $writer = writer::with_context($this->context);
+
+        $this->export_context_data_for_user($this->student0->id, $this->context, $component);
+        $subcontextattempt1 = [
+            get_string('myattempts', 'mod_h5pactivity'),
+            get_string('attempt', 'mod_h5pactivity'). " 1"
+        ];
+        $data = $writer->get_data($subcontextattempt1);
+        $this->assertEmpty($data);
+
+        // Validate exported data for student1.
+        writer::reset();
+        $this->setUser($this->student1);
+        $writer = writer::with_context($this->context);
+        $this->assertFalse($writer->has_any_data());
+        $this->export_context_data_for_user($this->student1->id, $this->context, $component);
+
+        $data = $writer->get_data([]);
+        $this->assertEquals('H5P activity 1', $data->name);
+
+        $data = $writer->get_data($subcontextattempt1);
+        $this->assertCount(1, (array) $data);
+        $this->assertCount(3, (array) reset($data));
+        $subcontextattempt2 = [
+            get_string('myattempts', 'mod_h5pactivity'),
+            get_string('attempt', 'mod_h5pactivity'). " 2"
+        ];
+        $data = $writer->get_data($subcontextattempt2);
+        $this->assertCount(3, (array) reset($data));
+        // The student1 has only 1 tracked attempts.
+        $subcontextattempt3 = [
+            get_string('myattempts', 'mod_h5pactivity'),
+            get_string('attempt', 'mod_h5pactivity'). " 3"
+        ];
+        $data = $writer->get_data($subcontextattempt3);
+        $this->assertEmpty($data);
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $this->h5pactivity_setup_test_scenario_data();
+
+        // Before deletion, we should have 4 entries in the attempts table.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(4, $count);
+        // Before deletion, we should have 12 entries in the results table.
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(12, $count);
+
+        // Delete data based on the context.
+        provider::delete_data_for_all_users_in_context($this->context);
+
+        // After deletion, the attempts entries should have been deleted.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(0, $count);
+        // After deletion, the results entries should have been deleted.
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(0, $count);
+    }
+
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $this->h5pactivity_setup_test_scenario_data();
+
+        $params = ['userid' => $this->student1->id];
+
+        // Before deletion, we should have 4 entries in the attempts table.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(4, $count);
+        // Before deletion, we should have 12 entries in the results table.
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(12, $count);
+
+        // Save student1 attempts ids.
+        $attemptsids = $DB->get_records_menu('h5pactivity_attempts', $params, '', 'attempt, id');
+        list($resultselect, $attemptids) = $DB->get_in_or_equal($attemptsids);
+        $resultselect = 'id ' . $resultselect;
+
+        $approvedcontextlist = new approved_contextlist($this->student1, 'h5pactivity', [$this->context->id]);
+        provider::delete_data_for_user($approvedcontextlist);
+
+        // After deletion, the h5pactivity_attempts entries for the first student should have been deleted.
+        $count = $DB->count_records('h5pactivity_attempts', $params);
+        $this->assertEquals(0, $count);
+
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        // After deletion, the results entries for the first student should have been deleted.
+        $count = $DB->count_records_select('h5pactivity_attempts_results', $resultselect, $attemptids);
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(6, $count);
+
+        // Confirm that the h5pactivity hasn't been removed.
+        $h5pactivitycount = $DB->get_records('h5pactivity');
+        $this->assertCount(1, (array) $h5pactivitycount);
+
+        // Delete track for student0 (nothing has to be removed).
+        $approvedcontextlist = new approved_contextlist($this->student0, 'h5pactivity', [$this->context->id]);
+        provider::delete_data_for_user($approvedcontextlist);
+
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(6, $count);
+    }
+
+    /**
+     * Test for provider::delete_data_for_users().
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+        $component = 'mod_h5pactivity';
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        // In this scenario we need a 3rd user to test batch deletion.
+        // Create student2 with 2 attempts.
+        $this->h5pactivity_setup_test_scenario_data(true);
+
+        // Before deletion, we should have 6 entries in the attempts table.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(6, $count);
+        // Before deletion, we should have 18 entries in the results table.
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(18, $count);
+
+        // Save student1 and student2 attempts ids.
+        $params1 = ['userid' => $this->student1->id];
+        $attempts1ids = $DB->get_records_menu('h5pactivity_attempts', $params1, '', 'attempt, id');
+        $params2 = ['userid' => $this->student2->id];
+        $attempts2ids = $DB->get_records_menu('h5pactivity_attempts', $params2, '', 'attempt, id');
+        list($resultselect, $attemptids) = $DB->get_in_or_equal(array_merge($attempts1ids, $attempts2ids));
+        $resultselect = 'id ' . $resultselect;
+
+        // Delete student 1 ans 2 data, retain student 3 data.
+        $approveduserids = [$this->student1->id, $this->student2->id];
+        $approvedlist = new approved_userlist($this->context, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        // After deletion, the h5pactivity_attempts entries for student1 and student2 should have been deleted.
+        $count = $DB->count_records('h5pactivity_attempts', $params1);
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts', $params2);
+        $this->assertEquals(0, $count);
+
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        // After deletion, the results entries for the first and second student should have been deleted.
+        $count = $DB->count_records_select('h5pactivity_attempts_results', $resultselect, $attemptids);
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(6, $count);
+
+        // Confirm that the h5pactivity hasn't been removed.
+        $h5pactivitycount = $DB->get_records('h5pactivity');
+        $this->assertCount(1, (array) $h5pactivitycount);
+
+        // Delete results track for student0 (nothing has to be removed).
+        $approveduserids = [$this->student0->id];
+        $approvedlist = new approved_userlist($this->context, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(6, $count);
+    }
+
+    /**
+     * Helper function to setup 3 users and 2 H5P attempts for student1 and student2.
+     * $this->student0 is always created without any attempt.
+     *
+     * @param bool $extrauser generate a 3rd user (default false).
+     */
+    protected function h5pactivity_setup_test_scenario_data(bool $extrauser = false): void {
+        global $DB;
+
+        $generator = $this->getDataGenerator();
+
+        $course = $this->getDataGenerator()->create_course();
+        $params = ['course' => $course];
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', $params);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $this->context = \context_module::instance($activity->cmid);
+
+        // Users enrolments.
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        // Create student0 withot any attempt.
+        $this->student0 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Create student1 with 2 attempts.
+        $this->student1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $params = ['cmid' => $cm->id, 'userid' => $this->student1->id];
+        $generator->create_content($activity, $params);
+        $generator->create_content($activity, $params);
+
+        // Create student2 with 2 attempts.
+        $this->student2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $params = ['cmid' => $cm->id, 'userid' => $this->student2->id];
+        $generator->create_content($activity, $params);
+        $generator->create_content($activity, $params);
+
+        if ($extrauser) {
+            $this->student3 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+            $params = ['cmid' => $cm->id, 'userid' => $this->student3->id];
+            $generator->create_content($activity, $params);
+            $generator->create_content($activity, $params);
+        }
+    }
+}
diff --git a/mod/h5pactivity/tests/xapi/handler_test.php b/mod/h5pactivity/tests/xapi/handler_test.php
new file mode 100644 (file)
index 0000000..c661e24
--- /dev/null
@@ -0,0 +1,329 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * mod_h5pactivity generator tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\xapi;
+
+use \core_xapi\local\statement;
+use \core_xapi\local\statement\item;
+use \core_xapi\local\statement\item_agent;
+use \core_xapi\local\statement\item_activity;
+use \core_xapi\local\statement\item_definition;
+use \core_xapi\local\statement\item_verb;
+use context_module;
+use stdClass;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Attempt tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class handler_testcase extends \advanced_testcase {
+
+    /**
+     * Generate a valid scenario for each tests.
+     *
+     * @return stdClass an object with all scenario data in it
+     */
+    private function generate_testing_scenario(): stdClass {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $data = new stdClass();
+
+        $data->course = $this->getDataGenerator()->create_course();
+
+        // Generate 2 users, one enroled into course and one not.
+        $data->student = $this->getDataGenerator()->create_and_enrol($data->course, 'student');
+        $data->otheruser = $this->getDataGenerator()->create_user();
+
+        // H5P activity.
+        $data->activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $data->course]);
+        $data->context = context_module::instance($data->activity->cmid);
+
+        $data->xapihandler = handler::create('mod_h5pactivity');
+        $this->assertNotEmpty($data->xapihandler);
+        $this->assertInstanceOf('\mod_h5pactivity\xapi\handler', $data->xapihandler);
+
+        $this->setUser($data->student);
+
+        return $data;
+    }
+
+    /**
+     * Test for xapi_handler with valid statements.
+     */
+    public function test_xapi_handler() {
+        global $DB;
+
+        $data = $this->generate_testing_scenario();
+        $xapihandler = $data->xapihandler;
+        $context = $data->context;
+        $student = $data->student;
+        $otheruser = $data->otheruser;
+
+        // Check we have 0 entries in the attempts tables.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(0, $count);
+
+        $statements = $this->generate_statements($context, $student);
+
+        // Insert first statement.
+        $event = $xapihandler->statement_to_event($statements[0]);
+        $this->assertNotNull($event);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(1, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(1, $count);
+
+        // Insert second statement.
+        $event = $xapihandler->statement_to_event($statements[1]);
+        $this->assertNotNull($event);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(1, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(2, $count);
+
+        // Insert again first statement.
+        $event = $xapihandler->statement_to_event($statements[0]);
+        $this->assertNotNull($event);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(3, $count);
+
+        // Insert again second statement.
+        $event = $xapihandler->statement_to_event($statements[1]);
+        $this->assertNotNull($event);
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(2, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(4, $count);
+    }
+
+    /**
+     * Testing wrong statements scenarios.
+     *
+     * @dataProvider xapi_handler_errors_data
+     * @param bool $hasverb valid verb
+     * @param bool $hasdefinition generate definition
+     * @param bool $hasresult generate result
+     * @param bool $hascontext valid context
+     * @param bool $hasuser valid user
+     * @param bool $generateattempt if generates an empty attempt
+     */
+    public function test_xapi_handler_errors(bool $hasverb, bool $hasdefinition, bool $hasresult,
+            bool $hascontext, bool $hasuser, bool $generateattempt) {
+        global $DB, $CFG;
+
+        $data = $this->generate_testing_scenario();
+        $xapihandler = $data->xapihandler;
+        $context = $data->context;
+        $student = $data->student;
+        $otheruser = $data->otheruser;
+
+        // Check we have 0 entries in the attempts tables.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(0, $count);
+
+        $statement = new statement();
+        if ($hasverb) {
+            $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+        } else {
+            $statement->set_verb(item_verb::create_from_id('cook'));
+        }
+        $definition = null;
+        if ($hasdefinition) {
+            $definition = item_definition::create_from_data((object)[
+                'interactionType' => 'compound',
+                'correctResponsesPattern' => '1',
+            ]);
+        }
+        if ($hascontext) {
+            $statement->set_object(item_activity::create_from_id($context->id, $definition));
+        } else {
+            $statement->set_object(item_activity::create_from_id('paella', $definition));
+        }
+        if ($hasresult) {
+            $statement->set_result(item::create_from_data((object)[
+                'completion' => true,
+                'success' => true,
+                'score' => (object) ['min' => 0, 'max' => 2, 'raw' => 2, 'scaled' => 1],
+            ]));
+        }
+        if ($hasuser) {
+            $statement->set_actor(item_agent::create_from_user($student));
+        } else {
+            $statement->set_actor(item_agent::create_from_user($otheruser));
+        }
+
+        $event = $xapihandler->statement_to_event($statement);
+        $this->assertNull($event);
+        // No enties should be generated.
+        $count = $DB->count_records('h5pactivity_attempts');
+        $attempts = ($generateattempt) ? 1 : 0;
+        $this->assertEquals($attempts, $count);
+        $count = $DB->count_records('h5pactivity_attempts_results');
+        $this->assertEquals(0, $count);
+    }
+
+    /**
+     * Data provider for data request creation tests.
+     *
+     * @return array
+     */
+    public function xapi_handler_errors_data(): array {
+        return [
+            // Invalid Definitions and results possibilities.
+            'Invalid definition and result' => [
+                true, false, false, true, true, false
+            ],
+            'Invalid result' => [
+                true, true, false, true, true, false
+            ],
+            'Invalid definition (generate empty attempt)' => [
+                true, false, true, true, true, true
+            ],
+            // Invalid verb possibilities.
+            'Invalid verb, definition and result' => [
+                false, false, false, true, true, false
+            ],
+            'Invalid verb and result' => [
+                false, true, false, true, true, false
+            ],
+            'Invalid verb and result' => [
+                false, false, true, true, true, false
+            ],
+            // Invalid context possibilities.
+            'Invalid definition, result and context' => [
+                true, false, false, false, true, false
+            ],
+            'Invalid result' => [
+                true, true, false, false, true, false
+            ],
+            'Invalid result and context' => [
+                true, false, true, false, true, false
+            ],
+            'Invalid verb, definition result and context' => [
+                false, false, false, false, true, false
+            ],
+            'Invalid verb, result and context' => [
+                false, true, false, false, true, false
+            ],
+            'Invalid verb, result and context' => [
+                false, false, true, false, true, false
+            ],
+            // Invalid user possibilities.
+            'Invalid definition, result and user' => [
+                true, false, false, true, false, false
+            ],
+            'Invalid result and user' => [
+                true, true, false, true, false, false
+            ],
+            'Invalid definition and user' => [
+                true, false, true, true, false, false
+            ],
+            'Invalid verb, definition, result and user' => [
+                false, false, false, true, false, false
+            ],
+            'Invalid verb, result and user' => [
+                false, true, false, true, false, false
+            ],
+            'Invalid verb, result and user' => [
+                false, false, true, true, false, false
+            ],
+            'Invalid definition, result, context and user' => [
+                true, false, false, false, false, false
+            ],
+            'Invalid result, context and user' => [
+                true, true, false, false, false, false
+            ],
+            'Invalid definition, context and user' => [
+                true, false, true, false, false, false
+            ],
+            'Invalid verb, definition, result, context and user' => [
+                false, false, false, false, false, false
+            ],
+            'Invalid verb, result, context and user' => [
+                false, true, false, false, false, false
+            ],
+            'Invalid verb, result, context and user' => [
+                false, false, true, false, false, false
+            ],
+        ];
+    }
+
+    /**
+     * Returns a basic xAPI statements simulating a H5P content.
+     *
+     * @param context_module $context activity context
+     * @param stdClass $user user record
+     * @return statement[] array of xAPI statements
+     */
+    private function generate_statements(context_module $context, stdClass $user): array {
+        $statements = [];
+
+        $statement = new statement();
+        $statement->set_actor(item_agent::create_from_user($user));
+        $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+        $definition = item_definition::create_from_data((object)[
+            'interactionType' => 'compound',
+            'correctResponsesPattern' => '1',
+        ]);
+        $statement->set_object(item_activity::create_from_id($context->id, $definition));
+        $statement->set_result(item::create_from_data((object)[
+            'completion' => true,
+            'success' => true,
+            'score' => (object) ['min' => 0, 'max' => 2, 'raw' => 2, 'scaled' => 1],
+        ]));
+        $statements[] = $statement;
+
+        $statement = new statement();
+        $statement->set_actor(item_agent::create_from_user($user));
+        $statement->set_verb(item_verb::create_from_id('http://adlnet.gov/expapi/verbs/completed'));
+        $definition = item_definition::create_from_data((object)[
+            'interactionType' => 'matching',
+            'correctResponsesPattern' => '1',
+        ]);
+        $statement->set_object(item_activity::create_from_id($context->id.'?subContentId=111-222-333', $definition));
+        $statement->set_result(item::create_from_data((object)[
+            'completion' => true,
+            'success' => true,
+            'score' => (object) ['min' => 0, 'max' => 1, 'raw' => 0, 'scaled' => 0],
+        ]));
+        $statements[] = $statement;
+
+        return $statements;
+    }
+}
index 3e72cfb..a23f286 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020022501;
+$plugin->version = 2020032300;
 $plugin->requires = 2020013000;
index 0336552..be2c11d 100644 (file)
@@ -62,13 +62,25 @@ $fileurl = moodle_url::make_pluginfile_url($file->get_contextid(), $file->get_co
                     $file->get_filename(), false);
 
 $PAGE->set_url('/mod/h5pactivity/view.php', ['id' => $cm->id]);
-$PAGE->set_title(format_string($moduleinstance->name));
+
+$shortname = format_string($course->shortname, true, ['context' => $context]);
+$pagetitle = strip_tags($shortname.': '.format_string($moduleinstance->name));
+$PAGE->set_title(format_string($pagetitle));
+
 $PAGE->set_heading(format_string($course->fullname));
 $PAGE->set_context($context);
 
 echo $OUTPUT->header();
+echo $OUTPUT->heading(format_string($moduleinstance->name));
+
+if (has_capability('mod/h5pactivity:submit', $context, null, false)) {
+    $trackcomponent = 'mod_h5pactivity';
+} else {
+    $trackcomponent = '';
+    $message = get_string('previewmode', 'mod_h5pactivity');
+    echo $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING);
+}
 
-// TODO: add component to enable xAPI traking.
-echo \core_h5p\player::display($fileurl, $config, true);
+echo \core_h5p\player::display($fileurl, $config, true, $trackcomponent);
 
 echo $OUTPUT->footer();
index 56ae1a6..10b580f 100644 (file)
@@ -203,7 +203,8 @@ class report_log_renderable implements renderable {
             $section = 0;
             $thissection = array();
             foreach ($modinfo->cms as $cm) {
-                if (!$cm->uservisible || !$cm->has_view()) {
+                // Exclude activities that aren't visible or have no view link (e.g. label). Account for folders displayed inline.
+                if (!$cm->uservisible || (!$cm->has_view() && strcmp($cm->modname, 'folder') !== 0)) {
                     continue;
                 }
                 if ($cm->sectionnum > 0 and $section <> $cm->sectionnum) {
index 317203e..e0251c7 100644 (file)
@@ -376,7 +376,8 @@ function report_log_print_mnet_selector_form($hostid, $course, $selecteduser=0,
         $section = 0;
         $thissection = array();
         foreach ($modinfo->cms as $cm) {
-            if (!$cm->uservisible || !$cm->has_view()) {
+            // Exclude activities that aren't visible or have no view link (e.g. label). Account for folder being displayed inline.
+            if (!$cm->uservisible || (!$cm->has_view() && strcmp($cm->modname, 'folder') !== 0)) {
                 continue;
             }
             if ($cm->sectionnum > 0 and $section <> $cm->sectionnum) {
index c2d84ff..1334e16 100644 (file)
@@ -1530,44 +1530,16 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
     overflow-y: hidden;
 }
 
-.modchooser .searchcontainer .searchbar {
+.modchooser .searchcontainer .searchbar .input-group-append {
+    align-items: center;
 
-    .searchinput {
-        font-size: inherit;
-        border: 1px solid #dee2e6;
-        border-top-left-radius: .3rem;
-        border-bottom-left-radius: .3rem;
-        border-right: none;
-    }
-
-    .searchbar-append {
-        align-items: center;
-        border: 1px solid #dee2e6;
-        border-left: none;
-        border-top-right-radius: .3rem;
-        border-bottom-right-radius: .3rem;
-
-        i {
-            color: #868e96;
-            margin: 0;
-            vertical-align: middle;
-        }
-
-        .clear {
-            i {
-                height: 23px;
-                width: 23px;
-                font-size: 23px;
-                pointer-events: none;
-            }
-        }
-        .search-icon {
-            i {
-                height: 20px;
-                width: 20px;
-                font-size: 20px;
-            }
-        }
+    i {
+        color: #868e96;
+        margin: 0;
+        vertical-align: middle;
+        font-size: 20px;
+        height: 20px;
+        width: 20px;
     }
 }
 
index 55c0802..f961a82 100644 (file)
@@ -717,10 +717,6 @@ $grading-content-show-content-button-padding-left: calc(#{map-get($spacers, 2) *
         @include transition(margin-right .2s ease-in-out);
     }
 
-    .grader-grading-panel.hidden + .grader-module-content {
-        margin-right: 0;
-    }
-
     .drawer-button {
         position: relative;
 
index 3cc16f4..ce4c146 100644 (file)
@@ -10669,32 +10669,15 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
 .modchoosercontainer.noscroll {
   overflow-y: hidden; }
 
-.modchooser .searchcontainer .searchbar .searchinput {
-  font-size: inherit;
-  border: 1px solid #dee2e6;
-  border-top-left-radius: .3rem;
-  border-bottom-left-radius: .3rem;
-  border-right: none; }
-
-.modchooser .searchcontainer .searchbar .searchbar-append {
-  align-items: center;
-  border: 1px solid #dee2e6;
-  border-left: none;
-  border-top-right-radius: .3rem;
-  border-bottom-right-radius: .3rem; }
-  .modchooser .searchcontainer .searchbar .searchbar-append i {
+.modchooser .searchcontainer .searchbar .input-group-append {
+  align-items: center; }
+  .modchooser .searchcontainer .searchbar .input-group-append i {
     color: #868e96;
     margin: 0;
-    vertical-align: middle; }
-  .modchooser .searchcontainer .searchbar .searchbar-append .clear i {
-    height: 23px;
-    width: 23px;
-    font-size: 23px;
-    pointer-events: none; }
-  .modchooser .searchcontainer .searchbar .searchbar-append .search-icon i {
+    vertical-align: middle;
+    font-size: 20px;
     height: 20px;
-    width: 20px;
-    font-size: 20px; }
+    width: 20px; }
 
 .modchoosercontainer .optionscontainer,
 .modchoosercontainer .searchresultitemscontainer {
@@ -16549,9 +16532,6 @@ select {
     .path-mod-forum .unified-grader .grader-module-content {
       transition: none; } }
 
-.path-mod-forum .unified-grader .grader-grading-panel.hidden + .grader-module-content {
-  margin-right: 0; }
-
 .path-mod-forum .unified-grader .drawer-button {
   position: relative; }
   .path-mod-forum .unified-grader .drawer-button.active::after {
index f87b804..3ec06ae 100644 (file)
@@ -10875,32 +10875,15 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
 .modchoosercontainer.noscroll {
   overflow-y: hidden; }
 
-.modchooser .searchcontainer .searchbar .searchinput {
-  font-size: inherit;
-  border: 1px solid #dee2e6;
-  border-top-left-radius: .3rem;
-  border-bottom-left-radius: .3rem;
-  border-right: none; }
-
-.modchooser .searchcontainer .searchbar .searchbar-append {
-  align-items: center;
-  border: 1px solid #dee2e6;
-  border-left: none;
-  border-top-right-radius: .3rem;
-  border-bottom-right-radius: .3rem; }
-  .modchooser .searchcontainer .searchbar .searchbar-append i {
+.modchooser .searchcontainer .searchbar .input-group-append {
+  align-items: center; }
+  .modchooser .searchcontainer .searchbar .input-group-append i {
     color: #868e96;
     margin: 0;
-    vertical-align: middle; }
-  .modchooser .searchcontainer .searchbar .searchbar-append .clear i {
-    height: 23px;
-    width: 23px;
-    font-size: 23px;
-    pointer-events: none; }
-  .modchooser .searchcontainer .searchbar .searchbar-append .search-icon i {
+    vertical-align: middle;
+    font-size: 20px;
     height: 20px;
-    width: 20px;
-    font-size: 20px; }
+    width: 20px; }
 
 .modchoosercontainer .optionscontainer,
 .modchoosercontainer .searchresultitemscontainer {
@@ -16774,9 +16757,6 @@ select {
     .path-mod-forum .unified-grader .grader-module-content {
       transition: none; } }
 
-.path-mod-forum .unified-grader .grader-grading-panel.hidden + .grader-module-content {
-  margin-right: 0; }
-
 .path-mod-forum .unified-grader .drawer-button {
   position: relative; }
   .path-mod-forum .unified-grader .drawer-button.active::after {