Merge branch 'MDL-68196-master' of git://github.com/rezaies/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Thu, 16 Apr 2020 02:55:11 +0000 (10:55 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Thu, 16 Apr 2020 02:55:11 +0000 (10:55 +0800)
155 files changed:
Gruntfile.js
admin/contentbank.php [new file with mode: 0644]
admin/settings/plugins.php
admin/testoutgoingmailconf.php
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
contentbank/classes/content.php [new file with mode: 0644]
contentbank/classes/contentbank.php [new file with mode: 0644]
contentbank/classes/contenttype.php [new file with mode: 0644]
contentbank/classes/helper.php [new file with mode: 0644]
contentbank/classes/output/bankcontent.php [new file with mode: 0644]
contentbank/classes/privacy/provider.php [new file with mode: 0644]
contentbank/contenttype/h5p/classes/content.php [new file with mode: 0644]
contentbank/contenttype/h5p/classes/contenttype.php [new file with mode: 0644]
contentbank/contenttype/h5p/classes/privacy/provider.php [new file with mode: 0644]
contentbank/contenttype/h5p/db/access.php [new file with mode: 0644]
contentbank/contenttype/h5p/lang/en/contenttype_h5p.php [new file with mode: 0644]
contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/behat/teacher_upload_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/content_h5p_test.php [new file with mode: 0644]
contentbank/contenttype/h5p/tests/contenttype_h5p_test.php [new file with mode: 0644]
contentbank/contenttype/h5p/version.php [new file with mode: 0644]
contentbank/files_form.php [new file with mode: 0644]
contentbank/index.php [new file with mode: 0644]
contentbank/templates/bankcontent.mustache [new file with mode: 0644]
contentbank/templates/toolbar.mustache [new file with mode: 0644]
contentbank/tests/behat/access_permissions.feature [new file with mode: 0644]
contentbank/tests/content_test.php [new file with mode: 0644]
contentbank/tests/contentbank_test.php [new file with mode: 0644]
contentbank/tests/contenttype_test.php [new file with mode: 0644]
contentbank/tests/fixtures/testable_content.php [new file with mode: 0644]
contentbank/tests/fixtures/testable_contenttype.php [new file with mode: 0644]
contentbank/upload.php [new file with mode: 0644]
contentbank/view.php [new file with mode: 0644]
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/format/renderer.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/admin.php
lang/en/cache.php
lang/en/contentbank.php [new file with mode: 0644]
lang/en/course.php
lang/en/error.php
lang/en/moodle.php
lang/en/plugin.php
lang/en/role.php
lib/adminlib.php
lib/amd/build/drawer.min.js
lib/amd/build/drawer.min.js.map
lib/amd/src/drawer.js
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/contenttype.php [new file with mode: 0644]
lib/components.json
lib/db/access.php
lib/db/caches.php
lib/db/install.xml
lib/db/upgrade.php
lib/filelib.php
lib/moodlelib.php
lib/navigationlib.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/custom_menu_item.mustache
lib/templates/drawer.mustache
lib/templates/paging_bar.mustache
lib/tests/component_test.php
lib/tests/moodlelib_test.php
lib/tests/outputcomponents_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
phpunit.xml.dist
pix/i/upload.png [new file with mode: 0644]
pix/i/upload.svg [new file with mode: 0644]
report/log/classes/renderable.php
report/log/locallib.php
theme/boost/scss/moodle.scss
theme/boost/scss/moodle/contentbank.scss [new file with mode: 0644]
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index bd39185..657bd77 100644 (file)
@@ -289,7 +289,13 @@ module.exports = function(grunt) {
         eslint: {
             // Even though warnings dont stop the build we don't display warnings by default because
             // at this moment we've got too many core warnings.
-            options: {quiet: !grunt.option('show-lint-warnings')},
+            // To display warnings call: grunt eslint --show-lint-warnings
+            // To fail on warnings call: grunt eslint --max-lint-warnings=0
+            // Also --max-lint-warnings=-1 can be used to display warnings but not fail.
+            options: {
+                quiet: (!grunt.option('show-lint-warnings')) && (typeof grunt.option('max-lint-warnings') === 'undefined'),
+                maxWarnings: ((typeof grunt.option('max-lint-warnings') !== 'undefined') ? grunt.option('max-lint-warnings') : -1)
+            },
             amd: {src: files ? files : amdSrc},
             // Check YUI module source files.
             yui: {src: files ? files : yuiSrc},
@@ -536,12 +542,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() {
diff --git a/admin/contentbank.php b/admin/contentbank.php
new file mode 100644 (file)
index 0000000..da29e6b
--- /dev/null
@@ -0,0 +1,84 @@
+<?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/>.
+
+/**
+ * Content bank and its plugins settings.
+ *
+ * @package    core
+ * @subpackage contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+$action = required_param('action', PARAM_ALPHANUMEXT);
+$name   = required_param('name', PARAM_PLUGIN);
+
+$syscontext = context_system::instance();
+$PAGE->set_url('/admin/contentbank.php');
+$PAGE->set_context($syscontext);
+
+require_admin();
+require_sesskey();
+
+$return = new moodle_url('/admin/settings.php', array('section' => 'managecontentbanktypes'));
+
+$plugins = core_plugin_manager::instance()->get_plugins_of_type('contenttype');
+$sortorder = array_flip(array_keys($plugins));
+
+if (!isset($plugins[$name])) {
+    print_error('contenttypenotfound', 'error', $return, $name);
+}
+
+switch ($action) {
+    case 'disable':
+        if ($plugins[$name]->is_enabled()) {
+            set_config('disabled', 1, 'contentbank_'. $name);
+        }
+        break;
+    case 'enable':
+        if (!$plugins[$name]->is_enabled()) {
+            unset_config('disabled', 'contentbank_'. $name);
+        }
+        break;
+    case 'up':
+        if ($sortorder[$name]) {
+            $currentindex = $sortorder[$name];
+            $seq = array_keys($plugins);
+            $seq[$currentindex] = $seq[$currentindex - 1];
+            $seq[$currentindex - 1] = $name;
+            set_config('contentbank_plugins_sortorder', implode(',', $seq));
+        }
+        break;
+    case 'down':
+        if ($sortorder[$name] < count($sortorder) - 1) {
+            $currentindex = $sortorder[$name];
+            $seq = array_keys($plugins);
+            $seq[$currentindex] = $seq[$currentindex + 1];
+            $seq[$currentindex + 1] = $name;
+            set_config('contentbank_plugins_sortorder', implode(',', $seq));
+        }
+        break;
+}
+core_plugin_manager::reset_caches();
+$cache = cache::make('core', 'contentbank_enabled_extensions');
+$cache->purge();
+$cache = cache::make('core', 'contentbank_context_extensions');
+$cache->purge();
+
+redirect($return);
index 6b3848b..278d4f3 100644 (file)
@@ -657,6 +657,19 @@ if ($hassiteconfig) {
     }
 }
 
+// Content bank content types.
+if ($hassiteconfig) {
+    $ADMIN->add('modules', new admin_category('contenbanksettings', new lang_string('contentbank')));
+    $temp = new admin_settingpage('managecontentbanktypes', new lang_string('managecontentbanktypes'));
+    $temp->add(new admin_setting_managecontentbankcontenttypes());
+    $ADMIN->add('contenbanksettings', $temp);
+    $plugins = core_plugin_manager::instance()->get_plugins_of_type('contenttype');
+    foreach ($plugins as $plugin) {
+        /** @var \core\plugininfo\contentbank $plugin */
+        $plugin->load_settings($ADMIN, 'contenbanksettings', $hassiteconfig);
+    }
+}
+
 /// Add all local plugins - must be always last!
 if ($hassiteconfig) {
     $ADMIN->add('modules', new admin_category('localplugins', new lang_string('localplugins')));
index 7d0db03..b1f9c58 100644 (file)
@@ -46,7 +46,8 @@ if ($data) {
     $emailuser->email = $data->recipient;
     $emailuser->id = -99;
 
-    $subject = get_string('testoutgoingmailconf_subject', 'admin', $SITE->fullname);
+    $subject = get_string('testoutgoingmailconf_subject', 'admin',
+        format_string($SITE->fullname, true, ['context' => context_system::instance()]));
     $messagetext = get_string('testoutgoingmailconf_message', 'admin');
 
     // Manage Moodle debugging options.
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;
     }
diff --git a/contentbank/classes/content.php b/contentbank/classes/content.php
new file mode 100644 (file)
index 0000000..b535b2f
--- /dev/null
@@ -0,0 +1,219 @@
+<?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/>.
+
+/**
+ * Content manager class
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank;
+
+use stored_file;
+use stdClass;
+use coding_exception;
+use moodle_url;
+
+/**
+ * Content manager class
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class content {
+
+    /** @var stdClass $content The content of the current instance. **/
+    protected $content  = null;
+
+    /**
+     * Content bank constructor
+     *
+     * @param stdClass $content A contentbanck_content record.
+     * @throws coding_exception If content type is not right.
+     */
+    public function __construct(stdClass $content) {
+        // Content type should exist and be linked to plugin classname.
+        $classname = $content->contenttype.'\\content';
+        if (get_class($this) != $classname) {
+            throw new coding_exception(get_string('contenttypenotfound', 'error', $content->contenttype));
+        }
+        $typeclass = $content->contenttype.'\\contenttype';
+        if (!class_exists($typeclass)) {
+            throw new coding_exception(get_string('contenttypenotfound', 'error', $content->contenttype));
+        }
+        // A record with the id must exist in 'contenbank_content' table.
+        // To improve performance, we are only checking the id is set, but no querying the database.
+        if (!isset($content->id)) {
+            throw new coding_exception(get_string('invalidcontentid', 'error'));
+        }
+        $this->content = $content;
+    }
+
+    /**
+     * Returns $this->content.
+     *
+     * @return stdClass  $this->content.
+     */
+    public function get_content(): stdClass {
+        return $this->content;
+    }
+
+    /**
+     * Returns $this->content->contenttype.
+     *
+     * @return string  $this->content->contenttype.
+     */
+    public function get_content_type(): string {
+        return $this->content->contenttype;
+    }
+
+    /**
+     * Updates content_bank table with information in $this->content.
+     *
+     * @return boolean  True if the content has been succesfully updated. False otherwise.
+     * @throws \coding_exception if not loaded.
+     */
+    public function update_content(): bool {
+        global $USER, $DB;
+
+        // A record with the id must exist in 'contenbank_content' table.
+        // To improve performance, we are only checking the id is set, but no querying the database.
+        if (!isset($this->content->id)) {
+            throw new coding_exception(get_string('invalidcontentid', 'error'));
+        }
+        $this->content->usermodified = $USER->id;
+        $this->content->timemodified = time();
+        return $DB->update_record('contentbank_content', $this->content);
+    }
+
+    /**
+     * Returns the name of the content.
+     *
+     * @return string   The name of the content.
+     */
+    public function get_name(): string {
+        return $this->content->name;
+    }
+
+    /**
+     * Returns the content ID.
+     *
+     * @return int   The content ID.
+     */
+    public function get_id(): int {
+        return $this->content->id;
+    }
+
+    /**
+     * Change the content instanceid value.
+     *
+     * @param int $instanceid    New instanceid for this content
+     * @return boolean           True if the instanceid has been succesfully updated. False otherwise.
+     */
+    public function set_instanceid(int $instanceid): bool {
+        $this->content->instanceid = $instanceid;
+        return $this->update_content();
+    }
+
+    /**
+     * Returns the $instanceid of this content.
+     *
+     * @return int   contentbank instanceid
+     */
+    public function get_instanceid(): int {
+        return $this->content->instanceid;
+    }
+
+    /**
+     * Change the content config values.
+     *
+     * @param string $configdata    New config information for this content
+     * @return boolean              True if the configdata has been succesfully updated. False otherwise.
+     */
+    public function set_configdata(string $configdata): bool {
+        $this->content->configdata = $configdata;
+        return $this->update_content();
+    }
+
+    /**
+     * Return the content config values.
+     *
+     * @return mixed   Config information for this content (json decoded)
+     */
+    public function get_configdata() {
+        return $this->content->configdata;
+    }
+
+    /**
+     * Returns the $file related to this content.
+     *
+     * @return stored_file  File stored in content bank area related to the given itemid.
+     * @throws \coding_exception if not loaded.
+     */
+    public function get_file(): ?stored_file {
+        $itemid = $this->get_id();
+        $fs = get_file_storage();
+        $files = $fs->get_area_files(
+            $this->content->contextid,
+            'contentbank',
+            'public',
+            $itemid,
+            'itemid, filepath, filename',
+            false
+        );
+        if (!empty($files)) {
+            $file = reset($files);
+            return $file;
+        }
+        return null;
+    }
+
+    /**
+     * Returns the file url related to this content.
+     *
+     * @return string       URL of the file stored in content bank area related to the given itemid.
+     * @throws \coding_exception if not loaded.
+     */
+    public function get_file_url(): string {
+        if (!$file = $this->get_file()) {
+            return '';
+        }
+        $fileurl = moodle_url::make_pluginfile_url(
+            $this->content->contextid,
+            'contentbank',
+            'public',
+            $file->get_itemid(),
+            $file->get_filepath(),
+            $file->get_filename()
+        );
+
+        return $fileurl;
+    }
+
+    /**
+     * Returns user has access permission for the content itself (based on what plugin needs).
+     *
+     * @return bool     True if content could be accessed. False otherwise.
+     */
+    public function can_view(): bool {
+        // There's no capability at content level to check,
+        // but plugins can overwrite this method in case they want to check something related to content properties.
+        return true;
+    }
+}
diff --git a/contentbank/classes/contentbank.php b/contentbank/classes/contentbank.php
new file mode 100644 (file)
index 0000000..2e89d42
--- /dev/null
@@ -0,0 +1,158 @@
+<?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/>.
+
+/**
+ * Content bank manager class
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank;
+
+/**
+ * Content bank manager class
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank {
+
+    /**
+     * Obtains the list of core_contentbank_content objects currently active.
+     *
+     * The list does not include players which are disabled.
+     *
+     * @return string[] Array of contentbank contenttypes.
+     */
+    private function get_enabled_content_types(): array {
+        $enabledtypes = \core\plugininfo\contenttype::get_enabled_plugins();
+        $types = [];
+        foreach ($enabledtypes as $name) {
+            $classname = "\\contenttype_$name\\contenttype";
+            if (class_exists($classname)) {
+                $types[] = $name;
+            }
+        }
+        return $types;
+    }
+
+    /**
+     * Obtains an array of supported extensions by active plugins.
+     *
+     * @return array The array with all the extensions supported and the supporting plugin names.
+     */
+    public function load_all_supported_extensions(): array {
+        $extensionscache = \cache::make('core', 'contentbank_enabled_extensions');
+        $supportedextensions = $extensionscache->get('enabled_extensions');
+        if ($supportedextensions === false) {
+            // Load all enabled extensions.
+            $supportedextensions = [];
+            foreach ($this->get_enabled_content_types() as $type) {
+                $classname = "\\contenttype_$type\\contenttype";
+                if (class_exists($classname)) {
+                    $manager = new $classname;
+                    if ($manager->is_feature_supported($manager::CAN_UPLOAD)) {
+                        $extensions = $manager->get_manageable_extensions();
+                        foreach ($extensions as $extension) {
+                            if (array_key_exists($extension, $supportedextensions)) {
+                                $supportedextensions[$extension][] = $type;
+                            } else {
+                                $supportedextensions[$extension] = [$type];
+                            }
+                        }
+                    }
+                }
+            }
+            $extensionscache->set('enabled_extensions', $supportedextensions);
+        }
+        return $supportedextensions;
+    }
+
+    /**
+     * Obtains an array of supported extensions in the given context.
+     *
+     * @param \context $context Optional context to check (default null)
+     * @return array The array with all the extensions supported and the supporting plugin names.
+     */
+    public function load_context_supported_extensions(\context $context = null): array {
+        $extensionscache = \cache::make('core', 'contentbank_context_extensions');
+
+        $contextextensions = $extensionscache->get($context->id);
+        if ($contextextensions === false) {
+            $contextextensions = [];
+            $supportedextensions = $this->load_all_supported_extensions();
+            foreach ($supportedextensions as $extension => $types) {
+                foreach ($types as $type) {
+                    $classname = "\\contenttype_$type\\contenttype";
+                    if (class_exists($classname)) {
+                        $manager = new $classname($context);
+                        if ($manager->can_upload()) {
+                            $contextextensions[$extension] = $type;
+                            break;
+                        }
+                    }
+                }
+            }
+            $extensionscache->set($context->id, $contextextensions);
+        }
+        return $contextextensions;
+    }
+
+    /**
+     * Obtains a string with all supported extensions by active plugins.
+     * Mainly to use as filepicker options parameter.
+     *
+     * @param \context $context   Optional context to check (default null)
+     * @return string A string with all the extensions supported.
+     */
+    public function get_supported_extensions_as_string(\context $context = null) {
+        $supported = $this->load_context_supported_extensions($context);
+        $extensions = array_keys($supported);
+        return implode(',', $extensions);
+    }
+
+    /**
+     * Returns the file extension for a file.
+     *
+     * @param  string $filename The name of the file
+     * @return string The extension of the file
+     */
+    public function get_extension(string $filename) {
+        $dot = strrpos($filename, '.');
+        if ($dot === false) {
+            return '';
+        }
+        return strtolower(substr($filename, $dot));
+    }
+
+    /**
+     * Get the first content bank plugin supports a file extension.
+     *
+     * @param string $extension Content file extension
+     * @param \context $context $context     Optional context to check (default null)
+     * @return string contenttype name supports the file extension or null if the extension is not supported by any allowed plugin.
+     */
+    public function get_extension_supporter(string $extension, \context $context = null): ?string {
+        $supporters = $this->load_context_supported_extensions($context);
+        if (array_key_exists($extension, $supporters)) {
+            return $supporters[$extension];
+        }
+        return null;
+    }
+}
diff --git a/contentbank/classes/contenttype.php b/contentbank/classes/contenttype.php
new file mode 100644 (file)
index 0000000..f406282
--- /dev/null
@@ -0,0 +1,213 @@
+<?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/>.
+
+/**
+ * Content type manager class
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank;
+
+use coding_exception;
+use moodle_url;
+
+/**
+ * Content type manager class
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class contenttype {
+
+    /** Plugin implements uploading feature */
+    const CAN_UPLOAD = 'upload';
+
+    /** @var context This content's context. **/
+    protected $context = null;
+
+    /**
+     * Content type constructor
+     *
+     * @param \context $context Optional context to check (default null)
+     */
+    public function __construct(\context $context = null) {
+        if (empty($context)) {
+            $context = \context_system::instance();
+        }
+        $this->context = $context;
+    }
+
+    /**
+     * Fills content_bank table with appropiate information.
+     *
+     * @param stdClass $content  An optional content record compatible object (default null)
+     * @return content       Object with content bank information.
+     */
+    public function create_content(\stdClass $content = null): ?content {
+        global $USER, $DB;
+
+        $record = new \stdClass();
+        $record->contenttype = $this->get_contenttype_name();
+        $record->contextid = $this->context->id;
+        $record->name = $content->name ?? '';
+        $record->usercreated = $content->usercreated ?? $USER->id;
+        $record->timecreated = time();
+        $record->usermodified = $record->usercreated;
+        $record->timemodified = $record->timecreated;
+        $record->configdata = $content->configdata ?? '';
+        $record->id = $DB->insert_record('contentbank_content', $record);
+        if ($record->id) {
+            $classname = '\\'.$record->contenttype.'\\content';
+            return new $classname($record);
+        }
+        return null;
+    }
+
+    /**
+     * Returns the contenttype name of this content.
+     *
+     * @return string   Content type of the current instance
+     */
+    public function get_contenttype_name(): string {
+        $classname = get_class($this);
+        $contenttype = explode('\\', $classname);
+        return array_shift($contenttype);
+    }
+
+    /**
+     * Returns the plugin name of the current instance.
+     *
+     * @return string   Plugin name of the current instance
+     */
+    public function get_plugin_name(): string {
+        $contenttype = $this->get_contenttype_name();
+        $plugin = explode('_', $contenttype);
+        return array_pop($plugin);
+    }
+
+    /**
+     * Returns the URL where the content will be visualized.
+     *
+     * @param stdClass $record  Th content to be displayed.
+     * @return string            URL where to visualize the given content.
+     */
+    public function get_view_url(\stdClass $record): string {
+        return new moodle_url('/contentbank/view.php', ['id' => $record->id]);
+    }
+
+    /**
+     * Returns the HTML content to add to view.php visualizer.
+     *
+     * @param stdClass $record  Th content to be displayed.
+     * @return string            HTML code to include in view.php.
+     */
+    public function get_view_content(\stdClass $record): string {
+        // Main contenttype class can visualize the content, but plugins could overwrite visualization.
+        return '';
+    }
+
+    /**
+     * Returns the HTML code to render the icon for content bank contents.
+     *
+     * @param string $contentname   The contentname to add as alt value to the icon.
+     * @return string               HTML code to render the icon
+     */
+    public function get_icon(string $contentname): string {
+        global $OUTPUT;
+        return $OUTPUT->pix_icon('f/unknown-64', $contentname, 'moodle', ['class' => 'iconsize-big']);
+    }
+
+    /**
+     * Returns user has access capability for the main content bank and the content itself (base on is_access_allowed from plugin).
+     *
+     * @return bool     True if content could be accessed. False otherwise.
+     */
+    final public function can_access(): bool {
+        $classname = 'contenttype/'.$this->get_plugin_name();
+        $capability = $classname.":access";
+        $hascapabilities = has_capability('moodle/contentbank:access', $this->context)
+            && has_capability($capability, $this->context);
+        return $hascapabilities && $this->is_access_allowed();
+    }
+
+    /**
+     * Returns user has access capability for the content itself.
+     *
+     * @return bool     True if content could be accessed. False otherwise.
+     */
+    protected function is_access_allowed(): bool {
+        // Plugins can overwrite this function to add any check they need.
+        return true;
+    }
+
+    /**
+     * Returns the user has permission to upload new content.
+     *
+     * @return bool     True if content could be uploaded. False otherwise.
+     */
+    final public function can_upload(): bool {
+        if (!$this->is_feature_supported(self::CAN_UPLOAD)) {
+            return false;
+        }
+        if (!$this->can_access()) {
+            return false;
+        }
+
+        $classname = 'contenttype/'.$this->get_plugin_name();
+        $uploadcap = $classname.':upload';
+        $hascapabilities = has_capability('moodle/contentbank:upload', $this->context)
+            && has_capability($uploadcap, $this->context);
+        return $hascapabilities && $this->is_upload_allowed();
+    }
+
+    /**
+     * Returns plugin allows uploading.
+     *
+     * @return bool     True if plugin allows uploading. False otherwise.
+     */
+    protected function is_upload_allowed(): bool {
+        // Plugins can overwrite this function to add any check they need.
+        return true;
+    }
+
+    /**
+     * Returns the plugin supports the feature.
+     *
+     * @param string $feature Feature code e.g CAN_UPLOAD
+     * @return bool     True if content could be uploaded. False otherwise.
+     */
+    final public function is_feature_supported(string $feature): bool {
+        return in_array($feature, $this->get_implemented_features());
+    }
+
+    /**
+     * Return an array of implemented features by the plugins.
+     *
+     * @return array
+     */
+    abstract protected function get_implemented_features(): array;
+
+    /**
+     * Return an array of extensions the plugins could manage.
+     *
+     * @return array
+     */
+    abstract public function get_manageable_extensions(): array;
+}
diff --git a/contentbank/classes/helper.php b/contentbank/classes/helper.php
new file mode 100644 (file)
index 0000000..463f7ed
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * Contains helper class for the content bank.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank;
+
+/**
+ * Helper class for the content bank.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper {
+
+    /**
+     * Getting content bank page ready for the breadcrumbs.
+     *
+     * @param \context $context Context of the current page.
+     * @param string $title Title of the current page.
+     * @param bool $internal True if is an internal page, false otherwise.
+     */
+    public static function get_page_ready(\context $context, string $title, bool $internal = false): void {
+        global $PAGE, $DB;
+
+        $PAGE->set_context($context);
+        $cburl = new \moodle_url('/contentbank/index.php', ['contextid' => $context->id]);
+
+        switch ($context->contextlevel) {
+            case CONTEXT_COURSE:
+                $courseid = $context->instanceid;
+                $course = $DB->get_record('course', ['id' => $courseid], '*', MUST_EXIST);
+                $PAGE->set_course($course);
+                \navigation_node::override_active_url(new \moodle_url('/course/view.php', ['id' => $courseid]));
+                $PAGE->navbar->add($title, $cburl);
+                $PAGE->set_pagelayout('incourse');
+                break;
+            case CONTEXT_COURSECAT:
+                $coursecat = $context->instanceid;
+                \navigation_node::override_active_url(new \moodle_url('/course/index.php', ['categoryid' => $coursecat]));
+                $PAGE->navbar->add($title, $cburl);
+                $PAGE->set_pagelayout('coursecategory');
+                break;
+            default:
+                if ($node = $PAGE->navigation->find('contentbank', \global_navigation::TYPE_CUSTOM)) {
+                    $node->make_active();
+                }
+                $PAGE->set_pagelayout('standard');
+        }
+    }
+}
diff --git a/contentbank/classes/output/bankcontent.php b/contentbank/classes/output/bankcontent.php
new file mode 100644 (file)
index 0000000..ac1a855
--- /dev/null
@@ -0,0 +1,96 @@
+<?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/>.
+
+/**
+ * core_contentbank specific renderers
+ *
+ * @package   core_contentbank
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank\output;
+
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class containing data for bank content
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class bankcontent implements renderable, templatable {
+
+    /**
+     * @var \core_contentbank\content[]    Array of content bank contents.
+     */
+    private $contents;
+
+    /**
+     * @var array   $toolbar object.
+     */
+    private $toolbar;
+
+    /**
+     * @var \context    Given context. Null by default.
+     */
+    private $context;
+
+    /**
+     * Construct this renderable.
+     *
+     * @param \core_contentbank\content[] $contents   Array of content bank contents.
+     * @param array $toolbar     List of content bank toolbar options.
+     * @param \context $context Optional context to check (default null)
+     */
+    public function __construct(array $contents, array $toolbar, \context $context = null) {
+        $this->contents = $contents;
+        $this->toolbar = $toolbar;
+        $this->context = $context;
+    }
+
+    /**
+     * Export the data.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        $data = new stdClass();
+        $contentdata = array();
+        foreach ($this->contents as $content) {
+            $record = $content->get_content();
+            $managerclass = $content->get_content_type().'\\contenttype';
+            if (class_exists($managerclass)) {
+                $manager = new $managerclass($this->context);
+                if ($manager->can_access()) {
+                    $name = $content->get_name();
+                    $contentdata[] = array(
+                        'name' => $name,
+                        'link' => $manager->get_view_url($record),
+                        'icon' => $manager->get_icon($name)
+                    );
+                }
+            }
+        }
+        $data->contents = $contentdata;
+        $data->tools = $this->toolbar;
+        return $data;
+    }
+}
diff --git a/contentbank/classes/privacy/provider.php b/contentbank/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..3869f3d
--- /dev/null
@@ -0,0 +1,129 @@
+<?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/>.
+
+/**
+ * Privacy provider implementation for core_contentbank.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank\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\userlist;
+
+/**
+ * Privacy provider implementation for core_contentbank.
+ *
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\core_userlist_provider,
+    \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Returns metadata.
+     * TODO: MDL-67798.
+     *
+     * @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 {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+
+        $collection->add_database_table('contentbank_content', [
+            'usercreated' => 'privacy:metadata:content:usercreated',
+            'usermodified' => 'privacy:metadata:content:usermodified',
+        ], 'privacy:metadata:userid');
+
+        return $collection;
+    }
+
+    /**
+     * TODO: MDL-67798.
+     *
+     * @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) {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+    }
+
+    /**
+     * TODO: MDL-67798.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+    }
+
+    /**
+     * TODO: MDL-67798.
+     * 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 {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+
+        return (new contextlist());
+    }
+
+    /**
+     * TODO: MDL-67798.
+     * 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) {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+    }
+
+    /**
+     * TODO: MDL-67798.
+     * Delete all data for all users in the specified context.
+     *
+     * @param   context                 $context   The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+    }
+
+    /**
+     * TODO: MDL-67798.
+     * 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) {
+        // We are not implementing a proper privacy provider for now.
+        // A right privacy provider will be implemented in MDL-67798.
+    }
+}
diff --git a/contentbank/contenttype/h5p/classes/content.php b/contentbank/contenttype/h5p/classes/content.php
new file mode 100644 (file)
index 0000000..1457476
--- /dev/null
@@ -0,0 +1,38 @@
+<?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 Content manager class
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace contenttype_h5p;
+
+use stdClass;
+use html_writer;
+
+/**
+ * H5P Content manager class
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content extends \core_contentbank\content {
+}
diff --git a/contentbank/contenttype/h5p/classes/contenttype.php b/contentbank/contenttype/h5p/classes/contenttype.php
new file mode 100644 (file)
index 0000000..6c100cb
--- /dev/null
@@ -0,0 +1,90 @@
+<?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 content type manager class
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace contenttype_h5p;
+
+use stdClass;
+use html_writer;
+
+/**
+ * H5P content bank manager class
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contenttype extends \core_contentbank\contenttype {
+
+    /**
+     * Returns the HTML content to add to view.php visualizer.
+     *
+     * @param stdClass $record  Th content to be displayed.
+     * @return string            HTML code to include in view.php.
+     */
+    public function get_view_content(\stdClass $record): string {
+        $content = new content($record);
+        $fileurl = $content->get_file_url();
+        $html = html_writer::tag('h2', $content->get_name());
+        $html .= \core_h5p\player::display($fileurl, new \stdClass(), true);
+        return $html;
+    }
+
+    /**
+     * Returns the HTML code to render the icon for H5P content types.
+     *
+     * @param string $contentname   The contentname to add as alt value to the icon.
+     * @return string            HTML code to render the icon
+     */
+    public function get_icon(string $contentname): string {
+        global $OUTPUT;
+        return $OUTPUT->pix_icon('f/h5p-64', $contentname, 'moodle', ['class' => 'iconsize-big']);
+    }
+
+    /**
+     * Return an array of implemented features by this plugin.
+     *
+     * @return array
+     */
+    protected function get_implemented_features(): array {
+        return [self::CAN_UPLOAD];
+    }
+
+    /**
+     * Return an array of extensions this contenttype could manage.
+     *
+     * @return array
+     */
+    public function get_manageable_extensions(): array {
+        return ['.h5p'];
+    }
+
+    /**
+     * Returns user has access capability for the content itself.
+     *
+     * @return bool     True if content could be accessed. False otherwise.
+     */
+    protected function is_access_allowed(): bool {
+        return true;
+    }
+}
diff --git a/contentbank/contenttype/h5p/classes/privacy/provider.php b/contentbank/contenttype/h5p/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..48aa9e2
--- /dev/null
@@ -0,0 +1,44 @@
+<?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/>.
+
+/**
+ * Privacy provider implementation for core_contentbank.
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace contenttype_h5p\privacy;
+
+/**
+ * Privacy provider implementation for contenttype_h5p.
+ *
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
diff --git a/contentbank/contenttype/h5p/db/access.php b/contentbank/contenttype/h5p/db/access.php
new file mode 100644 (file)
index 0000000..95db4aa
--- /dev/null
@@ -0,0 +1,47 @@
+<?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 content bank contenttype capabilities.
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = [
+    'contenttype/h5p:access' => array(
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        )
+    ),
+    'contenttype/h5p:upload' => [
+        'riskbitmask' => RISK_SPAM,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => [
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        ]
+    ],
+];
diff --git a/contentbank/contenttype/h5p/lang/en/contenttype_h5p.php b/contentbank/contenttype/h5p/lang/en/contenttype_h5p.php
new file mode 100644 (file)
index 0000000..10ea721
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * Strings for plugin 'contenttype_h5p'
+ *
+ * @package    contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['pluginname'] = 'H5P';
+$string['pluginname_help'] = 'Content bank to upload and share H5P content';
+$string['privacy:metadata'] = 'The H5P content bank plugin does not store any personal data.';
+$string['h5p:access'] = 'Access to H5P content in the content bank';
+$string['h5p:upload'] = 'Upload a new H5P content';
diff --git a/contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature b/contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature
new file mode 100644 (file)
index 0000000..8d03118
--- /dev/null
@@ -0,0 +1,73 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+Feature: H5P file upload to content bank for admins
+  In order import new H5P content to content bank
+  As an admin
+  I need to be able to upload a new .h5p file to content bank
+
+  Background:
+    Given I log in as "admin"
+    And I follow "Manage private files..."
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager
+    And I click on "Save changes" "button"
+    And I press "Customise this page"
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+
+  Scenario: Admins can upload .h5p extension files to content bank
+    Given I should not see "filltheblanks.h5p"
+    When I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I wait until the page is ready
+    Then I should see "filltheblanks.h5p"
+
+  Scenario: Admins can see uploaded H5P contents
+    Given I should not see "filltheblanks.h5p"
+    When I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I wait until the page is ready
+    And I click on "filltheblanks.h5p" "link"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    Then I should see "Of which countries"
+
+  Scenario: Users can't see content managed by disabled plugins
+    Given I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I wait until the page is ready
+    And I should see "filltheblanks.h5p"
+    And I navigate to "Plugins > Content bank > Manage content bank content types" in site administration
+    And I click on "Disable" "icon" in the "H5P" "table_row"
+    And I wait until the page is ready
+    When I click on "Content bank" "link"
+    Then I should not see "filltheblanks.h5p"
+
+  Scenario: Contents in a context are not available from other contexts
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    When I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I wait until the page is ready
+    Then I should see "filltheblanks.h5p"
+    And I am on "Course 1" course homepage
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should not see "filltheblanks.h5p"
\ No newline at end of file
diff --git a/contentbank/contenttype/h5p/tests/behat/teacher_upload_content.feature b/contentbank/contenttype/h5p/tests/behat/teacher_upload_content.feature
new file mode 100644 (file)
index 0000000..9c25ac9
--- /dev/null
@@ -0,0 +1,73 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+Feature: H5P file upload to content bank for non admins
+  In order import new H5P content to content bank
+  As an admin
+  I need to be able to upload a new .h5p file to content bank
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | teacher2 | Teacher   | 2        | teacher2@example.com |
+    And the following "categories" exist:
+      | name  | category | idnumber |
+      | Cat 1 | 0        | CAT1     |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | CAT1     |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | teacher2 | C1     | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Manage private files..."
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager
+    And I click on "Save changes" "button"
+
+  Scenario: Teachers can not access system level content bank
+    Given I press "Customise this page"
+    And I add the "Navigation" block if not present
+    When I expand "Site pages" node
+    Then I should not see "Content bank"
+
+  Scenario: Teachers can access course level content bank
+    Given I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    When I expand "Site pages" node
+    Then I should see "Content bank"
+
+  Scenario: Teachers can upload .h5p extension files to course content bank
+    Given I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    When I expand "Site pages" node
+    And I click on "Content bank" "link"
+    Then I should not see "filltheblanks.h5p"
+    And I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I wait until the page is ready
+    And I should see "filltheblanks.h5p"
+
+  Scenario: Other teachers can see uploaded H5P contents
+    Given I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    When I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I wait until the page is ready
+    And I should see "filltheblanks.h5p"
+    And I log out
+    When I log in as "teacher2"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    Then I should see "filltheblanks.h5p"
diff --git a/contentbank/contenttype/h5p/tests/content_h5p_test.php b/contentbank/contenttype/h5p/tests/content_h5p_test.php
new file mode 100644 (file)
index 0000000..a4082f8
--- /dev/null
@@ -0,0 +1,69 @@
+<?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/>.
+
+/**
+ * Test for H5P content bank plugin.
+ *
+ * @package    contenttype_h5p
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Test for H5P content bank plugin.
+ *
+ * @package    contenttype_h5p
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \contenttype_h5p\content
+ */
+class contenttype_h5p_content_plugin_testcase extends advanced_testcase {
+
+    /**
+     * Tests for uploaded file.
+     *
+     * @covers ::get_file
+     */
+    public function test_upload_file() {
+        $this->resetAfterTest();
+
+        // Create content.
+        $record = new stdClass();
+        $record->name = 'Test content';
+        $record->configdata = '';
+        $contenttype = new \contenttype_h5p\contenttype(context_system::instance());
+        $content = $contenttype->create_content($record);
+
+        // Create a dummy file.
+        $filename = 'content.h5p';
+        $dummy = array(
+            'contextid' => \context_system::instance()->id,
+            'component' => 'contentbank',
+            'filearea' => 'public',
+            'itemid' => $content->get_id(),
+            'filepath' => '/',
+            'filename' => $filename
+        );
+        $fs = get_file_storage();
+        $fs->create_file_from_string($dummy, 'dummy content');
+
+        $file = $content->get_file();
+        $this->assertInstanceOf(\stored_file::class, $file);
+        $this->assertEquals($filename, $file->get_filename());
+    }
+}
diff --git a/contentbank/contenttype/h5p/tests/contenttype_h5p_test.php b/contentbank/contenttype/h5p/tests/contenttype_h5p_test.php
new file mode 100644 (file)
index 0000000..a2daa82
--- /dev/null
@@ -0,0 +1,67 @@
+<?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/>.
+
+/**
+ * Test for H5P content bank plugin.
+ *
+ * @package    contenttype_h5p
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Test for H5P content bank plugin.
+ *
+ * @package    contenttype_h5p
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \contenttype_h5p\contenttype
+ */
+class contenttype_h5p_contenttype_plugin_testcase extends advanced_testcase {
+
+    /**
+     * Tests can_upload behavior.
+     *
+     * @covers ::can_upload
+     */
+    public function test_can_upload() {
+        $this->resetAfterTest();
+
+        $systemcontext = \context_system::instance();
+        $systemtype = new \contenttype_h5p\contenttype($systemcontext);
+
+        // Admins can upload.
+        $this->setAdminUser();
+        $this->assertTrue($systemtype->can_upload());
+
+        // Teacher can upload in the course but not at system level.
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $coursecontext = \context_course::instance($course->id);
+        $coursetype = new \contenttype_h5p\contenttype($coursecontext);
+        $this->setUser($teacher);
+        $this->assertTrue($coursetype->can_upload());
+        $this->assertFalse($systemtype->can_upload());
+
+        // Users can't upload.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $this->assertFalse($coursetype->can_upload());
+        $this->assertFalse($systemtype->can_upload());
+    }
+}
diff --git a/contentbank/contenttype/h5p/version.php b/contentbank/contenttype/h5p/version.php
new file mode 100644 (file)
index 0000000..548ef13
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * Version details
+ *
+ * @package   contenttype_h5p
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2020041500.00;         // The current plugin version (Date: YYYYMMDDXX)
+$plugin->requires  = 2020041500.00;         // Requires this Moodle version
+$plugin->component = 'contenttype_h5p'; // Full name of the plugin (used for diagnostics).
diff --git a/contentbank/files_form.php b/contentbank/files_form.php
new file mode 100644 (file)
index 0000000..d94f61f
--- /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/>.
+
+/**
+ * Upload files to content bank form
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/formslib.php");
+
+/**
+ * Class contentbank_files_form
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_files_form extends moodleform {
+
+    /**
+     * Add elements to this form.
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        $mform->addElement('hidden', 'contextid', $this->_customdata['contextid']);
+        $mform->setType('contextid', PARAM_INT);
+
+        $options = $this->_customdata['options'];
+        $mform->addElement('filepicker', 'file', get_string('file', 'core_contentbank'), null, $options);
+        $mform->addHelpButton('file', 'file', 'core_contentbank');
+        $mform->addRule('file', null, 'required');
+
+        $this->add_action_buttons(true, get_string('savechanges'));
+
+        $data = $this->_customdata['data'];
+        $this->set_data($data);
+    }
+
+    /**
+     * Validate incoming data.
+     *
+     * @param array $data
+     * @param array $files
+     * @return array
+     */
+    public function validation($data, $files) {
+        $errors = array();
+        $draftitemid = $data['file'];
+        if (file_is_draft_area_limit_reached($draftitemid, $this->_customdata['options']['areamaxbytes'])) {
+            $errors['file'] = get_string('userquotalimit', 'error');
+        }
+        return $errors;
+    }
+}
diff --git a/contentbank/index.php b/contentbank/index.php
new file mode 100644 (file)
index 0000000..01d1db6
--- /dev/null
@@ -0,0 +1,81 @@
+<?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/>.
+
+/**
+ * List content in content bank.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require('../config.php');
+
+require_login();
+
+$contextid    = optional_param('contextid', \context_system::instance()->id, PARAM_INT);
+$context = context::instance_by_id($contextid, MUST_EXIST);
+
+require_capability('moodle/contentbank:access', $context);
+
+$title = get_string('contentbank');
+\core_contentbank\helper::get_page_ready($context, $title);
+if ($PAGE->course) {
+    require_login($PAGE->course->id);
+}
+$PAGE->set_url('/contentbank/index.php');
+$PAGE->set_context($context);
+$PAGE->set_title($title);
+$PAGE->set_heading($title);
+$PAGE->set_pagetype('contenbank');
+
+// Get all contents managed by active plugins to render.
+$foldercontents = array();
+$contents = $DB->get_records('contentbank_content', ['contextid' => $contextid]);
+foreach ($contents as $content) {
+    $plugin = core_plugin_manager::instance()->get_plugin_info($content->contenttype);
+    if (!$plugin || !$plugin->is_enabled()) {
+        continue;
+    }
+    $contentclass = "\\$content->contenttype\\content";
+    if (class_exists($contentclass)) {
+        $contentmanager = new $contentclass($content);
+        if ($contentmanager->can_view()) {
+            $foldercontents[] = $contentmanager;
+        }
+    }
+}
+
+// Get the toolbar ready.
+$toolbar = array ();
+if (has_capability('moodle/contentbank:upload', $context)) {
+    // Don' show upload button if there's no plugin to support any file extension.
+    $cb = new \core_contentbank\contentbank();
+    $accepted = $cb->get_supported_extensions_as_string($context);
+    if (!empty($accepted)) {
+        $importurl = new moodle_url('/contentbank/upload.php', ['contextid' => $contextid]);
+        $toolbar[] = array('name' => 'Upload', 'link' => $importurl, 'icon' => 'i/upload');
+    }
+}
+
+echo $OUTPUT->header();
+echo $OUTPUT->box_start('generalbox');
+
+$folder = new \core_contentbank\output\bankcontent($foldercontents, $toolbar, $context);
+echo $OUTPUT->render($folder);
+
+echo $OUTPUT->box_end();
+echo $OUTPUT->footer();
diff --git a/contentbank/templates/bankcontent.mustache b/contentbank/templates/bankcontent.mustache
new file mode 100644 (file)
index 0000000..5b1a1dd
--- /dev/null
@@ -0,0 +1,73 @@
+{{!
+    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/>.
+}}
+{{!
+    @template core_contentbank/list
+
+    Example context (json):
+    {
+        "contents": [
+            {
+                "name": "accordion.h5p",
+                "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p",
+                "icon" : "<img class='icon iconsize-big' alt='accordion.h5p' aria-hidden='true' src='http://something/theme/image.php/boost/core/1581597850/f/h5p-64'>"
+            },
+            {
+                "name": "resume.pdf",
+                "icon": "<img class='icon iconsize-big' alt='resume.pdf' aria-hidden='true' src='http://something/theme/image.php/boost/core/1584597850/f/pdf-64'>"
+            }
+        ],
+        "tools": [
+            {
+                "name": "Upload",
+                "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p",
+                "icon" : "i/upload"
+            },
+            {
+                "icon": "i/export"
+            }
+        ]
+    }
+
+}}
+{{>core_contentbank/toolbar}}
+<div class="content-bank-container card">
+    <div class="content-bank">
+        <div class="cb-navbar">
+            {{#pix}} i/folder {{/pix}}
+        </div>
+        <div class="cb-content-wrapper">
+        {{#contents}}
+            <div class="cb-content">
+                <div class="cb-iconview">
+                    <div class="cb-file text-center position-relative">
+                        {{#link}}<a href="{{{ link }}}">{{/link}}
+                            <div style="position:relative;">
+                                <div class="cb-thumbnail text-center d-block" style="width: 110px; height: 110px;">
+                                    {{{ icon }}}
+                                </div>
+                            </div>
+                            <div class="cb-contentname-field position-absolute overflow-visible">
+                                <div class="cb-contentname text-truncate" style="width: 112px;">{{{ name }}}</div>
+                            </div>
+                        {{#link}}</a>{{/link}}
+                    </div>
+                </div>
+            </div>
+        {{/contents}}
+        </div>
+    </div>
+</div>
diff --git a/contentbank/templates/toolbar.mustache b/contentbank/templates/toolbar.mustache
new file mode 100644 (file)
index 0000000..7a041a6
--- /dev/null
@@ -0,0 +1,47 @@
+{{!
+    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/>.
+}}
+{{!
+    @template core_contentbank/toolbar
+
+    Example context (json):
+    {
+        "tools": [
+            {
+                "name": "Upload",
+                "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p",
+                "icon" : "i/upload"
+            },
+            {
+                "icon": "i/export"
+            }
+        ]
+    }
+
+}}
+<div class="content-bank-toolbar card border-0 mb-3">
+    <div class="content-bank">
+        <div class="cb-toolbar float-sm-right">
+        {{#tools}}
+            {{#link}}<a href="{{{ link }}}" title="{{{ name }}}">{{/link}}
+                <div class="cb-tool btn btn-secondary btn-sm">
+                    {{#pix}} {{{ icon }}} {{/pix}}
+                </div>
+            {{#link}}</a>{{/link}}
+        {{/tools}}
+        </div>
+    </div>
+</div>
diff --git a/contentbank/tests/behat/access_permissions.feature b/contentbank/tests/behat/access_permissions.feature
new file mode 100644 (file)
index 0000000..de1fde0
--- /dev/null
@@ -0,0 +1,29 @@
+@core @core_contentbank
+Feature: Access permission to content Bank
+  In order to control access to content bank
+  As an admin
+  I need to be able to configure users' permissions
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email             |
+      | teacher1 | Teacher   | 1        | user1@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 |
+
+  Scenario: Admins access content bank
+    Given I log in as "admin"
+    Then "Content bank" "link" should exist
+
+  Scenario: Editing teachers can access content bank at course level
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    Then "Content bank" "link" should exist
+
+  Scenario: Editing teachers can't access content bank at system level
+    Given I log in as "teacher1"
+    Then "Content bank" "link" should not exist
\ No newline at end of file
diff --git a/contentbank/tests/content_test.php b/contentbank/tests/content_test.php
new file mode 100644 (file)
index 0000000..04b722f
--- /dev/null
@@ -0,0 +1,107 @@
+<?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/>.
+
+/**
+ * Test for content bank contenttype class.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+
+use stdClass;
+use context_system;
+use contenttype_testable\contenttype as contenttype;
+/**
+ * Test for content bank contenttype class.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core_contentbank\content
+ *
+ */
+class core_contenttype_content_testcase extends \advanced_testcase {
+
+    /**
+     * Tests for behaviour of get_name().
+     *
+     * @covers ::get_name
+     */
+    public function test_get_name() {
+        $this->resetAfterTest();
+
+        // Create content.
+        $record = new stdClass();
+        $record->name = 'Test content';
+        $record->configdata = '';
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->create_content($record);
+        $this->assertEquals($record->name, $content->get_name());
+    }
+
+    /**
+     * Tests for behaviour of get_content_type().
+     *
+     * @covers ::get_content_type
+     */
+    public function test_get_content_type() {
+        $this->resetAfterTest();
+
+        // Create content.
+        $record = new stdClass();
+        $record->name = 'Test content';
+        $record->configdata = '';
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->create_content($record);
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+    }
+
+    /**
+     * Tests for 'configdata' behaviour.
+     *
+     * @covers ::set_configdata
+     */
+    public function test_configdata_changes() {
+        $this->resetAfterTest();
+
+        $configdata = "{img: 'icon.svg'}";
+
+        // Create content.
+        $record = new stdClass();
+        $record->configdata = $configdata;
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->create_content($record);
+        $this->assertEquals($configdata, $content->get_configdata());
+
+        $configdata = "{alt: 'Name'}";
+        $content->set_configdata($configdata);
+        $this->assertEquals($configdata, $content->get_configdata());
+    }
+}
diff --git a/contentbank/tests/contentbank_test.php b/contentbank/tests/contentbank_test.php
new file mode 100644 (file)
index 0000000..199b73a
--- /dev/null
@@ -0,0 +1,178 @@
+<?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/>.
+
+/**
+ * Test for extensions manager.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+
+/**
+ * Test for extensions manager.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core_contentbank\contentbank
+ */
+class core_contentbank_testcase extends advanced_testcase {
+    /**
+     * Data provider for test_get_extension_supporter.
+     *
+     * @return  array
+     */
+    public function get_extension_provider() {
+        return [
+            'H5P file' => ['something.h5p', '.h5p'],
+            'PDF file' => ['something.pdf', '.pdf']
+        ];
+    }
+
+    /**
+     * Tests for get_extension() function.
+     *
+     * @dataProvider    get_extension_provider
+     * @param   string  $filename    The filename given
+     * @param   string   $expected   The extension of the file
+     *
+     * @covers ::get_extension
+     */
+    public function test_get_extension(string $filename, string $expected) {
+        $this->resetAfterTest();
+
+        $cb = new \core_contentbank\contentbank();
+
+        $extension = $cb->get_extension($filename);
+        $this->assertEquals($expected, $extension);
+    }
+
+    /**
+     * Data provider for test_load_context_supported_extensions.
+     *
+     * @return  array
+     */
+    public function get_extension_supporters_provider() {
+        return [
+            'H5P first' => [['.h5p' => ['h5p', 'testable']], '.h5p', 'h5p'],
+            'Testable first (but upload not implemented)' => [['.h5p' => ['testable', 'h5p']], '.h5p', 'h5p'],
+        ];
+    }
+
+    /**
+     * Tests for get_extension_supporter() function with admin permissions.
+     *
+     * @dataProvider    get_extension_supporters_provider
+     * @param   array   $supporters   The content type plugin supporters for each extension
+     * @param   string  $extension    The extension of the file given
+     * @param   string  $expected   The supporter contenttype of the file
+     *
+     * @covers ::load_context_supported_extensions
+     */
+    public function test_get_extension_supporter_for_admins(array $supporters, string $extension, string $expected) {
+        $this->resetAfterTest();
+
+        $cb = new \core_contentbank\contentbank();
+        $expectedsupporters = [$extension => $expected];
+
+        $systemcontext = context_system::instance();
+
+        // All contexts allowed for admins.
+        $this->setAdminUser();
+        $contextsupporters = $cb->load_context_supported_extensions($systemcontext);
+        $this->assertEquals($expectedsupporters, $contextsupporters);
+    }
+
+    /**
+     * Tests for get_extension_supporter() function with user default permissions.
+     *
+     * @dataProvider    get_extension_supporters_provider
+     * @param   array   $supporters   The content type plugin supporters for each extension
+     * @param   string  $extension    The extension of the file given
+     * @param   string  $expected   The supporter contenttype of the file
+     *
+     * @covers ::load_context_supported_extensions
+     */
+    public function test_get_extension_supporter_for_users(array $supporters, string $extension, string $expected) {
+        $this->resetAfterTest();
+
+        $cb = new \core_contentbank\contentbank();
+        $systemcontext = context_system::instance();
+
+        // Set a user with no permissions.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // Users with no capabilities can't upload content.
+        $contextsupporters = $cb->load_context_supported_extensions($systemcontext);
+        $this->assertEquals([], $contextsupporters);
+    }
+
+    /**
+     * Tests for get_extension_supporter() function with teacher defaul permissions.
+     *
+     * @dataProvider    get_extension_supporters_provider
+     * @param   array   $supporters   The content type plugin supporters for each extension
+     * @param   string  $extension    The extension of the file given
+     * @param   string  $expected   The supporter contenttype of the file
+     *
+     * @covers ::load_context_supported_extensions
+     */
+    public function test_get_extension_supporter_for_teachers(array $supporters, string $extension, string $expected) {
+        $this->resetAfterTest();
+
+        $cb = new \core_contentbank\contentbank();
+        $expectedsupporters = [$extension => $expected];
+
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $this->setUser($teacher);
+        $coursecontext = context_course::instance($course->id);
+
+        // Teachers has permission in their context to upload supported by H5P content type.
+        $contextsupporters = $cb->load_context_supported_extensions($coursecontext);
+        $this->assertEquals($expectedsupporters, $contextsupporters);
+    }
+
+    /**
+     * Tests for get_extension_supporter() function.
+     *
+     * @dataProvider    get_extension_supporters_provider
+     * @param   array   $supporters   The content type plugin supporters for each extension
+     * @param   string  $extension    The extension of the file given
+     * @param   string  $expected   The supporter contenttype of the file
+     *
+     * @covers ::get_extension_supporter
+     */
+    public function test_get_extension_supporter(array $supporters, string $extension, string $expected) {
+        $this->resetAfterTest();
+
+        $cb = new \core_contentbank\contentbank();
+        $systemcontext = context_system::instance();
+        $this->setAdminUser();
+
+        $supporter = $cb->get_extension_supporter($extension, $systemcontext);
+        $this->assertEquals($expected, $supporter);
+    }
+}
diff --git a/contentbank/tests/contenttype_test.php b/contentbank/tests/contenttype_test.php
new file mode 100644 (file)
index 0000000..ec231a0
--- /dev/null
@@ -0,0 +1,160 @@
+<?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/>.
+
+/**
+ * Test for content bank contenttype class.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+
+use stdClass;
+use context_system;
+use contenttype_testable\contenttype as contenttype;
+/**
+ * Test for content bank contenttype class.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core_contentbank\contenttype
+ *
+ */
+class core_contenttype_contenttype_testcase extends \advanced_testcase {
+
+    /**
+     * Tests get_contenttype_name result.
+     *
+     * @covers ::get_contenttype_name
+     */
+    public function test_get_contenttype_name() {
+        $this->resetAfterTest();
+
+        $systemcontext = \context_system::instance();
+        $testable = new contenttype($systemcontext);
+
+        $this->assertEquals('contenttype_testable', $testable->get_contenttype_name());
+    }
+
+    /**
+     * Tests get_plugin_name result.
+     *
+     * @covers ::get_plugin_name
+     */
+    public function test_get_plugin_name() {
+        $this->resetAfterTest();
+
+        $systemcontext = \context_system::instance();
+        $testable = new contenttype($systemcontext);
+
+        $this->assertEquals('testable', $testable->get_plugin_name());
+    }
+
+    /**
+     * Tests get_icon result.
+     *
+     * @covers ::get_icon
+     */
+    public function test_get_icon() {
+        $this->resetAfterTest();
+
+        $systemcontext = \context_system::instance();
+        $testable = new contenttype($systemcontext);
+        $icon = $testable->get_icon('new content');
+        $this->assertContains('archive', $icon);
+    }
+
+    /**
+     * Tests is_feature_supported behavior .
+     *
+     * @covers ::is_feature_supported
+     */
+    public function test_is_feature_supported() {
+        $this->resetAfterTest();
+
+        $systemcontext = \context_system::instance();
+        $testable = new contenttype($systemcontext);
+
+        $this->assertTrue($testable->is_feature_supported(contenttype::CAN_TEST));
+        $this->assertFalse($testable->is_feature_supported(contenttype::CAN_UPLOAD));
+    }
+
+    /**
+     * Tests can_upload behavior with no implemented upload feature.
+     *
+     * @covers ::can_upload
+     */
+    public function test_no_upload_feature_supported() {
+        $this->resetAfterTest();
+
+        $systemcontext = \context_system::instance();
+        $testable = new contenttype($systemcontext);
+
+        $this->setAdminUser();
+        $this->assertFalse($testable->is_feature_supported(contenttype::CAN_UPLOAD));
+        $this->assertFalse($testable->can_upload());
+    }
+
+    /**
+     * Test create_content() with empty data.
+     *
+     * @covers ::create_content
+     */
+    public function test_create_empty_content() {
+        $this->resetAfterTest();
+
+        // Create empty content.
+        $record = new stdClass();
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->create_content($record);
+
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+        $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+    }
+
+    /**
+     * Tests for behaviour of create_content() with data.
+     *
+     * @covers ::create_content
+     */
+    public function test_create_content() {
+        $this->resetAfterTest();
+
+        // Create content.
+        $record = new stdClass();
+        $record->name = 'Test content';
+        $record->configdata = '';
+        $record->contenttype = '';
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->create_content($record);
+
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+        $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+    }
+}
diff --git a/contentbank/tests/fixtures/testable_content.php b/contentbank/tests/fixtures/testable_content.php
new file mode 100644 (file)
index 0000000..d379dea
--- /dev/null
@@ -0,0 +1,36 @@
+<?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/>.
+
+/**
+ * Testable content plugin class.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace contenttype_testable;
+
+/**
+ * Testable content plugin class.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content extends \core_contentbank\content {
+}
diff --git a/contentbank/tests/fixtures/testable_contenttype.php b/contentbank/tests/fixtures/testable_contenttype.php
new file mode 100644 (file)
index 0000000..850d4ef
--- /dev/null
@@ -0,0 +1,82 @@
+<?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/>.
+
+/**
+ * Testable contenttype plugin class.
+ *
+ * @package    core_contentbank
+ * @category   test
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace contenttype_testable;
+
+/**
+ * Testable contenttype plugin class.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contenttype extends \core_contentbank\contenttype {
+
+    /** Feature for testing */
+    const CAN_TEST = 'test';
+
+    /**
+     * Returns the URL where the content will be visualized.
+     *
+     * @param stdClass $record  Th content to be displayed.
+     * @return string            URL where to visualize the given content.
+     */
+    public function get_view_url(\stdClass $record): string {
+        $fileurl = $this->get_file_url($record->id);
+        $url = $fileurl."?forcedownload=1";
+
+        return $url;
+    }
+
+    /**
+     * Returns the HTML code to render the icon for content bank contents.
+     *
+     * @param string $contentname   The contentname to add as alt value to the icon.
+     * @return string               HTML code to render the icon
+     */
+    public function get_icon(string $contentname): string {
+        global $OUTPUT;
+
+        return $OUTPUT->pix_icon('f/archive-64', $contentname, 'moodle', ['class' => 'iconsize-big']);
+    }
+
+    /**
+     * Return an array of implemented features by this plugin.
+     *
+     * @return array
+     */
+    protected function get_implemented_features(): array {
+        return [self::CAN_TEST];
+    }
+
+    /**
+     * Return an array of extensions this plugin could manage.
+     *
+     * @return array
+     */
+    public function get_manageable_extensions(): array {
+        return  ['.txt', '.png', '.h5p'];
+    }
+}
diff --git a/contentbank/upload.php b/contentbank/upload.php
new file mode 100644 (file)
index 0000000..48b651e
--- /dev/null
@@ -0,0 +1,104 @@
+<?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/>.
+
+/**
+ * Upload a file to content bank.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require('../config.php');
+require_once("$CFG->dirroot/contentbank/files_form.php");
+
+require_login();
+
+$contextid = optional_param('contextid', \context_system::instance()->id, PARAM_INT);
+$context = context::instance_by_id($contextid, MUST_EXIST);
+
+require_capability('moodle/contentbank:upload', $context);
+
+$title = get_string('contentbank');
+\core_contentbank\helper::get_page_ready($context, $title, true);
+if ($PAGE->course) {
+    require_login($PAGE->course->id);
+}
+$returnurl = new \moodle_url('/contentbank/index.php', ['contextid' => $contextid]);
+
+$PAGE->set_url('/contentbank/upload.php');
+$PAGE->set_context($context);
+$PAGE->navbar->add(get_string('upload', 'contentbank'));
+$PAGE->set_title($title);
+$PAGE->set_heading($title);
+$PAGE->set_pagetype('contenbank');
+
+$maxbytes = $CFG->userquota;
+$maxareabytes = $CFG->userquota;
+if (has_capability('moodle/user:ignoreuserquota', $context)) {
+    $maxbytes = USER_CAN_IGNORE_FILE_SIZE_LIMITS;
+    $maxareabytes = FILE_AREA_MAX_BYTES_UNLIMITED;
+}
+
+$cb = new \core_contentbank\contentbank();
+$accepted = $cb->get_supported_extensions_as_string($context);
+
+$data = new stdClass();
+$options = array(
+    'subdirs' => 1,
+    'maxbytes' => $maxbytes,
+    'maxfiles' => -1,
+    'accepted_types' => $accepted,
+    'areamaxbytes' => $maxareabytes
+);
+file_prepare_standard_filemanager($data, 'files', $options, $context, 'contentbank', 'public', 0);
+
+$mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options]);
+
+if ($mform->is_cancelled()) {
+    redirect($returnurl);
+} else if ($formdata = $mform->get_data()) {
+    require_sesskey();
+
+    // Get the file and the contenttype to manage given file's extension.
+    $usercontext = context_user::instance($USER->id);
+    $fs = get_file_storage();
+    $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $formdata->file, 'itemid, filepath, filename', false);
+
+    if (!empty($files)) {
+        $file = reset($files);
+        $filename = $file->get_filename();
+        $extension = $cb->get_extension($filename);
+        $plugin = $cb->get_extension_supporter($extension, $context);
+        $classname = '\\contenttype_'.$plugin.'\\contenttype';
+        $record = new stdClass();
+        $record->name = $filename;
+        if (class_exists($classname)) {
+            $contentype = new $classname($context);
+            $content = $contentype->create_content($record);
+            file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
+        }
+    }
+    redirect($returnurl);
+}
+
+echo $OUTPUT->header();
+echo $OUTPUT->box_start('generalbox');
+
+$mform->display();
+
+echo $OUTPUT->box_end();
+echo $OUTPUT->footer();
diff --git a/contentbank/view.php b/contentbank/view.php
new file mode 100644 (file)
index 0000000..9514bdf
--- /dev/null
@@ -0,0 +1,67 @@
+<?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/>.
+
+/**
+ * Generic content bank visualizer.
+ *
+ * @package   core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require('../config.php');
+
+require_login();
+
+$id = required_param('id', PARAM_INT);
+$record = $DB->get_record('contentbank_content', ['id' => $id], '*', MUST_EXIST);
+$context = context::instance_by_id($record->contextid, MUST_EXIST);
+require_capability('moodle/contentbank:access', $context);
+
+$returnurl = new \moodle_url('/contentbank/index.php');
+$plugin = core_plugin_manager::instance()->get_plugin_info($record->contenttype);
+if (!$plugin || !$plugin->is_enabled()) {
+    print_error('unsupported', 'core_contentbank', $returnurl);
+}
+
+$title = get_string('contentbank');
+\core_contentbank\helper::get_page_ready($context, $title, true);
+if ($PAGE->course) {
+    require_login($PAGE->course->id);
+}
+$returnurl = new \moodle_url('/contentbank/index.php', ['contextid' => $context->id]);
+
+$PAGE->set_url(new \moodle_url('/contentbank/view.php', ['id' => $id]));
+$PAGE->set_context($context);
+$PAGE->navbar->add($record->name);
+$PAGE->set_heading($title);
+$title .= ": ".$record->name;
+$PAGE->set_title($title);
+$PAGE->set_pagetype('contenbank');
+
+echo $OUTPUT->header();
+echo $OUTPUT->box_start('generalbox');
+
+$managerlass = "\\$record->contenttype\\contenttype";
+if (class_exists($managerlass)) {
+    $manager = new $managerlass($context);
+    if ($manager->can_access()) {
+        echo $manager->get_view_content($record);
+    }
+}
+
+echo $OUTPUT->box_end();
+echo $OUTPUT->footer();
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 7f4beaa..f42fa95 100644 (file)
@@ -200,10 +200,7 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
 
         $o.= html_writer::start_tag('li', array('id' => 'section-'.$section->section,
             'class' => 'section main clearfix'.$sectionstyle, 'role'=>'region',
-            'aria-label'=> get_section_name($course, $section)));
-
-        // Create a span that contains the section title to be used to create the keyboard section move menu.
-        $o .= html_writer::tag('span', get_section_name($course, $section), array('class' => 'hidden sectionname'));
+            'aria-labelledby' => "sectionid-{$section->id}-title"));
 
         $leftcontent = $this->section_left_content($section, $course, $onsectionpage);
         $o.= html_writer::tag('div', $leftcontent, array('class' => 'left side'));
@@ -223,7 +220,7 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
             $classes = '';
         }
         $sectionname = html_writer::tag('span', $this->section_title($section, $course));
-        $o.= $this->output->heading($sectionname, 3, 'sectionname' . $classes);
+        $o .= $this->output->heading($sectionname, 3, 'sectionname' . $classes, "sectionid-{$section->id}-title");
 
         $o .= $this->section_availability($section);
 
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 5cc4192..b736c9b 100644 (file)
@@ -399,6 +399,7 @@ $string['contextlockappliestoadmin'] = 'Context freezing applies to administrato
 $string['contextlockappliestoadmin_desc'] = 'If disabled, administrators remain with write access to any frozen contexts.';
 $string['cookiehttponly'] = 'Only http cookies';
 $string['cookiesecure'] = 'Secure cookies only';
+$string['contenttypeuninstalling'] = 'There are {$a->count} contents supported by {$a->type}. They will be deleted. Are you sure you want to proceed?';
 $string['country'] = 'Default country';
 $string['course_customfield'] = 'Course custom fields';
 $string['coursecolor'] = 'Colour {$a}';
index 2bb8a14..862ed68 100644 (file)
@@ -41,6 +41,8 @@ $string['cachedef_calendar_subscriptions'] = 'Calendar subscriptions';
 $string['cachedef_calendar_categories'] = 'Calendar course categories that a user can access';
 $string['cachedef_capabilities'] = 'System capabilities list';
 $string['cachedef_config'] = 'Config settings';
+$string['cachedef_contentbank_enabled_extensions'] = 'Allowed extensions and its supporter plugins in content bank';
+$string['cachedef_contentbank_context_extensions'] = 'Allowed extensions and its supporter plugins in a content bank context';
 $string['cachedef_coursecat'] = 'Course categories lists for particular user';
 $string['cachedef_coursecatrecords'] = 'Course categories records';
 $string['cachedef_coursecattree'] = 'Course categories tree';
diff --git a/lang/en/contentbank.php b/lang/en/contentbank.php
new file mode 100644 (file)
index 0000000..dbcb279
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Strings for component 'contentbank', language 'en'
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['author'] = 'Author';
+$string['file'] = 'Upload content';
+$string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
+$string['name'] = 'Content';
+$string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
+$string['privacy:metadata:content:usercreated'] = 'The user has created the content.';
+$string['privacy:metadata:content:usermodified'] = 'Last user has modified the content.';
+$string['timecreated'] = 'Time created';
+$string['unsupported'] = 'This content type is not supported';
+$string['upload'] = 'Upload';
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 61674dd..a1f5006 100644 (file)
@@ -181,6 +181,7 @@ $string['commentmisconf'] = 'Comment ID is misconfigured';
 $string['componentisuptodate'] = 'Component is up-to-date';
 $string['confirmationnotenabled'] = 'User confirmation is not enabled on this site';
 $string['confirmsesskeybad'] = 'Sorry, but your session key could not be confirmed to carry out this action.  This security feature prevents against accidental or malicious execution of important functions in your name.  Please make sure you really wanted to execute this function.';
+$string['contenttypenotfound'] = 'The \'{$a}\' content bank type doesn\'t exist or is not recognized';
 $string['couldnotassignrole'] = 'A serious but unspecified error occurred while trying to assign a role to you';
 $string['couldnotupdatenoexistinguser'] = 'Cannot update the user - user doesn\'t exist';
 $string['couldnotverifyagedigitalconsent'] = 'An error occurred while trying to verify the age of digital consent.<br />Please contact administrator.';
@@ -312,6 +313,7 @@ $string['invalidcommentarea'] = 'Invalid comment area';
 $string['invalidcommentparam'] = 'Invalid comment parameters';
 $string['invalidcomponent'] = 'Invalid component name';
 $string['invalidconfirmdata'] = 'Invalid confirmation data';
+$string['invalidcontentid'] = 'Invalid content ID';
 $string['invalidcontext'] = 'Invalid context';
 $string['invalidcourse'] = 'Invalid course';
 $string['invalidcourseid'] = 'You are trying to use an invalid course ID';
index 89c386b..eae72de 100644 (file)
@@ -274,6 +274,7 @@ $string['confirmcheckfull'] = 'Are you absolutely sure you want to confirm {$a}
 $string['confirmcoursemove'] = 'Are you sure you want to move this course ({$a->course}) into this category ({$a->category})?';
 $string['considereddigitalminor'] = 'You are too young to create an account on this site.';
 $string['content'] = 'Content';
+$string['contentbank'] = 'Content bank';
 $string['continue'] = 'Continue';
 $string['continuetocourse'] = 'Click here to enter your course';
 $string['convertingwikitomarkdown'] = 'Converting wiki to Markdown';
@@ -1182,6 +1183,7 @@ $string['makethismyhome'] = 'Make this my home page';
 $string['makeunavailable'] = 'Make unavailable';
 $string['manageblocks'] = 'Blocks';
 $string['managecategorythis'] = 'Manage this category';
+$string['managecontentbanktypes'] = 'Manage content bank content types';
 $string['managecourses'] = 'Manage courses';
 $string['managedataformats'] = 'Manage data formats';
 $string['managedatabase'] = 'Database';
index caaf671..e9d2fc7 100644 (file)
@@ -124,6 +124,8 @@ $string['type_cachestore'] = 'Cache store';
 $string['type_cachestore_plural'] = 'Cache stores';
 $string['type_calendartype'] = 'Calendar type';
 $string['type_calendartype_plural'] = 'Calendar types';
+$string['type_contentbank'] = 'Content bank';
+$string['type_contentbank_plural'] = 'Content bank plugins';
 $string['type_customfield'] = 'Custom field';
 $string['type_customfield_plural'] = 'Custom fields';
 $string['type_coursereport'] = 'Course report';
index 0ae3f80..b6e2904 100644 (file)
@@ -150,6 +150,8 @@ $string['confirmunassign'] = 'Are you sure you wish to remove this role from thi
 $string['confirmunassigntitle'] = 'Confirm role change';
 $string['confirmunassignyes'] = 'Remove';
 $string['confirmunassignno'] = 'Cancel';
+$string['contentbank:access'] = 'Access the content bank';
+$string['contentbank:upload'] = 'Upload new content in the content bank';
 $string['context'] = 'Context';
 $string['course:activityvisibility'] = 'Hide/show activities';
 $string['course:bulkmessaging'] = 'Send a message to many people';
index 1713735..fda6915 100644 (file)
@@ -8257,6 +8257,153 @@ class admin_setting_managemediaplayers extends admin_setting {
     }
 }
 
+
+/**
+ * Content bank content types manager. Allow reorder and to enable/disable content bank content types and jump to settings
+ *
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class admin_setting_managecontentbankcontenttypes extends admin_setting {
+
+    /**
+     * Calls parent::__construct with specific arguments
+     */
+    public function __construct() {
+        $this->nosave = true;
+        parent::__construct('contentbank', new lang_string('managecontentbanktypes'), '', '');
+    }
+
+    /**
+     * Always returns true
+     *
+     * @return true
+     */
+    public function get_setting() {
+        return true;
+    }
+
+    /**
+     * Always returns true
+     *
+     * @return true
+     */
+    public function get_defaultsetting() {
+        return true;
+    }
+
+    /**
+     * Always returns '' and doesn't write anything
+     *
+     * @param mixed $data string or array, must not be NULL
+     * @return string Always returns ''
+     */
+    public function write_setting($data) {
+        // Do not write any setting.
+        return '';
+    }
+
+    /**
+     * Search to find if Query is related to content bank plugin
+     *
+     * @param string $query The string to search for
+     * @return bool true for related false for not
+     */
+    public function is_related($query) {
+        if (parent::is_related($query)) {
+            return true;
+        }
+        $types = core_plugin_manager::instance()->get_plugins_of_type('contenttype');
+        foreach ($types as $type) {
+            if (strpos($type->component, $query) !== false ||
+                strpos(core_text::strtolower($type->displayname), $query) !== false) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Return XHTML to display control
+     *
+     * @param mixed $data Unused
+     * @param string $query
+     * @return string highlight
+     */
+    public function output_html($data, $query='') {
+        global $CFG, $OUTPUT;
+        $return = '';
+
+        $types = core_plugin_manager::instance()->get_plugins_of_type('contenttype');
+        $txt = get_strings(array('settings', 'name', 'enable', 'disable', 'order', 'up', 'down', 'default'));
+        $txt->uninstall = get_string('uninstallplugin', 'core_admin');
+
+        $table = new html_table();
+        $table->head  = array($txt->name, $txt->enable, $txt->order, $txt->settings, $txt->uninstall);
+        $table->align = array('left', 'center', 'center', 'center', 'center');
+        $table->attributes['class'] = 'managecontentbanktable generaltable admintable';
+        $table->data  = array();
+        $spacer = $OUTPUT->pix_icon('spacer', '', 'moodle', array('class' => 'iconsmall'));
+
+        $totalenabled = 0;
+        $count = 0;
+        foreach ($types as $type) {
+            if ($type->is_enabled() && $type->is_installed_and_upgraded()) {
+                $totalenabled++;
+            }
+        }
+
+        foreach ($types as $type) {
+            $url = new moodle_url('/admin/contentbank.php',
+                array('sesskey' => sesskey(), 'name' => $type->name));
+
+            $class = '';
+            $strtypename = $type->displayname;
+            if ($type->is_enabled()) {
+                $hideshow = html_writer::link($url->out(false, array('action' => 'disable')),
+                    $OUTPUT->pix_icon('t/hide', $txt->disable, 'moodle', array('class' => 'iconsmall')));
+            } else {
+                $class = 'dimmed_text';
+                $hideshow = html_writer::link($url->out(false, array('action' => 'enable')),
+                    $OUTPUT->pix_icon('t/show', $txt->enable, 'moodle', array('class' => 'iconsmall')));
+            }
+
+            $updown = '';
+            if ($count) {
+                $updown .= html_writer::link($url->out(false, array('action' => 'up')),
+                        $OUTPUT->pix_icon('t/up', $txt->up, 'moodle', array('class' => 'iconsmall'))). '';
+            } else {
+                $updown .= $spacer;
+            }
+            if ($count < count($types) - 1) {
+                $updown .= '&nbsp;'.html_writer::link($url->out(false, array('action' => 'down')),
+                        $OUTPUT->pix_icon('t/down', $txt->down, 'moodle', array('class' => 'iconsmall')));
+            } else {
+                $updown .= $spacer;
+            }
+
+            $settings = '';
+            if ($type->get_settings_url()) {
+                $settings = html_writer::link($type->get_settings_url(), $txt->settings);
+            }
+
+            $uninstall = '';
+            if ($uninstallurl = core_plugin_manager::instance()->get_uninstall_url('contenttype_'.$type->name, 'manage')) {
+                $uninstall = html_writer::link($uninstallurl, $txt->uninstall);
+            }
+
+            $row = new html_table_row(array($strtypename, $hideshow, $updown, $settings, $uninstall));
+            if ($class) {
+                $row->attributes['class'] = $class;
+            }
+            $table->data[] = $row;
+            $count++;
+        }
+        $return .= html_writer::table($table);
+        return highlight($query, $return);
+    }
+}
+
 /**
  * Initialise admin page - this function does require login and permission
  * checks specified in page definition.
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 8f70760..6c1f4d5 100644 (file)
@@ -208,6 +208,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/cohort' => 'fa-users',
             'core:i/competencies' => 'fa-check-square-o',
             'core:i/completion_self' => 'fa-user-o',
+            'core:i/contentbank' => 'fa-paint-brush',
             'core:i/dashboard' => 'fa-tachometer',
             'core:i/lock' => 'fa-lock',
             'core:i/categoryevent' => 'fa-cubes',
@@ -333,6 +334,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/unflagged' => 'fa-flag-o',
             'core:i/unlock' => 'fa-unlock',
             'core:i/up' => 'fa-arrow-up',
+            'core:i/upload' => 'fa-upload',
             'core:i/userevent' => 'fa-user',
             'core:i/user' => 'fa-user',
             'core:i/users' => 'fa-users',
index 72be171..900c4e4 100644 (file)
@@ -1816,6 +1816,10 @@ class core_plugin_manager {
                 'gregorian'
             ),
 
+            'contenttype' => array(
+                'h5p'
+            ),
+
             'customfield' => array(
                 'checkbox', 'date', 'select', 'text', 'textarea'
             ),
diff --git a/lib/classes/plugininfo/contenttype.php b/lib/classes/plugininfo/contenttype.php
new file mode 100644 (file)
index 0000000..a92de76
--- /dev/null
@@ -0,0 +1,199 @@
+<?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/>.
+
+/**
+ * Defines classes used for plugin info.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\plugininfo;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class for contentbank plugins
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contenttype extends base {
+
+
+    /**
+     * Defines if there should be a way to uninstall the plugin via the administration UI.
+     *
+     * @return bool
+     */
+    public function is_uninstall_allowed() {
+        return true;
+    }
+
+    /**
+     * Get the name for the settings section.
+     *
+     * @return string
+     */
+    public function get_settings_section_name() {
+        return 'contentbanksetting' . $this->name;
+    }
+
+    /**
+     * Load the global settings for a particular contentbank plugin (if there are any)
+     *
+     * @param \part_of_admin_tree $adminroot
+     * @param string $parentnodename
+     * @param bool $hassiteconfig
+     */
+    public function load_settings(\part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig) {
+        global $CFG, $USER, $DB, $OUTPUT, $PAGE; // In case settings.php wants to refer to them.
+        $ADMIN = $adminroot; // May be used in settings.php.
+        $plugininfo = $this; // Also can be used inside settings.php
+        $contenttype = $this; // Also to be used inside settings.php.
+
+        if (!$this->is_installed_and_upgraded()) {
+            return;
+        }
+
+        if (!$hassiteconfig) {
+            return;
+        }
+
+        $section = $this->get_settings_section_name();
+
+        $settings = null;
+        if (file_exists($this->full_path('settings.php'))) {
+            $settings = new \admin_settingpage($section, $this->displayname, 'moodle/site:config', $this->is_enabled() === false);
+            include($this->full_path('settings.php')); // This may also set $settings to null.
+        }
+        if ($settings) {
+            $ADMIN->add($parentnodename, $settings);
+        }
+    }
+
+    /**
+     * Return URL used for management of plugins of this type.
+     * @return \moodle_url
+     */
+    public static function get_manage_url() {
+        return new \moodle_url('/admin/settings.php', array('section' => 'managecontentbanktypes'));
+    }
+
+
+    /**
+     * Gathers and returns the information about all plugins of the given type
+     *
+     * @param string $type the name of the plugintype, eg. mod, auth or workshopform
+     * @param string $typerootdir full path to the location of the plugin dir
+     * @param string $typeclass the name of the actually called class
+     * @param core_plugin_manager $pluginman the plugin manager calling this method
+     * @return array of plugintype classes, indexed by the plugin name
+     */
+    public static function get_plugins($type, $typerootdir, $typeclass, $pluginman) {
+        global $CFG;
+
+        $contents = parent::get_plugins($type, $typerootdir, $typeclass, $pluginman);
+        if (!empty($CFG->contentbank_plugins_sortorder)) {
+            $order = explode(',', $CFG->contentbank_plugins_sortorder);
+            $order = array_merge(array_intersect($order, array_keys($contents)),
+                array_diff(array_keys($contents), $order));
+        } else {
+            $order = array_keys($contents);
+        }
+        $sortedcontents = array();
+        foreach ($order as $contentname) {
+            $sortedcontents[$contentname] = $contents[$contentname];
+        }
+        return $sortedcontents;
+    }
+
+    /**
+     * Finds all enabled plugins, the result may include missing plugins.
+     * @return array|null of enabled plugins $pluginname=>$pluginname, null means unknown
+     */
+    public static function get_enabled_plugins() {
+        global $CFG;
+
+        $plugins = \core_plugin_manager::instance()->get_installed_plugins('contenttype');
+
+        if (!$plugins) {
+            return array();
+        }
+
+        $plugins = array_keys($plugins);
+        // Order the plugins.
+        if (!empty($CFG->contentbank_plugins_sortorder)) {
+            $order = explode(',', $CFG->contentbank_plugins_sortorder);
+            $order = array_merge(array_intersect($order, $plugins),
+                array_diff($plugins, $order));
+        } else {
+            $order = $plugins;
+        }
+
+        // Filter to return only enabled plugins.
+        $enabled = array();
+        foreach ($order as $plugin) {
+            $disabled = get_config('contentbank_' . $plugin, 'disabled');
+            if (empty($disabled)) {
+                $enabled[$plugin] = $plugin;
+            }
+        }
+        return $enabled;
+    }
+
+    /**
+     * Optional extra warning before uninstallation adding number of existing contenttype contents.
+     *
+     * @return string
+     */
+    public function get_uninstall_extra_warning() {
+        global $DB;
+
+        $contentcount = $DB->count_records('contentbank_content', ['contenttype' => "contenttype_$this->name"]);
+        if (!$contentcount) {
+            return '';
+        }
+
+        $message = get_string('contenttypeuninstalling',
+            'core_admin',
+            (object)['count' => $contentcount, 'type' => $this->displayname]
+        );
+
+        return $message;
+    }
+
+    /**
+     * Pre-uninstall hook.
+     *
+     * This is intended for disabling of plugin, some DB table purging, etc.
+     *
+     * NOTE: to be called from uninstall_plugin() only.
+     */
+    public function uninstall_cleanup() {
+        global $DB;
+
+        $contents = $DB->get_records('contentbank_content', ['contenttype' => 'contenttype_'.$this->name]);
+        foreach ($contents as $content) {
+            $fs = get_file_storage();
+            $fs->delete_area_files($content->contextid, 'contentbank', false, $content->id);
+        }
+        $DB->delete_records('contentbank_content', ['contenttype' => 'contenttype_'.$this->name]);
+
+        parent::uninstall_cleanup();
+    }
+}
index f1c2d8a..81fcc1a 100644 (file)
@@ -35,6 +35,7 @@
         "cachestore": "cache\/stores",
         "cachelock": "cache\/locks",
         "fileconverter": "files\/converter",
+        "contenttype": "contentbank\/contenttype",
         "theme": "theme",
         "local": "local",
         "h5plib": "h5p\/h5plib"
@@ -57,6 +58,7 @@
         "comment": "comment",
         "competency": "competency",
         "completion": "completion",
+        "contentbank": "contentbank",
         "countries": null,
         "course": "course",
         "currencies": null,
index 95e1d4f..c74645b 100644 (file)
@@ -2479,5 +2479,27 @@ $capabilities = array(
         'archetypes' => [
             'manager' => CAP_ALLOW,
         ]
-    ]
+    ],
+
+    // Content bank capabilities.
+    'moodle/contentbank:access' => array(
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        )
+    ),
+
+    'moodle/contentbank:upload' => array(
+        'riskbitmask' => RISK_SPAM,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        )
+    )
 );
index ce4c641..fc05d97 100644 (file)
@@ -432,4 +432,18 @@ $definitions = array(
         'mode' => cache_store::MODE_APPLICATION,
         'simplekeys' => true,
     ],
+
+    // Caches contentbank extensions management.
+    'contentbank_enabled_extensions' => [
+        'mode' => cache_store::MODE_APPLICATION,
+        'simplekeys' => true,
+        'simpledata' => true,
+        'staticacceleration' => true,
+    ],
+    'contentbank_context_extensions' => [
+        'mode' => cache_store::MODE_REQUEST,
+        'simplekeys' => true,
+        'simpledata' => true,
+        'staticacceleration' => true,
+    ],
 );
index 9a30e74..0fd662a 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20191109" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20200415" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <KEY NAME="libraryid" TYPE="foreign" FIELDS="libraryid" REFTABLE="h5p_libraries" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="contentbank_content" COMMENT="This table stores content data in the content bank.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="contenttype" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="References context.id."/>
+        <FIELD NAME="instanceid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="configdata" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="usercreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The original author of the content"/>
+        <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
+        <KEY NAME="usermodified" TYPE="foreign" FIELDS="usermodified" REFTABLE="user" REFFIELDS="id"/>
+        <KEY NAME="usercreated" TYPE="foreign" FIELDS="usercreated" REFTABLE="user" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="name" UNIQUE="false" FIELDS="name"/>
+        <INDEX NAME="instance" UNIQUE="false" FIELDS="contextid, contenttype, instanceid"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index dc266b3..e27a541 100644 (file)
@@ -2258,5 +2258,38 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020040700.00);
     }
 
+    if ($oldversion < 2020041500.00) {
+        // Define table to store contentbank contents.
+        $table = new xmldb_table('contentbank_content');
+
+        // Adding fields to table content_bank.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('contenttype', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('instanceid', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('configdata', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('usercreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, null, null, '0');
+
+        // Adding keys to table contentbank_content.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('contextid', XMLDB_KEY_FOREIGN, ['contextid'], 'context', ['id']);
+        $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']);
+        $table->add_key('usercreated', XMLDB_KEY_FOREIGN, ['usercreated'], 'user', ['id']);
+
+        // Adding indexes to table contentbank_content.
+        $table->add_index('name', XMLDB_INDEX_NOTUNIQUE, ['name']);
+        $table->add_index('instance', XMLDB_INDEX_NOTUNIQUE, ['contextid', 'contenttype', 'instanceid']);
+
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020041500.00);
+    }
     return true;
 }
index c8e98f5..ce314c7 100644 (file)
@@ -4907,8 +4907,29 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null, $offlin
             \core\session\manager::write_close(); // Unlock session during file serving.
             send_stored_file($file, 60*60, 0, $forcedownload, $sendfileoptions);
         }
+    } else if ($component === 'contentbank') {
+        if ($filearea != 'public' || isguestuser()) {
+            send_file_not_found();
+        }
+
+        if ($context->contextlevel == CONTEXT_SYSTEM || $context->contextlevel == CONTEXT_COURSECAT) {
+            require_login();
+        } else if ($context->contextlevel == CONTEXT_COURSE) {
+            require_login($course);
+        } else {
+            send_file_not_found();
+        }
+
+        $itemid = (int)array_shift($args);
+        $filename = array_pop($args);
+        $filepath = $args ? '/'.implode('/', $args).'/' : '/';
+        if (!$file = $fs->get_file($context->id, $component, $filearea, $itemid, $filepath, $filename) or
+            $file->is_directory()) {
+            send_file_not_found();
+        }
 
-        // ========================================================================================================================
+        \core\session\manager::write_close(); // Unlock session during file serving.
+        send_stored_file($file, 0, 0, true, $sendfileoptions); // must force download - security!
     } else if (strpos($component, 'mod_') === 0) {
         $modname = substr($component, 4);
         if (!file_exists("$CFG->dirroot/mod/$modname/lib.php")) {
index c068e0e..6d4a2be 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);
@@ -4424,10 +4430,15 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f
     if (!\core\session\manager::validate_login_token($logintoken)) {
         $failurereason = AUTH_LOGIN_FAILED;
 
-        // Trigger login failed event.
-        $event = \core\event\user_login_failed::create(array('userid' => $user->id,
-                'other' => array('username' => $username, 'reason' => $failurereason)));
-        $event->trigger();
+        // Trigger login failed event (specifying the ID of the found user, if available).
+        \core\event\user_login_failed::create([
+            'userid' => ($user->id ?? 0),
+            'other' => [
+                'username' => $username,
+                'reason' => $failurereason,
+            ],
+        ])->trigger();
+
         error_log('[client '.getremoteaddr()."]  $CFG->wwwroot  Invalid Login Token:  $username  ".$_SERVER['HTTP_USER_AGENT']);
         return false;
     }
index b6b2819..02a55e2 100644 (file)
@@ -2914,6 +2914,31 @@ class global_navigation extends navigation_node {
             }
         }
 
+        if (isloggedin()) {
+            $context = $this->page->context;
+            switch ($context->contextlevel) {
+                case CONTEXT_COURSECAT:
+                    $type = self::TYPE_CATEGORY;
+                    break;
+                case CONTEXT_COURSE:
+                    $type = self::TYPE_COURSE;
+                    if ($COURSE->id != $SITE->id) {
+                        break;
+                    }
+                default:
+                    $type = self::TYPE_CUSTOM;
+                    $context = $sitecontext;
+            }
+
+            $params = ['contextid' => $context->id];
+            if (has_capability('moodle/contentbank:access', $context)) {
+                $url = new moodle_url('/contentbank/index.php', $params);
+                $node = $coursenode->add(get_string('contentbank'), $url,
+                    $type, null, 'contentbank', new pix_icon('i/contentbank', ''));
+                $node->showinflatnavigation = true;
+            }
+        }
+
         return true;
     }
 
index fa9a76f..f6d432d 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;
@@ -3763,11 +3764,10 @@ class custom_menu extends custom_menu_item {
                 $setting = trim($setting);
                 if (!empty($setting)) {
                     switch ($i) {
-                        case 0:
+                        case 0: // Menu text.
                             $itemtext = ltrim($setting, '-');
-                            $itemtitle = $itemtext;
                             break;
-                        case 1:
+                        case 1: // URL.
                             try {
                                 $itemurl = new moodle_url($setting);
                             } catch (moodle_exception $exception) {
@@ -3776,10 +3776,10 @@ class custom_menu extends custom_menu_item {
                                 $itemurl = null;
                             }
                             break;
-                        case 2:
+                        case 2: // Title attribute.
                             $itemtitle = $setting;
                             break;
-                        case 3:
+                        case 3: // Language.
                             if (!empty($language)) {
                                 $itemlanguages = array_map('trim', explode(',', $setting));
                                 $itemvisible &= in_array($language, $itemlanguages);
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 42f1fbc..544d081 100644 (file)
@@ -33,7 +33,7 @@
 {{^divider}}
 {{#haschildren}}
 <li class="dropdown nav-item">
-    <a class="dropdown-toggle nav-link" id="drop-down-{{uniqid}}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" href="#" aria-controls="drop-down-menu-{{uniqid}}">
+    <a class="dropdown-toggle nav-link" id="drop-down-{{uniqid}}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" href="#" {{#title}}title="{{{title}}}"{{/title}} aria-controls="drop-down-menu-{{uniqid}}">
         {{{text}}}
     </a>
     <div class="dropdown-menu" role="menu" id="drop-down-menu-{{uniqid}}" aria-labelledby="drop-down-{{uniqid}}">
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 f721312..5f1c068 100644 (file)
@@ -36,7 +36,7 @@ class core_component_testcase extends advanced_testcase {
      * this is defined here to annoy devs that try to add more without any thinking,
      * always verify that it does not collide with any existing add-on modules and subplugins!!!
      */
-    const SUBSYSTEMCOUNT = 70;
+    const SUBSYSTEMCOUNT = 71;
 
     public function setUp() {
         $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces');
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 b782f11..0a631aa 100644 (file)
@@ -321,7 +321,7 @@ EOF;
         $itemurl = $item->get_url();
         $this->assertTrue($itemurl instanceof moodle_url);
         $this->assertEquals('http://moodle.org', $itemurl->out());
-        $this->assertEquals($item->get_text(), $item->get_title()); // Implicit title.
+        $this->assertNull($item->get_title()); // Implicit title.
 
         /** @var custom_menu_item $item */
         $item = array_shift($firstlevel);
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
+