Merge branch 'MDL-70408-master' of git://github.com/sarjona/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 13 Jan 2021 22:59:36 +0000 (23:59 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 13 Jan 2021 22:59:36 +0000 (23:59 +0100)
195 files changed:
admin/cli/uninstall_plugins.php
admin/settings/server.php
admin/tasklogs.php
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/langimport/classes/output/langimport_page.php
admin/tool/lp/tests/behat/plan_crud.feature
admin/tool/task/classes/check/maxfaildelay.php
admin/tool/task/renderer.php
admin/tool/templatelibrary/amd/build/search.min.js
admin/tool/templatelibrary/amd/build/search.min.js.map
admin/tool/templatelibrary/amd/src/search.js
admin/tool/templatelibrary/classes/output/list_templates_page.php
admin/tool/templatelibrary/templates/list_templates_page.mustache
analytics/tests/behat/manage_models.feature
auth/shibboleth/README.txt
auth/shibboleth/classes/admin_setting_special_convert_data_configfile.php [new file with mode: 0644]
auth/shibboleth/db/upgrade.php
auth/shibboleth/lang/en/auth_shibboleth.php
auth/shibboleth/settings.php
auth/shibboleth/upgrade.txt
auth/shibboleth/version.php
calendar/classes/external/calendar_event_exporter.php
cohort/assign.php
contentbank/classes/contentbank.php
contentbank/classes/external/delete_content.php
contentbank/classes/external/rename_content.php
contentbank/view.php
course/management.php
course/modedit.php
course/tests/behat/coursetags.feature
course/tests/behat/rename_roles.feature
course/tests/services_content_item_service_test.php
enrol/fee/classes/plugin.php
enrol/manual/tests/behat/quickenrolment.feature
files/classes/external/get/unused_draft.php [new file with mode: 0644]
files/tests/externallib_test.php
filter/tex/filter.php
grade/classes/grades/grader/gradingpanel/point/external/fetch.php
grade/classes/grades/grader/gradingpanel/scale/external/fetch.php
grade/grading/form/guide/classes/grades/grader/gradingpanel/external/fetch.php
grade/grading/form/guide/lib.php
grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_fetch_test.php
grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/fetch.php
grade/grading/form/rubric/styles.css
grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_fetch_test.php
grade/tests/behat/grade_to_pass.feature
grade/tests/grades_grader_gradingpanel_point_external_fetch_test.php
grade/tests/grades_grader_gradingpanel_scale_external_fetch_test.php
group/tests/behat/create_groups.feature
group/tests/behat/group_description.feature
h5p/classes/api.php
install/lang/ar/langconfig.php
install/lang/el/install.php
install/lang/en_us_k12/langconfig.php
install/lang/he/langconfig.php
install/lang/id/admin.php
install/lang/sv/error.php
install/lang/sv/install.php
iplookup/tests/geoplugin_test.php
lang/en/admin.php
lang/en/badges.php
lang/en/cache.php
lang/en/grades.php
lang/en/message.php
lang/en/moodle.php
lang/en/payment.php
lib/adminlib.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/build/loglevel.min.js
lib/amd/build/loglevel.min.js.map
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/modal_backdrop.min.js
lib/amd/build/modal_backdrop.min.js.map
lib/amd/build/network.min.js
lib/amd/build/network.min.js.map
lib/amd/build/tree.min.js
lib/amd/build/tree.min.js.map
lib/amd/src/form-autocomplete.js
lib/amd/src/loglevel.js
lib/amd/src/modal.js
lib/amd/src/modal_backdrop.js
lib/amd/src/network.js
lib/amd/src/tree.js
lib/behat/behat_field_manager.php
lib/behat/form_field/behat_form_autocomplete.php
lib/classes/oauth2/client.php
lib/classes/output/mustache_pix_helper.php
lib/classes/task/manager.php
lib/db/caches.php
lib/db/services.php
lib/form/templates/element-advcheckbox.mustache
lib/form/templates/element-checkbox.mustache
lib/form/templates/element-radio.mustache
lib/form/templates/element-template.mustache
lib/gradelib.php
lib/horde/framework/Horde/Idna.php
lib/horde/framework/Horde/Imap/Client/Data/Thread.php
lib/horde/framework/Horde/Imap/Client/Search/Query.php
lib/horde/framework/Horde/Imap/Client/Socket.php
lib/horde/framework/Horde/Imap/Client/Socket/ClientSort.php
lib/horde/framework/Horde/Mime/Mail.php
lib/horde/framework/Horde/Socket/Client.php
lib/horde/framework/Horde/Util.php
lib/horde/readme_moodle.txt
lib/moodlelib.php
lib/myprofilelib.php
lib/outputcomponents.php
lib/outputlib.php
lib/outputrenderers.php
lib/outputrequirementslib.php
lib/setup.php
lib/templates/form_autocomplete_input.mustache
lib/templates/permissionmanager_role.mustache
lib/templates/search_input.mustache
lib/templates/search_input_auto.mustache
lib/templates/search_input_navbar.mustache
lib/tests/behat/behat_forms.php
lib/tests/outputcomponents_test.php
lib/tests/scheduled_task_test.php
lib/tests/theme_config_test.php
lib/tests/upgradelib_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
media/player/videojs/amd/build/video-lazy.min.js
media/player/videojs/amd/build/video-lazy.min.js.map
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/fonts/VideoJS.svg [changed mode: 0644->0755]
media/player/videojs/fonts/VideoJS.ttf [changed mode: 0644->0755]
media/player/videojs/fonts/VideoJS.woff [changed mode: 0644->0755]
media/player/videojs/readme_moodle.txt
media/player/videojs/styles.css
media/player/videojs/thirdpartylibs.xml
media/player/videojs/videojs/lang/de.js
media/player/videojs/videojs/lang/de.json
media/player/videojs/videojs/lang/gd.json [changed mode: 0644->0755]
media/player/videojs/videojs/lang/pt-BR.js
media/player/videojs/videojs/lang/pt-BR.json
media/player/videojs/videojs/lang/zh-CN.js
media/player/videojs/videojs/lang/zh-CN.json
media/player/videojs/videojs/lang/zh-Hans.js
media/player/videojs/videojs/lang/zh-Hans.json
message/classes/api.php
message/classes/helper.php
message/externallib.php
message/templates/message_drawer_view_conversation_footer_content.mustache
message/tests/externallib_test.php
mod/assign/lang/en/assign.php
mod/assign/locallib.php
mod/assign/tests/behat/steps_blind_marking.feature
mod/assign/tests/locallib_test.php
mod/data/export_form.php
mod/data/lang/en/data.php
mod/data/lang/en/deprecated.txt
mod/data/tests/behat/view_entries.feature
mod/feedback/tests/behat/coursemapping.feature
mod/forum/lang/en/forum.php
mod/forum/tests/behat/advanced_search.feature
mod/forum/tests/behat/grade_forum.feature
mod/lti/classes/local/ltiopenid/registration_helper.php
mod/lti/locallib.php
mod/lti/openid-configuration.php
mod/lti/tests/openidregistration_test.php
mod/quiz/lang/en/quiz.php
mod/quiz/override_form.php
mod/quiz/overridedelete.php
mod/quiz/overrides.php
mod/quiz/tests/behat/quiz_user_override.feature
payment/classes/helper.php
payment/gateway/paypal/db/install.xml
payment/gateway/paypal/db/upgrade.php [new file with mode: 0644]
payment/gateway/paypal/lang/en/paygw_paypal.php
payment/gateway/paypal/version.php
question/type/ddmarker/amd/build/question.min.js
question/type/ddmarker/amd/build/question.min.js.map
question/type/ddmarker/amd/src/question.js
question/type/multichoice/renderer.php
question/type/multichoice/styles.css
report/competency/tests/behat/breakdown_by_activity.feature
theme/boost/config.php
theme/boost/layout/embedded.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/boost/templates/embedded.mustache
theme/classic/style/moodle.css
theme/upgrade.txt
user/tests/behat/edit_user_roles.feature
user/tests/behat/filter_participants.feature
user/tests/behat/filter_participants_showall.feature
user/tests/behat/full_name_display.feature
user/tests/behat/view_participants_groups.feature
version.php

index 3e16237..8d9ad2b 100644 (file)
@@ -39,6 +39,8 @@ Options:
     --purge-missing             Uninstall all missing from disk plugins.
     --plugins=<plugin name>     A comma separated list of plugins to be uninstalled. E.g. mod_assign,mod_forum
     --run                       Execute uninstall. If this option is not set, then the script will be run in a dry mode.
+    --showsql                   Show sql queries before they are executed.
+    --showdebugging             Show developer level debugging information.
 
 Examples:
 
@@ -72,6 +74,8 @@ list($options, $unrecognised) = cli_get_params([
     'purge-missing' => false,
     'plugins' => false,
     'run' => false,
+    'showsql' => false,
+    'showdebugging' => false,
 ], [
     'h' => 'help'
 ]);
@@ -86,6 +90,14 @@ if ($options['help']) {
     exit(0);
 }
 
+if ($options['showdebugging']) {
+    set_debugging(DEBUG_DEVELOPER, true);
+}
+
+if ($options['showsql']) {
+    $DB->set_debug(true);
+}
+
 $pluginman = core_plugin_manager::instance();
 $plugininfo = $pluginman->get_plugins();
 
index 11541bb..beb70f3 100644 (file)
@@ -73,6 +73,22 @@ if ($hassiteconfig) {
     $temp->add(new admin_setting_configduration('sessiontimeout', new lang_string('sessiontimeout', 'admin'),
         new lang_string('configsessiontimeout', 'admin'), 8 * 60 * 60));
 
+    $sessiontimeoutwarning = new admin_setting_configduration('sessiontimeoutwarning',
+        new lang_string('sessiontimeoutwarning', 'admin'),
+        new lang_string('configsessiontimeoutwarning', 'admin'), 20 * 60);
+
+    $sessiontimeoutwarning->set_validate_function(function(int $value): string {
+        global $CFG;
+        // Check sessiontimeoutwarning is less than sessiontimeout.
+        if ($CFG->sessiontimeout <= $value) {
+            return get_string('configsessiontimeoutwarningcheck', 'admin');
+        } else {
+            return '';
+        }
+    });
+
+    $temp->add($sessiontimeoutwarning);
+
     $temp->add(new admin_setting_configtext('sessioncookie', new lang_string('sessioncookie', 'admin'),
         new lang_string('configsessioncookie', 'admin'), '', PARAM_ALPHANUM));
     $temp->add(new admin_setting_configtext('sessioncookiepath', new lang_string('sessioncookiepath', 'admin'),
index a91faa3..9d100c0 100644 (file)
@@ -32,6 +32,7 @@ $result = optional_param('result', null, PARAM_INT);
 
 $pageurl = new \moodle_url('/admin/tasklogs.php');
 $pageurl->param('filter', $filter);
+$pageurl->param('result', $result);
 
 $PAGE->set_url($pageurl);
 $PAGE->set_context(context_system::instance());
index 6619bda..ff4b208 100644 (file)
@@ -10,16 +10,12 @@ Feature: Manage data storage purposes
     And I open the action menu in "region-main" "region"
     And I choose "Purposes" in the open action menu
     And I press "Add purpose"
-    And I set the field "Name" to "Purpose 1"
-    And I set the field "Description" to "Purpose 1 description"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Lawful bases" "form_row"
-    And I click on "Contract (GDPR Art. 6.1(b))" "list_item"
-    And I click on "Legal obligation (GDPR Art 6.1(c))" "list_item"
-    And I press the escape key
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Sensitive personal data processing reasons" "form_row"
-    And I click on "Explicit consent (GDPR Art. 9.2(a))" "list_item"
-    And I press the escape key
-    And I set the field "retentionperiodnumber" to "2"
+    And I set the following fields to these values:
+      | Name                                       | Purpose 1                                                      |
+      | Description                                | Purpose 1 description                                          |
+      | Lawful bases                               | Contract (GDPR Art. 6.1(b)),Legal obligation (GDPR Art 6.1(c)) |
+      | Sensitive personal data processing reasons | Explicit consent (GDPR Art. 9.2(a))                            |
+      | retentionperiodnumber                      | 2                                                              |
     When I press "Save"
     Then I should see "Purpose 1" in the "List of data purposes" "table"
     And I should see "Contract (GDPR Art. 6.1(b))" in the "Purpose 1" "table_row"
@@ -31,14 +27,13 @@ Feature: Manage data storage purposes
   Scenario: Update a data storage purpose
     Given I open the action menu in "Purpose 1" "table_row"
     And I choose "Edit" in the open action menu
-    And I set the field "Name" to "Purpose 1 edited"
-    And I set the field "Description" to "Purpose 1 description edited"
-    And I click on "Legal obligation (GDPR Art 6.1(c))" "text" in the ".form-autocomplete-selection" "css_element"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Lawful bases" "form_row"
-    And I click on "Vital interests (GDPR Art. 6.1(d))" "list_item"
-    And I press the escape key
-    And I set the field "retentionperiodnumber" to "3"
-    And I click on "protected" "checkbox"
+    And I set the following fields to these values:
+      | Name                                       | Purpose 1 edited                                                |
+      | Description                                | Purpose 1 description edited                                    |
+      | Lawful bases                               | Contract (GDPR Art. 6.1(b)), Vital interests (GDPR Art. 6.1(d)) |
+      | Sensitive personal data processing reasons | Explicit consent (GDPR Art. 9.2(a))                             |
+      | retentionperiodnumber                      | 3                                                               |
+      | protected                                  | 1                                                               |
     When I press "Save changes"
     Then I should see "Purpose 1 edited" in the "List of data purposes" "table"
     And I should see "Purpose 1 description edited" in the "Purpose 1 edited" "table_row"
index 97ff743..1643f02 100644 (file)
@@ -22,8 +22,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 namespace tool_langimport\output;
-defined('MOODLE_INTERNAL') || die();
 
+use core_collator;
 use moodle_url;
 use renderable;
 use renderer_base;
@@ -95,6 +95,8 @@ class langimport_page implements renderable, templatable {
 
         if (!empty($this->availablelanguages)) {
             $data->toinstalloptions = [];
+
+            core_collator::asort($this->availablelanguages);
             foreach ($this->availablelanguages as $code => $language) {
                 $option = new stdClass();
                 $option->value = $code;
index 55c085a..9f3dc07 100644 (file)
@@ -53,9 +53,7 @@ Feature: Manage plearning plan
     And I follow "Home"
     And I navigate to "Competencies > Learning plan templates" in site administration
     And I click on ".template-cohorts" "css_element" in the "Science template cohort" "table_row"
-    And I click on ".form-autocomplete-downarrow" "css_element"
-    And I click on "cohort plan" item in the autocomplete list
-    And I press the escape key
+    And I set the field "Select cohorts to sync" to "cohort plan"
     When I click on "Add cohorts" "button"
     Then I should see "2 learning plans were created."
     And I follow "Learning plan templates"
index 019aa75..d6be005 100644 (file)
@@ -79,6 +79,19 @@ class maxfaildelay extends check {
             }
         }
 
+        $tasks = \core\task\manager::get_failed_adhoc_tasks();
+        foreach ($tasks as $task) {
+            $faildelay = $task->get_fail_delay();
+            if ($faildelay > $maxdelay) {
+                $maxdelay = $faildelay;
+            }
+            if ($faildelay > 0) {
+                $failures++;
+                $details .= get_string('faildelay', 'tool_task') . ': ' . format_time($faildelay);
+                $details .= ' - ' .get_class($task) . " ID = " . $task->get_id() ."<br>";
+            }
+        }
+
         if ($failures > 0) {
             // Intermittent failures are not yet a warning.
             $status = result::INFO;
index d690924..cdf7457 100644 (file)
@@ -115,7 +115,7 @@ class tool_task_renderer extends plugin_renderer_base {
             $disabled = $plugindisabled || $task->get_disabled();
 
             $runnow = '';
-            if (!$disabled && get_config('tool_task', 'enablerunnow') && $canruntasks ) {
+            if (!$plugindisabled && get_config('tool_task', 'enablerunnow') && $canruntasks ) {
                 $runnow = html_writer::div(html_writer::link(
                         new moodle_url('/admin/tool/task/schedule_task.php',
                             ['task' => $classname]),
index 74f8c50..9645fa6 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/search.min.js and b/admin/tool/templatelibrary/amd/build/search.min.js differ
index 0245bb8..c16fd85 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/search.min.js.map and b/admin/tool/templatelibrary/amd/build/search.min.js.map differ
index 9caf344..1865457 100644 (file)
@@ -52,9 +52,8 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
         } else {
             $('[data-region="list-templates"] [data-action="clearsearch"]').addClass('d-none');
         }
-        // Trigger the search.
-        document.location.hash = searchStr;
 
+        // Trigger the search.
         ajax.call([
             {methodname: 'tool_templatelibrary_list_templates',
               args: {component: componentStr, search: searchStr, themename: themename},
@@ -96,7 +95,6 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
         $(this).addClass('d-none');
     });
 
-    $('[data-region="input"]').val(document.location.hash.replace('#', ''));
     refreshSearch(config.theme);
     return {};
 });
index ebd4500..71035d8 100644 (file)
@@ -40,6 +40,22 @@ use tool_templatelibrary\api;
  */
 class list_templates_page implements renderable, templatable {
 
+    /** @var string $component The currently selected component */
+    protected $component;
+    /** @var string $search The current search */
+    protected $search;
+
+    /**
+     * Template page constructor
+     *
+     * @param string $component
+     * @param string $search
+     */
+    public function __construct(string $component = '', string $search = '') {
+        $this->component = $component;
+        $this->search = $search;
+    }
+
     /**
      * Export this data so it can be used as the context for a mustache template.
      *
@@ -75,6 +91,7 @@ class list_templates_page implements renderable, templatable {
             $components[$type]->plugins[$component] = (object) [
                 'name' => $pluginname,
                 'component' => $component,
+                'selected' => ($component === $this->component),
             ];
         }
 
@@ -87,6 +104,7 @@ class list_templates_page implements renderable, templatable {
 
         return (object) [
             'allcomponents' => array_values($components),
+            'search' => $this->search,
         ];
     }
 }
index 71d8042..0accf0a 100644 (file)
         {{/label}}
 
         {{$element}}
-            <select id="selectcomponent" class="form-control" data-field="component">
+            <select id="selectcomponent" name="component" class="form-control" data-field="component">
                 <option value="">{{#str}}all, tool_templatelibrary{{/str}}</option>
                 {{#allcomponents}}
                     <optgroup label="{{type}}">
                         {{#plugins}}
-                            <option value="{{component}}">{{name}}</option>
+                            <option value="{{component}}" {{#selected}}selected{{/selected}}>{{name}}</option>
                         {{/plugins}}
                     </optgroup>
                 {{/allcomponents}}
     {{< core_form/element-template }}
         {{$element}}
             {{< core/search_input_auto }}
-                {{$label}}{{{ searchstring }}}{{/label}}
-                {{$placeholder}}{{#str}}
-                    search, core
-                {{/str}}{{/placeholder}}
+                {{$label}}
+                    {{#str}} search, tool_templatelibrary {{/str}}
+                {{/label}}
+                {{$value}}{{ search }}{{/value}}
             {{/ core/search_input_auto }}
         {{/element}}
     {{/ core_form/element-template }}
index 816a253..d68ed78 100644 (file)
@@ -55,10 +55,7 @@ Feature: Manage analytics models
     And I choose "Create model" in the open action menu
     And I set the field "Enabled" to "Enable"
     And I select "__core_course__analytics__target__course_completion" from the "target" singleselect
-    And I open the autocomplete suggestions list
-    And I click on "Read actions amount" item in the autocomplete list
-    And I open the autocomplete suggestions list
-    And I click on "Any write action in the course" item in the autocomplete list
+    And I set the field "Indicators" to "Read actions amount, Any write action in the course"
     And I select "__core__analytics__time_splitting__single_range" from the "timesplitting" singleselect
     And I press "Save changes"
     Then I should see "No predictions available yet" in the "Students at risk of not meeting the course completion conditions" "table_row"
index d1adb68..eaaddac 100644 (file)
@@ -182,7 +182,8 @@ How to customize the way the Shibboleth user data is used in Moodle
 Among the Shibboleth settings in Moodle there is a field that should contain a
 path to a php file that can be used as data manipulation hook.
 You can use this if you want to further process the way your Shibboleth
-attributes are used in Moodle.
+attributes are used in Moodle. Due to security reasons this file cannot be
+located within the current site data directory ($CFG->dataroot).
 
 Example 1: Your Shibboleth federation uses an attribute that specifies the
            user's preferred language, but the content of this attribute is not
diff --git a/auth/shibboleth/classes/admin_setting_special_convert_data_configfile.php b/auth/shibboleth/classes/admin_setting_special_convert_data_configfile.php
new file mode 100644 (file)
index 0000000..a11ff42
--- /dev/null
@@ -0,0 +1,75 @@
+<?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/>.
+
+/**
+ * Special setting for auth_shibboleth convert_data.
+ *
+ * @package    auth_shibboleth
+ * @copyright  2020 Mihail Geshoski
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Admin settings class for the convert_data option.
+ *
+ * @package    auth_shibboleth
+ * @copyright  2020 Mihail Geshoski
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class auth_shibboleth_admin_setting_convert_data extends admin_setting_configfile {
+
+    /**
+     * Constructor.
+     *
+     * @param string $name
+     * @param string $visiblename
+     * @param string $description
+     * @param mixed $defaultdirectory
+     */
+    public function __construct($name, $visiblename, $description, $defaultdirectory) {
+        parent::__construct($name, $visiblename, $description, $defaultdirectory);
+    }
+
+    /**
+     * Validate the file path (location).
+     *
+     * This method ensures that the file defined as a data modification API exists and is not located in the site
+     * data directory ($CFG->dataroot). We should prohibit using files from the site data directory as this introduces
+     * security vulnerabilities.
+     *
+     * @param string $filepath The path to the file.
+     * @return mixed bool true for success or string:error on failure.
+     */
+    public function validate($filepath) {
+        global $CFG;
+
+        if (empty($filepath)) {
+            return true;
+        }
+
+        // Fail if the file does not exist or it is not readable by the webserver process.
+        if (!is_readable($filepath)) {
+            return get_string('auth_shib_convert_data_warning', 'auth_shibboleth');
+        }
+
+        // Fail if the absolute file path matches the currently defined dataroot path.
+        if (preg_match('/' . preg_quote($CFG->dataroot, '/') . '/', realpath($filepath))) {
+            return get_string('auth_shib_convert_data_filepath_warning', 'auth_shibboleth');
+        }
+
+        return true;
+    }
+}
index b16539d..9553db8 100644 (file)
@@ -30,7 +30,7 @@ defined('MOODLE_INTERNAL') || die();
  * @return bool result
  */
 function xmldb_auth_shibboleth_upgrade($oldversion) {
-    global $CFG, $DB;
+    global $CFG, $DB, $OUTPUT;
 
     // Automatically generated Moodle v3.5.0 release upgrade line.
     // Put any upgrade step following this.
@@ -47,5 +47,27 @@ function xmldb_auth_shibboleth_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2021052501) {
+        // The 'Data modification API' setting in the Shibboleth authentication plugin can no longer be configured
+        // to use files located within the site data directory, as it exposes the site to security risks. Therefore,
+        // we need to find every existing case and reset the 'Data modification API' setting to its default value.
+
+        $convertdataconfig = get_config('auth_shibboleth', 'convert_data');
+
+        if (preg_match('/' . preg_quote($CFG->dataroot, '/') . '/', realpath($convertdataconfig))) {
+            set_config('convert_data', '', 'auth_shibboleth');
+
+            $warn = 'Your \'Data modification API\' setting in the Shibboleth authentication plugin is currently
+            configured to use a file located within the current site data directory ($CFG->dataroot). You are no
+            longer able to use files from within this directory for this purpose as it exposes your site to security
+            risks. This setting has been reset to its default value. Please reconfigure it by providing a path
+            to a file which is not located within the site data directory.';
+
+            echo $OUTPUT->notification($warn, 'notifyproblem');
+        }
+
+        upgrade_plugin_savepoint(true, 2021052501, 'auth', 'shibboleth');
+    }
+
     return true;
 }
index ca9b65b..931d2f3 100644 (file)
@@ -38,6 +38,7 @@ $string['auth_shibboleth_select_organization'] = 'For authentication via Shibbol
 $string['auth_shib_convert_data'] = 'Data modification API';
 $string['auth_shib_convert_data_description'] = 'You can use this API to further modify the data provided by Shibboleth. Read the <a href="{$a}">README</a> for further instructions.';
 $string['auth_shib_convert_data_warning'] = 'The file does not exist or is not readable by the webserver process!';
+$string['auth_shib_convert_data_filepath_warning'] = 'You cannot use a file that is located within the current site data directory ($CFG->dataroot) as the data modification API.';
 $string['auth_shib_changepasswordurl'] = 'Password-change URL';
 $string['auth_shib_idp_list'] = 'Identity providers';
 $string['auth_shib_idp_list_description'] = 'Provide a list of Identity Provider entityIDs to let the user choose from on the login page.<br />On each line there must be a comma-separated tuple for entityID of the IdP (see the Shibboleth metadata file) and Name of IdP as it shall be displayed in the drop-down list.<br />As an optional third parameter you can add the location of a Shibboleth session initiator that shall be used in case your Moodle installation is part of a multi federation setup.';
index 86dce35..7c2117e 100644 (file)
@@ -28,6 +28,7 @@ if ($ADMIN->fulltree) {
     // We use a couple of custom admin settings since we need to massage the data before it is inserted into the DB.
     require_once($CFG->dirroot.'/auth/shibboleth/classes/admin_setting_special_wayf_select.php');
     require_once($CFG->dirroot.'/auth/shibboleth/classes/admin_setting_special_idp_configtextarea.php');
+    require_once($CFG->dirroot.'/auth/shibboleth/classes/admin_setting_special_convert_data_configfile.php');
 
     // Introductory explanation.
     $readmeurl = (new moodle_url('/auth/shibboleth/README.txt'))->out();
@@ -38,8 +39,8 @@ if ($ADMIN->fulltree) {
     $settings->add(new admin_setting_configtext('auth_shibboleth/user_attribute', get_string('username'),
             get_string('auth_shib_username_description', 'auth_shibboleth'), '', PARAM_RAW));
 
-    // COnvert Data configuration file.
-    $settings->add(new admin_setting_configfile('auth_shibboleth/convert_data',
+    // Convert Data configuration file.
+    $settings->add(new auth_shibboleth_admin_setting_convert_data('auth_shibboleth/convert_data',
             get_string('auth_shib_convert_data', 'auth_shibboleth'),
             get_string('auth_shib_convert_data_description', 'auth_shibboleth', $readmeurl), ''));
 
index 9083025..0acea5d 100644 (file)
@@ -1,6 +1,11 @@
 This files describes API changes in /auth/shibboleth/*,
 information provided here is intended especially for developers.
 
+=== 3.11 ===
+
+* The 'Data modification API' (convert_data) setting can no longer be configured to use files located within the
+  current site data directory ($CFG->dataroot), as it exposes the site to security risks.
+
 === 3.5.2 ===
 
 * Moved the public function unserializesession in auth/shibboleth/logout.php to auth/shibboleth/classes/helper.php and
index 8f361ee..4c099ee 100644 (file)
@@ -25,6 +25,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2021052501;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2021052500;        // Requires this Moodle version
 $plugin->component = 'auth_shibboleth'; // Full name of the plugin (used for diagnostics)
index e943e9c..bfa2368 100644 (file)
@@ -116,7 +116,11 @@ class calendar_event_exporter extends event_exporter_base {
         $values['islastday'] = false;
         $today = $this->related['type']->timestamp_to_date_array($this->related['today']);
 
-        $values['popupname'] = $this->event->get_name();
+        if ($hascourse) {
+            $values['popupname'] = external_format_string($this->event->get_name(), \context_course::instance($course->id), true);
+        } else {
+            $values['popupname'] = external_format_string($this->event->get_name(), \context_system::instance(), true);
+        }
 
         $times = $this->event->get_times();
         if ($duration = $times->get_duration()) {
index 4715517..511aac5 100644 (file)
@@ -115,11 +115,14 @@ if (optional_param('remove', false, PARAM_BOOL) && confirm_sesskey()) {
       </td>
       <td id="buttonscell">
           <div id="addcontrols">
-              <input name="add" id="add" type="submit" value="<?php echo $OUTPUT->larrow().'&nbsp;'.s(get_string('add')); ?>" title="<?php p(get_string('add')); ?>" /><br />
+              <input class="btn btn-secondary" name="add" id="add" type="submit" value="<?php echo $OUTPUT->larrow() . '&nbsp;' .
+                  s(get_string('add')); ?>" title="<?php p(get_string('add')); ?>" /><br />
           </div>
 
           <div id="removecontrols">
-              <input name="remove" id="remove" type="submit" value="<?php echo s(get_string('remove')).'&nbsp;'.$OUTPUT->rarrow(); ?>" title="<?php p(get_string('remove')); ?>" />
+              <input class="btn btn-secondary" name="remove" id="remove" type="submit"
+                     value="<?php echo s(get_string('remove')) . '&nbsp;' . $OUTPUT->rarrow(); ?>"
+                     title="<?php p(get_string('remove')); ?>" />
           </div>
       </td>
       <td id="potentialcell">
@@ -128,7 +131,7 @@ if (optional_param('remove', false, PARAM_BOOL) && confirm_sesskey()) {
       </td>
     </tr>
     <tr><td colspan="3" id='backcell'>
-      <input type="submit" name="cancel" value="<?php p(get_string('backtocohorts', 'cohort')); ?>" />
+      <input class="btn btn-secondary" type="submit" name="cancel" value="<?php p(get_string('backtocohorts', 'cohort')); ?>" />
     </td></tr>
   </table>
 </div></form>
index 9152067..6374055 100644 (file)
@@ -215,8 +215,7 @@ class contentbank {
 
         $records = $DB->get_records_select('contentbank_content', $sql, $params, 'name ASC');
         foreach ($records as $record) {
-            $contentclass = "\\$record->contenttype\\content";
-            $content = new $contentclass($record);
+            $content = $this->get_content_from_id($record->id);
             if ($content->is_view_allowed()) {
                 $contents[] = $content;
             }
@@ -267,14 +266,10 @@ class contentbank {
         $result = true;
         $records = $DB->get_records('contentbank_content', ['contextid' => $context->id]);
         foreach ($records as $record) {
-            $contenttypeclass = "\\$record->contenttype\\contenttype";
-            if (class_exists($contenttypeclass)) {
-                $contenttype = new $contenttypeclass($context);
-                $contentclass = "\\$record->contenttype\\content";
-                $content = new $contentclass($record);
-                if (!$contenttype->delete_content($content)) {
-                    $result = false;
-                }
+            $content = $this->get_content_from_id($record->id);
+            $contenttype = $content->get_content_type_instance();
+            if (!$contenttype->delete_content($content)) {
+                $result = false;
             }
         }
         return $result;
@@ -293,14 +288,10 @@ class contentbank {
         $result = true;
         $records = $DB->get_records('contentbank_content', ['contextid' => $from->id]);
         foreach ($records as $record) {
-            $contenttypeclass = "\\$record->contenttype\\contenttype";
-            if (class_exists($contenttypeclass)) {
-                $contenttype = new $contenttypeclass($from);
-                $contentclass = "\\$record->contenttype\\content";
-                $content = new $contentclass($record);
-                if (!$contenttype->move_content($content, $to)) {
-                    $result = false;
-                }
+            $content = $this->get_content_from_id($record->id);
+            $contenttype = $content->get_content_type_instance();
+            if (!$contenttype->move_content($content, $to)) {
+                $result = false;
             }
         }
         return $result;
index 65846aa..caf18c2 100644 (file)
@@ -30,6 +30,7 @@ defined('MOODLE_INTERNAL') || die();
 global $CFG;
 require_once($CFG->libdir . '/externallib.php');
 
+use core_contentbank\contentbank;
 use external_api;
 use external_function_parameters;
 use external_multiple_structure;
@@ -70,34 +71,31 @@ class delete_content extends external_api {
         $warnings = [];
 
         $params = self::validate_parameters(self::execute_parameters(), ['contentids' => $contentids]);
+        $cb = new contentbank();
         foreach ($params['contentids'] as $contentid) {
             try {
                 $record = $DB->get_record('contentbank_content', ['id' => $contentid], '*', MUST_EXIST);
-                $contenttypeclass = "\\$record->contenttype\\contenttype";
-                if (class_exists($contenttypeclass)) {
-                    $context = \context::instance_by_id($record->contextid, MUST_EXIST);
-                    self::validate_context($context);
-                    $contenttype = new $contenttypeclass($context);
-                    $contentclass = "\\$record->contenttype\\content";
-                    $content = new $contentclass($record);
-                    // Check capability.
-                    if ($contenttype->can_delete($content)) {
-                        // This content can be deleted.
-                        if (!$contenttype->delete_content($content)) {
-                            $warnings[] = [
-                                'item' => $contentid,
-                                'warningcode' => 'contentnotdeleted',
-                                'message' => get_string('contentnotdeleted', 'core_contentbank')
-                            ];
-                        }
-                    } else {
-                        // The user has no permission to delete this content.
+                $content = $cb->get_content_from_id($record->id);
+                $contenttype = $content->get_content_type_instance();
+                $context = \context::instance_by_id($record->contextid, MUST_EXIST);
+                self::validate_context($context);
+                // Check capability.
+                if ($contenttype->can_delete($content)) {
+                    // This content can be deleted.
+                    if (!$contenttype->delete_content($content)) {
                         $warnings[] = [
                             'item' => $contentid,
-                            'warningcode' => 'nopermissiontodelete',
-                            'message' => get_string('nopermissiontodelete', 'core_contentbank')
+                            'warningcode' => 'contentnotdeleted',
+                            'message' => get_string('contentnotdeleted', 'core_contentbank')
                         ];
                     }
+                } else {
+                    // The user has no permission to delete this content.
+                    $warnings[] = [
+                        'item' => $contentid,
+                        'warningcode' => 'nopermissiontodelete',
+                        'message' => get_string('nopermissiontodelete', 'core_contentbank')
+                    ];
                 }
             } catch (\moodle_exception $e) {
                 // The content or the context don't exist.
index 5cd9d75..1374aae 100644 (file)
@@ -29,6 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 global $CFG;
 require_once($CFG->libdir . '/externallib.php');
 
+use core_contentbank\contentbank;
 use external_api;
 use external_function_parameters;
 use external_single_structure;
@@ -76,35 +77,33 @@ class rename_content extends external_api {
             'name' => $name,
         ]);
         $params['name'] = clean_param($params['name'], PARAM_TEXT);
-        try {
-            $record = $DB->get_record('contentbank_content', ['id' => $contentid], '*', MUST_EXIST);
-            $contenttypeclass = "\\$record->contenttype\\contenttype";
-            if (class_exists($contenttypeclass)) {
+
+        // If name is empty don't try to rename and return a more detailed message.
+        if (empty(trim($params['name']))) {
+            $warnings[] = [
+                'item' => $contentid,
+                'warningcode' => 'emptynamenotallowed',
+                'message' => get_string('emptynamenotallowed', 'core_contentbank')
+            ];
+        } else {
+            try {
+                $record = $DB->get_record('contentbank_content', ['id' => $contentid], '*', MUST_EXIST);
+                $cb = new contentbank();
+                $content = $cb->get_content_from_id($record->id);
+                $contenttype = $content->get_content_type_instance();
                 $context = \context::instance_by_id($record->contextid, MUST_EXIST);
                 self::validate_context($context);
-                $contenttype = new $contenttypeclass($context);
-                $contentclass = "\\$record->contenttype\\content";
-                $content = new $contentclass($record);
                 // Check capability.
                 if ($contenttype->can_manage($content)) {
-                    if (empty(trim($name))) {
-                        // If name is empty don't try to rename and return a more detailed message.
+                    // This content can be renamed.
+                    if ($contenttype->rename_content($content, $params['name'])) {
+                        $result = true;
+                    } else {
                         $warnings[] = [
                             'item' => $contentid,
-                            'warningcode' => 'emptynamenotallowed',
-                            'message' => get_string('emptynamenotallowed', 'core_contentbank')
+                            'warningcode' => 'contentnotrenamed',
+                            'message' => get_string('contentnotrenamed', 'core_contentbank')
                         ];
-                    } else {
-                        // This content can be renamed.
-                        if ($contenttype->rename_content($content, $params['name'])) {
-                            $result = true;
-                        } else {
-                            $warnings[] = [
-                                'item' => $contentid,
-                                'warningcode' => 'contentnotrenamed',
-                                'message' => get_string('contentnotrenamed', 'core_contentbank')
-                            ];
-                        }
                     }
                 } else {
                     // The user has no permission to manage this content.
@@ -114,14 +113,14 @@ class rename_content extends external_api {
                         'message' => get_string('nopermissiontomanage', 'core_contentbank')
                     ];
                 }
+            } catch (\moodle_exception $e) {
+                // The content or the context don't exist.
+                $warnings[] = [
+                    'item' => $contentid,
+                    'warningcode' => 'exception',
+                    'message' => $e->getMessage()
+                ];
             }
-        } catch (\moodle_exception $e) {
-            // The content or the context don't exist.
-            $warnings[] = [
-                'item' => $contentid,
-                'warningcode' => 'exception',
-                'message' => $e->getMessage()
-            ];
         }
 
         return [
index 4c84b1b..46daffb 100644 (file)
@@ -58,13 +58,9 @@ $title .= ": ".$record->name;
 $PAGE->set_title($title);
 $PAGE->set_pagetype('contentbank');
 
-$contenttypeclass = "\\$record->contenttype\\contenttype";
-$contentclass = "\\$record->contenttype\\content";
-if (!class_exists($contenttypeclass) || !class_exists($contentclass)) {
-    print_error('contenttypenotfound', 'error', $returnurl, $record->contenttype);
-}
-$contenttype = new $contenttypeclass($context);
-$content = new $contentclass($record);
+$cb = new \core_contentbank\contentbank();
+$content = $cb->get_content_from_id($record->id);
+$contenttype = $content->get_content_type_instance();
 
 // Create the cog menu with all the secondary actions, such as delete, rename...
 $actionmenu = new action_menu();
index 5afc685..94e02aa 100644 (file)
@@ -455,7 +455,7 @@ if ($viewmode === 'default' || $viewmode === 'combined') {
     }
 }
 if ($viewmode === 'default' || $viewmode === 'combined') {
-    $class .= ' viewmode-cobmined';
+    $class .= ' viewmode-combined';
 } else {
     $class .= ' viewmode-'.$viewmode;
 }
index 4fa0524..71cc310 100644 (file)
@@ -60,6 +60,15 @@ if (!empty($add)) {
     // will be the closest match we have.
     navigation_node::override_active_url(course_get_url($course, $section));
 
+    // MDL-69431 Validate that $section (url param) does not exceed the maximum for this course / format.
+    // If too high (e.g. section *id* not number) non-sequential sections inserted in course_sections table.
+    // Then on import, backup fills 'gap' with empty sections (see restore_rebuild_course_cache). Avoid this.
+    $courseformat = course_get_format($course);
+    $maxsections = $courseformat->get_max_sections();
+    if ($section > $maxsections) {
+        print_error('maxsectionslimit', 'moodle', '', $maxsections);
+    }
+
     list($module, $context, $cw, $cm, $data) = prepare_new_moduleinfo_data($course, $add, $section);
     $data->return = 0;
     $data->sr = $sectionreturn;
index c2655fc..3506893 100644 (file)
@@ -38,7 +38,7 @@ Feature: Tagging courses
     And I expand all fieldsets
     Then "Mathematics" "autocomplete_suggestions" should exist
     And I set the following fields to these values:
-      | Tags | Algebra |
+      | Tags | Mathematics, Algebra |
     And I press "Save and display"
     And I am on "Course 2" course homepage
     And I navigate to "Edit settings" in current page administration
@@ -75,7 +75,7 @@ Feature: Tagging courses
     And I navigate to "Course tags" in current page administration
     Then I should see "Mathematics" in the ".form-autocomplete-selection" "css_element"
     And I set the following fields to these values:
-      | Tags | Algebra |
+      | Tags | Mathematics, Algebra |
     And I press "Save changes"
     And I am on "Course 2" course homepage
     And I navigate to "Course tags" in current page administration
index 1b3c581..00f7c81 100644 (file)
@@ -31,9 +31,11 @@ Feature: Rename roles within a course
     And "Learner" "button" should exist
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I should see "Tutor (Non-editing teacher)" in the ".form-autocomplete-suggestions" "css_element"
+    And I open the autocomplete suggestions list in the "Filter 1" "fieldset"
     And I should see "Learner (Student)" in the ".form-autocomplete-suggestions" "css_element"
+    And I press the escape key
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Tutor (Non-editing teacher)"
+
     And I click on "Student 1's role assignments" "link"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Student 1" "table_row"
     And "Tutor (Non-editing teacher)" "autocomplete_suggestions" should exist
@@ -53,6 +55,6 @@ Feature: Rename roles within a course
     And "Learner" "button" should not exist
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I open the autocomplete suggestions list in the "Filter 1" "fieldset"
     And I should see "Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
     And I should see "Student" in the ".form-autocomplete-suggestions" "css_element"
index f1924fb..f838265 100644 (file)
@@ -121,7 +121,8 @@ class services_content_item_service_testcase extends \advanced_testcase {
 
         // The call to get_all_content_items() should return the same items as for the course,
         // given the user in an editing teacher and can add manual lti instances.
-        $this->assertEquals(array_column($allcontentitems, 'name'), array_column($coursecontentitems, 'name'));
+        $this->assertContains('lti', array_column($coursecontentitems, 'name'));
+        $this->assertContains('lti', array_column($allcontentitems, 'name'));
 
         // Now removing the cap 'mod/lti:addinstance'. This will restrict those items returned by the course-specific method.
         $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
index 78cbbcd..549247c 100644 (file)
@@ -45,6 +45,10 @@ class enrol_fee_plugin extends enrol_plugin {
             $currencies[$c] = new lang_string($c, 'core_currencies');
         }
 
+        uasort($currencies, function($a, $b) {
+            return strcmp($a, $b);
+        });
+
         return $currencies;
     }
 
index cd3c979..564f69f 100644 (file)
@@ -130,18 +130,17 @@ Feature: Teacher can search and enrol users one by one into the course
   Scenario: Searching for a non-existing user
     Given I navigate to course participants
     And I press "Enrol users"
-    And I set the field "Select users" to "qwertyuiop"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    And I click on "Select users" "field"
+    And I type "qwertyuiop"
     Then I should see "No suggestions"
 
   @javascript
   Scenario: If there are less than 100 matching users, all are displayed for selection
     Given I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
-    And I click on "Student 099" item in the autocomplete list
-    Then I should see "Student 099"
+    When I click on "Select users" "field"
+    And I type "example.com"
+    Then "Student 099" "autocomplete_suggestions" should exist
 
   @javascript
   Scenario: If there are more than 100 matching users, inform there are too many.
@@ -151,8 +150,8 @@ Feature: Teacher can search and enrol users one by one into the course
       | student101  | Student   | 101      | student101@example.com  |
     And I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    When I click on "Select users" "field"
+    And I type "example.com"
     Then I should see "Too many users (>100) to show"
 
   @javascript
@@ -161,8 +160,8 @@ Feature: Teacher can search and enrol users one by one into the course
       | maxusersperpage | 5 |
     And I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "student00"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    When I click on "Select users" "field"
+    And I type "student00"
     Then I should see "Too many users (>5) to show"
 
   @javascript
@@ -177,18 +176,18 @@ Feature: Teacher can search and enrol users one by one into the course
     When I am on "Course 001" course homepage
     Then I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "student100@example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    And I click on "Select users" "field"
+    And I type "student100@example.com"
     Then I should see "student100@example.com, CITY1, GB, 1234567892, 1234567893, ABC1, ABC2"
     # Remove identity field in setting User policies
     And the following config values are set as admin:
       | showuseridentity | idnumber,email,phone1,phone2,department,institution |
-    When I am on "Course 001" course homepage
+    And I am on "Course 001" course homepage
     And I navigate to course participants
     And I press "Enrol users"
-    When I set the field "Select users" to "student100@example.com"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
-    Then I should see "student100@example.com, 1234567892, 1234567893, ABC1, ABC2"
+    And I click on "Select users" "field"
+    And I type "student100@example.com"
+    And I should see "student100@example.com, 1234567892, 1234567893, ABC1, ABC2"
 
 # The following tests are commented out as a result of MDL-66339.
 #  @javascript
diff --git a/files/classes/external/get/unused_draft.php b/files/classes/external/get/unused_draft.php
new file mode 100644 (file)
index 0000000..9bb9522
--- /dev/null
@@ -0,0 +1,100 @@
+<?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/>.
+
+/**
+ * Generate a new draft itemid for the current user.
+ *
+ * @package    core_files
+ * @since      Moodle 3.11
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_files\external\get;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->libdir . '/filelib.php');
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_warnings;
+use context_user;
+
+/**
+ * Generate a new draft itemid for the current user.
+ *
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class unused_draft extends external_api {
+
+    /**
+     * Describes the parameters for execute.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.11
+     */
+    public static function execute_parameters() : external_function_parameters {
+        return new external_function_parameters ([]);
+    }
+
+    /**
+     * Generate a new draft itemid for the current user.
+     *
+     * @return array of information containing the draft item area and possible warnings.
+     * @since Moodle 3.11
+     */
+    public static function execute() : array {
+        global $USER;
+
+        $usercontext = context_user::instance($USER->id);
+        self::validate_context($usercontext);
+
+        return [
+            'component' => 'user',
+            'contextid' => $usercontext->id,
+            'userid' => $USER->id,
+            'filearea' => 'draft',
+            'itemid' => file_get_unused_draft_itemid(),
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Describes the execute return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.11
+     */
+    public static function execute_returns() : external_single_structure {
+        return new external_single_structure(
+            [
+                'component' => new external_value(PARAM_COMPONENT, 'File area component.'),
+                'contextid' => new external_value(PARAM_INT, 'File area context.'),
+                'userid' => new external_value(PARAM_INT, 'File area user id.'),
+                'filearea' => new external_value(PARAM_ALPHA, 'File area name.'),
+                'itemid' => new external_value(PARAM_INT, 'File are item id.'),
+                'warnings' => new external_warnings(),
+            ]
+        );
+    }
+}
index 91a9e13..3e951d7 100644 (file)
@@ -346,4 +346,41 @@ class core_files_externallib_testcase extends advanced_testcase {
         $files = external_api::clean_returnvalue(core_files_external::get_files_returns(), $files);
         $this->assertCount(0, $files['files']);
     }
+
+    /**
+     * Test get_unused_draft_itemid.
+     */
+    public function test_get_unused_draft_itemid() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Add files to user draft area.
+        $result = core_files\external\get\unused_draft::execute();
+        $result = external_api::clean_returnvalue(core_files\external\get\unused_draft::execute_returns(), $result);
+
+        $filerecordinline = [
+            'contextid' => $result['contextid'],
+            'component' => $result['component'],
+            'filearea'  => $result['filearea'],
+            'itemid'    => $result['itemid'],
+            'filepath'  => '/',
+            'filename'  => 'faketxt.txt',
+        ];
+        $fs = get_file_storage();
+        $fs->create_file_from_string($filerecordinline, 'fake txt contents 1.');
+
+        // Now create a folder with a file inside.
+        $fs->create_directory($result['contextid'], $result['component'], $result['filearea'], $result['itemid'], '/fakefolder/');
+        $filerecordinline['filepath'] = '/fakefolder/';
+        $filerecordinline['filename'] = 'fakeimage.png';
+        $fs->create_file_from_string($filerecordinline, 'img...');
+
+        $context = context_user::instance($USER->id);
+        // Check two files were created (one file and one directory).
+        $files = core_files_external::get_files($context->id, 'user', 'draft', $result['itemid'], '/', '');
+        $files = external_api::clean_returnvalue(core_files_external::get_files_returns(), $files);
+        $this->assertCount(2, $files['files']);
+    }
 }
index 395638d..66f9eb0 100644 (file)
@@ -189,6 +189,9 @@ class filter_tex extends moodle_text_filter {
                 continue;
             }
 
+            // Sanitize the decoded string, because filter_text_image() injects the final string between script tags.
+            $texexp = clean_param($texexp, PARAM_TEXT);
+
             $md5 = md5($texexp);
             if (!$DB->record_exists("cache_filters", array("filter"=>"tex", "md5key"=>$md5))) {
                 $texcache = new stdClass();
index 458b64f..108724e 100644 (file)
@@ -128,7 +128,13 @@ class fetch extends external_api {
         }
 
         // Fetch the actual data.
-        $gradeduser = \core_user::get_user($gradeduserid);
+        $gradeduser = \core_user::get_user($gradeduserid, '*', MUST_EXIST);
+
+        // One can access its own grades. Others just if they're graders.
+        if ($gradeduserid != $USER->id) {
+            $gradeitem->require_user_can_grade($gradeduser, $USER);
+        }
+
         $hasgrade = $gradeitem->user_has_grade($gradeduser);
         $grade = $gradeitem->get_grade_for_user($gradeduser, $USER);
 
index 8f3bb48..3501e31 100644 (file)
@@ -127,7 +127,12 @@ class fetch extends external_api {
             throw new moodle_exception("The {$itemname} item in {$component}/{$contextid} is not configured for grading with scales");
         }
 
-        $gradeduser = \core_user::get_user($gradeduserid);
+        $gradeduser = \core_user::get_user($gradeduserid, '*', MUST_EXIST);
+
+        // One can access its own grades. Others just if they're graders.
+        if ($gradeduserid != $USER->id) {
+            $gradeitem->require_user_can_grade($gradeduser, $USER);
+        }
 
         // Set up some items we need to return on other interfaces.
         $gradegrade = \grade_grade::fetch(['itemid' => $gradeitem->get_grade_item()->id, 'userid' => $gradeduser->id]);
index f1f0f5d..3682372 100644 (file)
@@ -100,7 +100,7 @@ class fetch extends external_api {
      * @since Moodle 3.8
      */
     public static function execute(string $component, int $contextid, string $itemname, int $gradeduserid): array {
-        global $CFG;
+        global $CFG, $USER;
         require_once("{$CFG->libdir}/gradelib.php");
         [
             'component' => $component,
@@ -133,7 +133,12 @@ class fetch extends external_api {
         }
 
         // Fetch the actual data.
-        $gradeduser = core_user::get_user($gradeduserid);
+        $gradeduser = core_user::get_user($gradeduserid, '*', MUST_EXIST);
+
+        // One can access its own grades. Others just if they're graders.
+        if ($gradeduserid != $USER->id) {
+            $gradeitem->require_user_can_grade($gradeduser, $USER);
+        }
 
         return self::get_fetch_data($gradeitem, $gradeduser);
     }
index f878c5e..371a5ff 100644 (file)
@@ -657,8 +657,9 @@ class gradingform_guide_controller extends gradingform_controller {
         }
         $returnvalue['maxscore'] = $maxscore;
         $returnvalue['minscore'] = 0;
-        if (!empty($this->moduleinstance->grade)) {
-            $graderange = make_grades_menu($this->moduleinstance->grade);
+        $fieldname = \core_grades\component_gradeitems::get_field_name_for_itemname($this->component, $this->area, 'grade');
+        if (!empty($this->moduleinstance->{$fieldname})) {
+            $graderange = make_grades_menu($this->moduleinstance->{$fieldname});
             $returnvalue['modulegrade'] = count($graderange) - 1;
         }
         return $returnvalue;
index 50ec39b..77c0597 100644 (file)
@@ -178,8 +178,6 @@ class fetch_test extends advanced_testcase {
      */
     public function test_execute_fetch_graded(): void {
         $this->resetAfterTest();
-        $generator = \testing_util::get_data_generator();
-        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
 
         [
             'forum' => $forum,
@@ -189,23 +187,74 @@ class fetch_test extends advanced_testcase {
             'teacher' => $teacher,
         ] = $this->get_test_data();
 
-        $this->setUser($teacher);
+        $this->execute_and_assert_fetch($forum, $controller, $definition, $teacher, $teacher, $student);
+    }
+
+    /**
+     * Class mates should not get other's grades.
+     */
+    public function test_execute_fetch_does_not_return_data_to_other_students(): void {
+        $this->resetAfterTest();
+
+        [
+            'forum' => $forum,
+            'controller' => $controller,
+            'definition' => $definition,
+            'student' => $student,
+            'teacher' => $teacher,
+            'course' => $course,
+        ] = $this->get_test_data();
+
+        $evilstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $this->expectException(\required_capability_exception::class);
+        $this->execute_and_assert_fetch($forum, $controller, $definition, $evilstudent, $teacher, $student);
+    }
+
+    /**
+     * Grades can be returned to graded user.
+     */
+    public function test_execute_fetch_return_data_to_graded_user(): void {
+        $this->resetAfterTest();
+
+        [
+            'forum' => $forum,
+            'controller' => $controller,
+            'definition' => $definition,
+            'student' => $student,
+            'teacher' => $teacher,
+        ] = $this->get_test_data();
+
+        $this->execute_and_assert_fetch($forum, $controller, $definition, $student, $teacher, $student);
+    }
+
+    /**
+     * Executes and performs all the assertions of the fetch method with the given parameters.
+     */
+    private function execute_and_assert_fetch ($forum, $controller, $definition, $fetcheruser, $grader, $gradeduser) {
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        $this->setUser($grader);
 
         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
-        $grade = $gradeitem->get_grade_for_user($student, $teacher);
-        $instance = $gradeitem->get_advanced_grading_instance($teacher, $grade);
+        $grade = $gradeitem->get_grade_for_user($gradeduser, $grader);
+        $instance = $gradeitem->get_advanced_grading_instance($grader, $grade);
 
-        $submissiondata = $guidegenerator->get_test_form_data($controller, (int) $student->id,
+        $submissiondata = $guidegenerator->get_test_form_data($controller, (int) $gradeduser->id,
             10, 'Propper good speling',
             0, 'ASCII art is not a picture'
         );
 
-        $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+        $gradeitem->store_grade_from_formdata($gradeduser, $grader, (object) [
             'instanceid' => $instance->get_id(),
             'advancedgrading' => $submissiondata,
         ]);
+
+        $this->setUser($fetcheruser);
+
         // Set up some items we need to return on other interfaces.
-        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id);
+        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $gradeduser->id);
         $result = external_api::clean_returnvalue(fetch::execute_returns(), $result);
 
         $this->assertIsArray($result);
@@ -233,7 +282,7 @@ class fetch_test extends advanced_testcase {
         $this->assertEquals(100, $result['grade']['maxgrade']);
 
         $this->assertArrayHasKey('gradedby', $result['grade']);
-        $this->assertEquals(fullname($teacher), $result['grade']['gradedby']);
+        $this->assertEquals(fullname($grader), $result['grade']['gradedby']);
 
         $this->assertArrayHasKey('criterion', $result['grade']);
         $criteria = $result['grade']['criterion'];
@@ -326,6 +375,7 @@ class fetch_test extends advanced_testcase {
             'definition' => $definition,
             'student' => $student,
             'teacher' => $teacher,
+            'course' => $course,
         ];
     }
 
index 3632c7a..8fee77f 100644 (file)
@@ -93,7 +93,7 @@ class fetch extends external_api {
      * @since Moodle 3.8
      */
     public static function execute(string $component, int $contextid, string $itemname, int $gradeduserid): array {
-        global $CFG;
+        global $CFG, $USER;
         require_once("{$CFG->libdir}/gradelib.php");
         [
             'component' => $component,
@@ -126,7 +126,12 @@ class fetch extends external_api {
         }
 
         // Fetch the actual data.
-        $gradeduser = \core_user::get_user($gradeduserid);
+        $gradeduser = \core_user::get_user($gradeduserid, '*', MUST_EXIST);
+
+        // One can access its own grades. Others just if they're graders.
+        if ($gradeduserid != $USER->id) {
+            $gradeitem->require_user_can_grade($gradeduser, $USER);
+        }
 
         return self::get_fetch_data($gradeitem, $gradeduser);
     }
index 460fbb9..e45913b 100644 (file)
 }
 
 .gradingform_rubric .criterion .description {
-    width: 150px;
+    min-width: 150px;
     font-weight: bold;
 }
 
index 2f7525e..c62bf38 100644 (file)
@@ -186,8 +186,6 @@ class fetch_test extends advanced_testcase {
      */
     public function test_execute_fetch_graded(): void {
         $this->resetAfterTest();
-        $generator = \testing_util::get_data_generator();
-        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
 
         [
             'forum' => $forum,
@@ -197,23 +195,73 @@ class fetch_test extends advanced_testcase {
             'teacher' => $teacher,
         ] = $this->get_test_data();
 
-        $this->setUser($teacher);
+        $this->execute_and_assert_fetch($forum, $controller, $definition, $teacher, $teacher, $student);
+    }
+
+    /**
+     * Class mates should not get other's grades.
+     */
+    public function test_execute_fetch_does_not_return_data_to_other_students(): void {
+        $this->resetAfterTest();
+
+        [
+            'forum' => $forum,
+            'controller' => $controller,
+            'definition' => $definition,
+            'student' => $student,
+            'teacher' => $teacher,
+            'course' => $course,
+        ] = $this->get_test_data();
+
+        $evilstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $this->expectException(\required_capability_exception::class);
+        $this->execute_and_assert_fetch($forum, $controller, $definition, $evilstudent, $teacher, $student);
+    }
+
+    /**
+     * Grades can be returned to graded user.
+     */
+    public function test_execute_fetch_return_data_to_graded_user(): void {
+        $this->resetAfterTest();
+
+        [
+            'forum' => $forum,
+            'controller' => $controller,
+            'definition' => $definition,
+            'student' => $student,
+            'teacher' => $teacher,
+        ] = $this->get_test_data();
+
+        $this->execute_and_assert_fetch($forum, $controller, $definition, $student, $teacher, $student);
+    }
+
+    /**
+     * Executes and performs all the assertions of the fetch method with the given parameters.
+     */
+    private function execute_and_assert_fetch ($forum, $controller, $definition, $fetcheruser, $grader, $gradeduser) {
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        $this->setUser($grader);
 
         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
-        $grade = $gradeitem->get_grade_for_user($student, $teacher);
-        $instance = $gradeitem->get_advanced_grading_instance($teacher, $grade);
+        $grade = $gradeitem->get_grade_for_user($gradeduser, $grader);
+        $instance = $gradeitem->get_advanced_grading_instance($grader, $grade);
 
-        $submissiondata = $rubricgenerator->get_test_form_data($controller, (int) $student->id,
+        $submissiondata = $rubricgenerator->get_test_form_data($controller, (int) $gradeduser->id,
             0, 'Too many mistakes. Please try again.',
             2, 'Great number of pictures. Well done.'
         );
 
-        $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+        $gradeitem->store_grade_from_formdata($gradeduser, $grader, (object) [
             'instanceid' => $instance->get_id(),
             'advancedgrading' => $submissiondata,
         ]);
 
-        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id);
+        $this->setUser($fetcheruser);
+
+        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $gradeduser->id);
         $result = external_api::clean_returnvalue(fetch::execute_returns(), $result);
 
         $this->assertIsArray($result);
@@ -241,7 +289,7 @@ class fetch_test extends advanced_testcase {
         $this->assertEquals(100, $result['grade']['maxgrade']);
 
         $this->assertArrayHasKey('gradedby', $result['grade']);
-        $this->assertEquals(fullname($teacher), $result['grade']['gradedby']);
+        $this->assertEquals(fullname($grader), $result['grade']['gradedby']);
 
         $this->assertArrayHasKey('criteria', $result['grade']);
         $criteria = $result['grade']['criteria'];
@@ -348,6 +396,7 @@ class fetch_test extends advanced_testcase {
             'definition' => $definition,
             'student' => $student,
             'teacher' => $teacher,
+            'course' => $course,
         ];
     }
     /**
index 2cec4d0..0be6180 100644 (file)
@@ -209,7 +209,7 @@ Feature: We can set the grade to pass value
       | Ratings > Grade to pass  | 90                 |
     And I navigate to "View > Grader report" in the course gradebook
     And I turn editing mode on
-    And I click on "Edit  forum Rating grade for Test Forum 1" "link"
+    And I click on "Edit  forum Test Forum 1 rating" "link"
     And I expand all fieldsets
     Then the field "Grade to pass" matches value "90"
     And I set the field "Grade to pass" to "80"
index 6e61d5d..208693b 100644 (file)
@@ -161,14 +161,60 @@ class fetch_test extends advanced_testcase {
         $course = $forum->get_course_record();
         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
-        $this->setUser($teacher);
+
+        $this->execute_and_assert_fetch($forum, $teacher, $teacher, $student);
+    }
+
+    /**
+     * Class mates should not get other's grades.
+     */
+    public function test_execute_fetch_does_not_return_data_to_other_students(): void {
+        $this->resetAfterTest();
+
+        $forum = $this->get_forum_instance([
+            // Negative numbers mean a scale.
+            'grade_forum' => 5,
+        ]);
+        $course = $forum->get_course_record();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $evilstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $this->expectException(\required_capability_exception::class);
+        $this->execute_and_assert_fetch($forum, $evilstudent, $teacher, $student);
+    }
+
+    /**
+     * Grades can be returned to graded user.
+     */
+    public function test_execute_fetch_return_data_to_graded_user(): void {
+        $this->resetAfterTest();
+
+        $forum = $this->get_forum_instance([
+            // Negative numbers mean a scale.
+            'grade_forum' => 5,
+        ]);
+        $course = $forum->get_course_record();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $this->execute_and_assert_fetch($forum, $student, $teacher, $student);
+    }
+
+    /**
+     * Executes the fetch method with the given users and returns the result.
+     */
+    private function execute_and_assert_fetch ($forum, $fetcheruser, $grader, $gradeduser) {
+        $this->setUser($grader);
 
         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
-        $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+        $gradeitem->store_grade_from_formdata($gradeduser, $grader, (object) [
             'grade' => 4,
         ]);
 
-        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id);
+        $this->setUser($fetcheruser);
+
+        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $gradeduser->id);
         $result = external_api::clean_returnvalue(fetch::execute_returns(), $result);
 
         $this->assertIsArray($result);
@@ -200,7 +246,9 @@ class fetch_test extends advanced_testcase {
         $this->assertEquals(5, $result['grade']['maxgrade']);
 
         $this->assertArrayHasKey('gradedby', $result['grade']);
-        $this->assertEquals(fullname($teacher), $result['grade']['gradedby']);
+        $this->assertEquals(fullname($grader), $result['grade']['gradedby']);
+
+        return $result;
     }
 
     /**
index cd5de19..9a8ec1d 100644 (file)
@@ -189,14 +189,75 @@ class fetch_test extends advanced_testcase {
         $course = $forum->get_course_record();
         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
         $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
-        $this->setUser($teacher);
+
+        $this->execute_and_assert_fetch($forum, $options, $scale, $teacher, $teacher, $student);
+    }
+
+    /**
+     * Class mates should not get other's grades.
+     */
+    public function test_execute_fetch_does_not_return_data_to_other_students(): void {
+        $this->resetAfterTest();
+
+        $options = [
+            'A',
+            'B',
+            'C'
+        ];
+        $scale = $this->getDataGenerator()->create_scale(['scale' => implode(',', $options)]);
+
+        $forum = $this->get_forum_instance([
+            // Negative numbers mean a scale.
+            'grade_forum' => -1 * $scale->id
+        ]);
+        $course = $forum->get_course_record();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $evilstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $this->expectException(\required_capability_exception::class);
+        $this->execute_and_assert_fetch($forum, $options, $scale, $evilstudent, $teacher, $student);
+    }
+
+    /**
+     * Grades can be returned to graded user.
+     */
+    public function test_execute_fetch_return_data_to_graded_user(): void {
+        $this->resetAfterTest();
+
+        $options = [
+            'A',
+            'B',
+            'C'
+        ];
+        $scale = $this->getDataGenerator()->create_scale(['scale' => implode(',', $options)]);
+
+        $forum = $this->get_forum_instance([
+            // Negative numbers mean a scale.
+            'grade_forum' => -1 * $scale->id
+        ]);
+        $course = $forum->get_course_record();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $this->execute_and_assert_fetch($forum, $options, $scale, $student, $teacher, $student);
+    }
+
+    /**
+     * Executes the fetch method with the given users and returns the result.
+     */
+    private function execute_and_assert_fetch ($forum, $options, $scale, $fetcheruser, $grader, $gradeduser) {
+
+        $this->setUser($grader);
 
         $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
-        $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+        $gradeitem->store_grade_from_formdata($gradeduser, $grader, (object) [
             'grade' => 2,
         ]);
 
-        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id);
+        $this->setUser($fetcheruser);
+
+        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $gradeduser->id);
         $result = external_api::clean_returnvalue(fetch::execute_returns(), $result);
 
         $this->assertIsArray($result);
@@ -204,7 +265,7 @@ class fetch_test extends advanced_testcase {
 
         $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']);
 
-        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id);
+        $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $gradeduser->id);
         $result = external_api::clean_returnvalue(fetch::execute_returns(), $result);
 
         $this->assertIsArray($result);
@@ -232,7 +293,7 @@ class fetch_test extends advanced_testcase {
         $this->assertEquals(3, $result['grade']['maxgrade']);
 
         $this->assertArrayHasKey('gradedby', $result['grade']);
-        $this->assertEquals(fullname($teacher), $result['grade']['gradedby']);
+        $this->assertEquals(fullname($grader), $result['grade']['gradedby']);
 
         $this->assertArrayHasKey('options', $result['grade']);
         $this->assertCount(count($options), $result['grade']['options']);
index 7e50089..2b4a65a 100644 (file)
@@ -50,15 +50,12 @@ Feature: Organize students into groups
     And the "members" select box should not contain "Student 1 (student1@example.com)"
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group 1" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group 1"
     And I click on "Apply filters" "button"
     And I should see "Student 0"
     And I should see "Student 1"
     And I should not see "Student 2"
-    And I click on "Group 1" "autocomplete_selection"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group 2" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group 2"
     And I click on "Apply filters" "button"
     And I should see "Student 2"
     And I should see "Student 3"
index e63ee05..087fecb 100644 (file)
@@ -46,8 +46,7 @@ Feature: The description of a group can be viewed by students and teachers
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
     And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group B" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group B"
     And I click on "Apply filters" "button"
     And I click on "Student 2" "link" in the "participants" "table"
     And I click on "Group B" "link"
@@ -96,8 +95,7 @@ Feature: The description of a group can be viewed by students and teachers
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
     And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
-    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I click on "Group B" "list_item"
+    And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Group B"
     And I click on "Apply filters" "button"
     And I click on "Student 2" "link" in the "participants" "table"
     And I click on "Group B" "link"
index cab3476..d864e79 100644 (file)
@@ -194,7 +194,10 @@ class api {
         global $DB;
 
         // Deconstruct the URL and get the pathname associated.
-        $pathnamehash = self::get_pluginfile_hash($url, $preventredirect);
+        if (self::can_access_pluginfile_hash($url, $preventredirect)) {
+            $pathnamehash = self::get_pluginfile_hash($url);
+        }
+
         if (!$pathnamehash) {
             return [false, false];
         }
@@ -337,22 +340,28 @@ class api {
      * @param factory $factory The \core_h5p\factory object
      */
     public static function delete_content_from_pluginfile_url(string $url, factory $factory): void {
+        global $DB;
+
         // Get the H5P to delete.
-        list($file, $h5p) = self::get_content_from_pluginfile_url($url);
+        $pathnamehash = self::get_pluginfile_hash($url);
+        $h5p = $DB->get_record('h5p', ['pathnamehash' => $pathnamehash]);
         if ($h5p) {
             self::delete_content($h5p, $factory);
         }
     }
 
     /**
-     * Get the pathnamehash from an H5P internal URL.
+     * If user can access pathnamehash from an H5P internal URL.
      *
      * @param  string $url H5P pluginfile URL poiting to an H5P file.
      * @param bool $preventredirect Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions
      *
-     * @return string|false pathnamehash for the file in the internal URL.
+     * @return bool if user can access pluginfile hash.
+     * @throws \moodle_exception
+     * @throws \coding_exception
+     * @throws \require_login_exception
      */
-    protected static function get_pluginfile_hash(string $url, bool $preventredirect = true) {
+    protected static function can_access_pluginfile_hash(string $url, bool $preventredirect = true): bool {
         global $USER, $CFG;
 
         // Decode the URL before start processing it.
@@ -365,7 +374,6 @@ class api {
         // We only need the slasharguments.
         $path = substr($path, strpos($path, '.php/') + 5);
         $parts = explode('/', $path);
-        $filename = array_pop($parts);
 
         // If the request is made by tokenpluginfile.php we need to avoid userprivateaccesskey.
         if (strpos($url, '/tokenpluginfile.php')) {
@@ -377,11 +385,6 @@ class api {
         $component = array_shift($parts);
         $filearea = array_shift($parts);
 
-        // Ignore draft files, because they are considered temporary files, so shouldn't be displayed.
-        if ($filearea == 'draft') {
-            return false;
-        }
-
         // Get the context.
         try {
             list($context, $course, $cm) = get_context_info_array($contextid);
@@ -415,7 +418,7 @@ class api {
                     $parentcontext = $context->get_parent_context();
                     if ($parentcontext->contextlevel === CONTEXT_COURSECAT) {
                         // Check if category is visible and user can view this category.
-                        if (!core_course_category::get($parentcontext->instanceid, IGNORE_MISSING)) {
+                        if (!\core_course_category::get($parentcontext->instanceid, IGNORE_MISSING)) {
                             send_file_not_found();
                         }
                     } else if ($parentcontext->contextlevel === CONTEXT_USER && $parentcontext->instanceid != $USER->id) {
@@ -431,7 +434,7 @@ class api {
             // For CONTEXT_MODULE and CONTEXT_COURSE check if the user is enrolled in the course.
             // And for CONTEXT_MODULE has permissions view this .h5p file.
             if ($context->contextlevel == CONTEXT_MODULE ||
-                    $context->contextlevel == CONTEXT_COURSE) {
+                $context->contextlevel == CONTEXT_COURSE) {
                 // Require login to the course first (without login to the module).
                 require_course_login($course, true, null, !$preventredirect, $preventredirect);
 
@@ -448,6 +451,54 @@ class api {
             }
         }
 
+        return true;
+    }
+
+    /**
+     * Get the pathnamehash from an H5P internal URL.
+     *
+     * @param  string $url H5P pluginfile URL poiting to an H5P file.
+     *
+     * @return string|false pathnamehash for the file in the internal URL.
+     *
+     * @throws \moodle_exception
+     */
+    protected static function get_pluginfile_hash(string $url) {
+
+        // Decode the URL before start processing it.
+        $url = new \moodle_url(urldecode($url));
+
+        // Remove params from the URL (such as the 'forcedownload=1'), to avoid errors.
+        $url->remove_params(array_keys($url->params()));
+        $path = $url->out_as_local_url();
+
+        // We only need the slasharguments.
+        $path = substr($path, strpos($path, '.php/') + 5);
+        $parts = explode('/', $path);
+        $filename = array_pop($parts);
+
+        // If the request is made by tokenpluginfile.php we need to avoid userprivateaccesskey.
+        if (strpos($url, '/tokenpluginfile.php')) {
+            array_shift($parts);
+        }
+
+        // Get the contextid, component and filearea.
+        $contextid = array_shift($parts);
+        $component = array_shift($parts);
+        $filearea = array_shift($parts);
+
+        // Ignore draft files, because they are considered temporary files, so shouldn't be displayed.
+        if ($filearea == 'draft') {
+            return false;
+        }
+
+        // Get the context.
+        try {
+            list($context, $course, $cm) = get_context_info_array($contextid);
+        } catch (\moodle_exception $e) {
+            throw new \moodle_exception('invalidcontextid', 'core_h5p');
+        }
+
         // Some components, such as mod_page or mod_resource, add the revision to the URL to prevent caching problems.
         // So the URL contains this revision number as itemid but a 0 is always stored in the files table.
         // In order to get the proper hash, a callback should be done (looking for those exceptions).
index 3ae9a6d..ff4f645 100644 (file)
@@ -29,5 +29,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = '';
 $string['thisdirection'] = 'rtl';
 $string['thislanguage'] = 'العربية';
index 3ca5689..efca936 100644 (file)
@@ -84,7 +84,7 @@ $string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
 $string['welcomep20'] = 'Βλέπετε αυτή τη σελίδα γιατί εγκαταστήσατε και ξεκινήσατε με επιτυχία το πακέτο <strong>{$a->packname} {$a->packversion}</strong> στον υπολογιστή σας. Συγχαρητήρια!';
 $string['welcomep30'] = 'Αυτή η έκδοση/διανομή <strong>{$a->installername}</strong> περιλαμβάνει τις εφαρμογές για τη δημιουργία ενός περιβάλλοντος μέσα στο οποίο θα λειτουργεί το <strong>Moodle</strong>, ονομαστικά:';
 $string['welcomep40'] = 'Το πακέτο περιλαμβάνει επίσης το <strong>Moodle {$a->moodlerelease} ({$a->moodleversion})</strong>.';
-$string['welcomep50'] = 'Η χρήση όλων των εφαρμογών σε αυτό το πακέτο υπόκειται στις αντίστοιχες άδειες. Ολόκληρο το πακέτο <strong>{$a->installername}</strong> είναι <a href="https://www.opensource.org/docs/definition_plain.html">λογισμικό ανοικτού κώδικα</a> και διανέμεται με την <a href="https://www.gnu.org/copyleft/gpl.html">GPL</a> άδεια.';
+$string['welcomep50'] = 'Η χρήση όλων των εφαρμογών σε αυτό το πακέτο υπόκειται στις αντίστοιχες άδειες χρήσης. Ολόκληρο το πακέτο <strong>{$a->installername}</strong> είναι <a href="https://www.opensource.org/docs/definition_plain.html">λογισμικό ανοικτού κώδικα</a> και διανέμεται με την άδεια χρήσης <a href="https://www.gnu.org/copyleft/gpl.html">GPL</a>.';
 $string['welcomep60'] = 'Οι παρακάτω σελίδες θα σας καθοδηγήσουν με εύκολα βήματα στην εγκατάσταση και ρύθμιση του <strong>Moodle</strong> στον υπολογιστή σας. Μπορείτε να δεχθείτε τις προεπιλεγμένες ρυθμίσεις ή προαιρετικά, να τις τροποποιήσετε ανάλογα με τις ανάγκες σας.';
 $string['welcomep70'] = 'Πατήστε το κουμπί «Συνέχεια» για να συνεχίσετε με την εγκατάσταση του <strong>Moodle</strong>.';
 $string['wwwroot'] = 'Διεύθυνση ιστού';
index 87e0429..f37a63b 100644 (file)
@@ -29,5 +29,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = 'en_us';
 $string['thisdirection'] = 'ltr';
-$string['thislanguage'] = 'American English K12';
+$string['thislanguage'] = 'English (United States) K12';
index c7b3cc6..9d06728 100644 (file)
@@ -29,5 +29,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = 'he';
 $string['thisdirection'] = 'rtl';
 $string['thislanguage'] = 'עברית';
index 7c147ae..a888f1f 100644 (file)
@@ -39,3 +39,4 @@ $string['cliunknowoption'] = 'Opsi tidak dikenali :
  {$a}
 Silahkan gunakan opsi --help';
 $string['cliyesnoprompt'] = 'ketik y (Ya) atau t (Tidak)';
+$string['environmentrequireinstall'] = 'harus dipasang dan diaktifkan';
index 7434b4e..aa6b691 100644 (file)
@@ -45,7 +45,7 @@ $string['dmlexceptiononinstall'] = '<p>Ett databasfel har inträffat [{$a->error
 $string['downloadedfilecheckfailed'] = 'Det gick inte att kontrollera den nedladdade filen';
 $string['invalidmd5'] = 'Kontrollvariabeln var felaktig - försök igen';
 $string['missingrequiredfield'] = 'Det saknas några obligatoriska fält';
-$string['remotedownloaderror'] = 'Nedladdningen av en komponent till din server misslyckades, var snäll och verifiera inställningar för proxy. <br /><br />Du måste ladda ner <a href="{$a->url}">{$a->url}</a> filen manuellt, kopiera den till "{$a->dest}" på din server och packa upp den där.';
+$string['remotedownloaderror'] = 'Nedladdningen av en komponent till din server misslyckades. Vänligen verifiera inställningar för proxy. <br /><br />Du måste ladda ner <a href="{$a->url}">{$a->url}</a> filen manuellt, kopiera den till "{$a->dest}" på din server och packa upp den där.';
 $string['wrongdestpath'] = 'Fel sökväg';
 $string['wrongsourcebase'] = 'Fel bas-URL till källan';
 $string['wrongzipfilename'] = 'Fel namn på ZIP-filen';
index 01e1eb1..cb25140 100644 (file)
@@ -32,8 +32,8 @@ defined('MOODLE_INTERNAL') || die();
 $string['admindirname'] = 'Katalog/mapp för administration';
 $string['availablelangs'] = 'Tillgängliga språkpaket';
 $string['chooselanguagehead'] = 'Välj ett språk';
-$string['chooselanguagesub'] = 'Var snäll och välj ett språk  för installationen. Du kommer att ha möjlighet att välja språk för webbplatsen och användarna på en senare skärm.';
-$string['clialreadyinstalled'] = 'Filen config.php finns redan, var snäll och använd admin/cli/upgrade.php om Du vill uppgradera Din webbplats.';
+$string['chooselanguagesub'] = 'Vänligen välj ett språk för installationen. Du kommer att ha möjlighet att välja språk för webbplatsen och användarna på en senare skärm.';
+$string['clialreadyinstalled'] = 'Filen config.php finns redan. Vänligen använd admin/cli/upgrade.php om Du vill uppgradera Din webbplats.';
 $string['cliinstallheader'] = 'Installationsprogram av typ kommandorad {$a} för Moodle ';
 $string['databasehost'] = 'Värd för databas';
 $string['databasename'] = 'Namn på databas';
index 491308e..e558939 100644 (file)
@@ -51,8 +51,8 @@ class core_iplookup_geoplugin_testcase extends advanced_testcase {
 
         $this->assertEquals('array', gettype($result));
         $this->assertEquals('San Francisco', $result['city']);
-        $this->assertEqualsWithDelta(-122.3933, $result['longitude'], 0.01, 'Coordinates are out of accepted tolerance');
-        $this->assertEqualsWithDelta(37.7697, $result['latitude'], 0.01, 'Coordinates are out of accepted tolerance');
+        $this->assertEqualsWithDelta(-122.3933, $result['longitude'], 0.1, 'Coordinates are out of accepted tolerance');
+        $this->assertEqualsWithDelta(37.7697, $result['latitude'], 0.1, 'Coordinates are out of accepted tolerance');
         $this->assertNull($result['error']);
         $this->assertEquals('array', gettype($result['title']));
         $this->assertEquals('San Francisco', $result['title'][0]);
index 4e1f6c3..65fa702 100644 (file)
@@ -348,6 +348,8 @@ $string['configsessioncookie'] = 'This setting customises the name of the cookie
 $string['configsessioncookiedomain'] = 'This allows you to change the domain that the Moodle cookies are available from. This is useful for Moodle customisations (e.g. authentication or enrolment plugins) that need to share Moodle session information with a web application on another subdomain. <strong>WARNING: it is strongly recommended to leave this setting at the default (empty) - an incorrect value will prevent all logins to the site.</strong>';
 $string['configsessioncookiepath'] = 'If you need to change where browsers send the Moodle cookies, you can change this setting to specify a subdirectory of your web site.  Otherwise the default \'/\' should be fine.';
 $string['configsessiontimeout'] = 'If people logged in to this site are idle for a long time (without loading pages) then they are automatically logged out (their session is ended).  This variable specifies how long this time should be.';
+$string['configsessiontimeoutwarning'] = 'If people logged in to this site are idle for a long time (without loading pages) then they are warned about their session is about to end.  This variable specifies how long this time should be.';
+$string['configsessiontimeoutwarningcheck'] = 'Session timeout warning must be less than session timeout';
 $string['configshowicalsource'] = 'Show source information for iCal events';
 $string['configshowcommentscount'] = 'Show comments count, it will cost one more query when display comments link';
 $string['configshowsiteparticipantslist'] = 'All of these site students and site teachers will be listed on the site participants list. Who shall be allowed to see this site participants list?';
@@ -503,7 +505,7 @@ $string['displayloginfailures'] = 'Display login failures';
 $string['divertallemails'] = 'Email diverting';
 $string['divertallemailsdetail'] = 'Used as a safeguard in development environments when testing emails and should not be used in production.';
 $string['divertallemailsexcept'] = 'Email diversion exceptions';
-$string['divertallemailsexcept_desc'] = 'A list of email exception rules separated by either commas or new lines. Each rule is interpreted as a regular expression, eg<pre>simone@acme.com
+$string['divertallemailsexcept_desc'] = 'A list of email exception rules separated by either commas or new lines. Each rule is interpreted as a regular expression e.g. <pre>simone@acme.com
 .*@acme.com
 fred(\\+.*)?@acme.com
 </pre>';
@@ -1191,6 +1193,7 @@ $string['sessioncookiedomain'] = 'Cookie domain';
 $string['sessioncookiepath'] = 'Cookie path';
 $string['sessionhandling'] = 'Session handling';
 $string['sessiontimeout'] = 'Timeout';
+$string['sessiontimeoutwarning'] = 'Timeout Warning';
 $string['settingdependenton'] = 'This setting may be hidden, based on the value of <strong>{$a}</strong>.';
 $string['settingfileuploads'] = 'File uploading is required for normal operation, please enable it in PHP configuration.';
 $string['settingmemorylimit'] = 'Insufficient memory detected, please set higher memory limit in PHP settings.';
index 51b61a6..ccdfd36 100644 (file)
@@ -136,7 +136,7 @@ In this area, you can select collections of badges from your backpack that you w
 $string['backpacksettings'] = 'Backpack settings';
 $string['backpackapiurl'] = 'Backpack API URL';
 $string['backpackweburl'] = 'Backpack URL';
-$string['backpackprovider'] = 'Backpack Provider';
+$string['backpackprovider'] = 'Backpack provider';
 $string['badges'] = 'Badges';
 $string['badgedetails'] = 'Badge details';
 $string['badgeimage'] = 'Image';
index 5a374dd..a0ff0c3 100644 (file)
@@ -89,6 +89,7 @@ $string['cachedef_user_favourite_course_content_items'] = 'User\'s starred items
 $string['cachedef_user_group_groupings'] = 'User\'s groupings and groups per course';
 $string['cachedef_user_course_content_items'] = 'User\'s content items (activities, resources and their subtypes) per course';
 $string['cachedef_yuimodules'] = 'YUI Module definitions';
+$string['cachedef_gradesetting'] = 'Course grade setting';
 $string['cachelock_file_default'] = 'Default file locking';
 $string['cachestores'] = 'Cache stores';
 $string['canuselocalstore'] = 'Can use local store';
index e6a73ae..9f5f867 100644 (file)
@@ -136,7 +136,7 @@ $string['coursesiamtaking'] = 'Courses I am taking';
 $string['coursesiamteaching'] = 'Courses I am teaching';
 $string['coursescales'] = 'Course scales';
 $string['coursesettings'] = 'Course settings';
-$string['coursesettingsexplanation'] = 'Course settings determine how the gradebook appears for all participants in the course.';
+$string['coursesettingsexplanation'] = 'Course grade settings determine how the gradebook appears for all participants in the course.';
 $string['coursetotal'] = 'Course total';
 $string['createcategory'] = 'Create category';
 $string['createcategoryerror'] = 'Could not create a new category';
index 77a5b18..a2ec609 100644 (file)
@@ -69,6 +69,7 @@ $string['emailtagline'] = 'This is a copy of a message sent to you at "{$a->site
 $string['enabled'] = 'Enabled';
 $string['errorcallingprocessor'] = 'Error calling defined output';
 $string['errorconversationdoesnotexist'] = 'Conversation does not exist';
+$string['errormessagetoolong'] = 'The message is longer than the maximum allowed.';
 $string['errortranslatingdefault'] = 'Error translating default setting provided by plugin, using system defaults instead.';
 $string['eventgroupmessagesent'] = 'Group message sent';
 $string['eventnotificationviewed'] = 'Notification viewed';
index 8fbcca4..95c040d 100644 (file)
@@ -1180,6 +1180,7 @@ $string['loginstepsnone'] = '<p>Hi!</p>
 <p>All you need to do is make up a username and password and use it in the form on this page!</p>
 <p>If someone else has already chosen your username then you\'ll have to try again using a different username.</p>';
 $string['loginto'] = 'Log in to {$a}';
+$string['loginagain'] = 'Log in again';
 $string['logout'] = 'Log out';
 $string['logoutconfirm'] = 'Do you really want to log out?';
 $string['logs'] = 'Logs';
index 1081158..f084db5 100644 (file)
@@ -28,9 +28,9 @@ $string['accountconfignote'] = 'Payment gateways for this account will be config
 $string['accountidnumber'] = 'ID number';
 $string['accountidnumber_help'] = 'The ID number is only used when matching the account against external systems and is not displayed anywhere on the site. If the account has an official code name it may be entered, otherwise the field can be left blank.';
 $string['accountname'] = 'Account name';
-$string['accountname_help'] = 'How this account will be identified for teachers or managers who set up payments (for example in the course enrolment plugin)';
+$string['accountname_help'] = 'How this account will be identified for teachers or managers who set up payments (for example in the course enrolment plugin).';
 $string['accountnotavailable'] = 'Not available';
-$string['paymentaccountsexplained'] = 'Create one or multiple payment accounts for this site. Each account includes configuration for available payment gateways. The person who configures payments on the site (for example, payment for the course enrolment) will be able to chose from the available accounts.';
+$string['paymentaccountsexplained'] = 'Create one or multiple payment accounts for this site. Each account includes configuration for available payment gateways. The person who configures payments on the site (for example, payment for the course enrolment) will be able to choose from the available accounts.';
 $string['createaccount'] = 'Create payment account';
 $string['deleteorarchive'] = 'Delete or archive';
 $string['eventaccountcreated'] = 'Payment account created';
index ff74c9a..1177b59 100644 (file)
@@ -3814,6 +3814,8 @@ class admin_setting_configduration extends admin_setting {
 
     /** @var int default duration unit */
     protected $defaultunit;
+    /** @var callable|null Validation function */
+    protected $validatefunction = null;
 
     /**
      * Constructor
@@ -3837,6 +3839,36 @@ class admin_setting_configduration extends admin_setting {
         parent::__construct($name, $visiblename, $description, $defaultsetting);
     }
 
+    /**
+     * Sets a validate function.
+     *
+     * The callback will be passed one parameter, the new setting value, and should return either
+     * an empty string '' if the value is OK, or an error message if not.
+     *
+     * @param callable|null $validatefunction Validate function or null to clear
+     * @since Moodle 3.10
+     */
+    public function set_validate_function(?callable $validatefunction = null) {
+        $this->validatefunction = $validatefunction;
+    }
+
+    /**
+     * Validate the setting. This uses the callback function if provided; subclasses could override
+     * to carry out validation directly in the class.
+     *
+     * @param int $data New value being set
+     * @return string Empty string if valid, or error message text
+     * @since Moodle 3.10
+     */
+    protected function validate_setting(int $data): string {
+        // If validation function is specified, call it now.
+        if ($this->validatefunction) {
+            return call_user_func($this->validatefunction, $data);
+        } else {
+            return '';
+        }
+    }
+
     /**
      * Returns selectable units.
      * @static
@@ -3922,6 +3954,12 @@ class admin_setting_configduration extends admin_setting {
             return get_string('errorsetting', 'admin');
         }
 
+        // Validate the new setting.
+        $error = $this->validate_setting($seconds);
+        if ($error) {
+            return $error;
+        }
+
         $result = $this->config_write($this->name, $seconds);
         return ($result ? '' : get_string('errorsetting', 'admin'));
     }
index 23e42cb..71d7b14 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 5c1fe60..89cf819 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js.map and b/lib/amd/build/form-autocomplete.min.js.map differ
index 6f1faf8..864b360 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js and b/lib/amd/build/loglevel.min.js differ
index d03d56a..212cc44 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js.map and b/lib/amd/build/loglevel.min.js.map differ
index 91677e8..dfa48ea 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index e330a08..0e682d0 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index 1778e05..5ebc430 100644 (file)
Binary files a/lib/amd/build/modal_backdrop.min.js and b/lib/amd/build/modal_backdrop.min.js differ
index df2f559..057e7c1 100644 (file)
Binary files a/lib/amd/build/modal_backdrop.min.js.map and b/lib/amd/build/modal_backdrop.min.js.map differ
index 81e4444..680602f 100644 (file)
Binary files a/lib/amd/build/network.min.js and b/lib/amd/build/network.min.js differ
index b2204e1..df849d1 100644 (file)
Binary files a/lib/amd/build/network.min.js.map and b/lib/amd/build/network.min.js.map differ
index d92786b..1d9c1c8 100644 (file)
Binary files a/lib/amd/build/tree.min.js and b/lib/amd/build/tree.min.js differ
index 3eed2c1..46c0374 100644 (file)
Binary files a/lib/amd/build/tree.min.js.map and b/lib/amd/build/tree.min.js.map differ
index f7f0f21..ffe8b07 100644 (file)
@@ -1173,6 +1173,9 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
                 originalSelect.hide();
                 var container = originalSelect.parent();
 
+                // Ensure that the data-fieldtype is set for behat.
+                input.find('input').attr('data-fieldtype', 'autocomplete');
+
                 container.append(layout);
                 container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
                 container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
index e3d9fd7..af8fe16 100644 (file)
@@ -26,7 +26,7 @@
 // Copy loglevel.js into lib/amd/src/ in Moodle folder.
 // Add the license as a comment to the file and these instructions.
 
-/*! loglevel - v1.6.6 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
+/*! loglevel - v1.7.1 - https://github.com/pimterry/loglevel - (c) 2020 Tim Perry - licensed MIT */
 (function (root, definition) {
     "use strict";
     if (typeof define === 'function' && define.amd) {
@@ -42,7 +42,7 @@
     // Slightly dubious tricks to cut down minimized file size
     var noop = function() {};
     var undefinedType = "undefined";
-    var isIE = (typeof window !== undefinedType) && (
+    var isIE = (typeof window !== undefinedType) && (typeof window.navigator !== undefinedType) && (
         /Trident\/|MSIE /.test(window.navigator.userAgent)
     );
 
     function Logger(name, defaultLevel, factory) {
       var self = this;
       var currentLevel;
+
       var storageKey = "loglevel";
-      if (name) {
+      if (typeof name === "string") {
         storageKey += ":" + name;
+      } else if (typeof name === "symbol") {
+        storageKey = undefined;
       }
 
       function persistLevelIfPossible(levelNum) {
           var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
 
-          if (typeof window === undefinedType) return;
+          if (typeof window === undefinedType || !storageKey) return;
 
           // Use localStorage if available
           try {
       function getPersistedLevel() {
           var storedLevel;
 
-          if (typeof window === undefinedType) return;
+          if (typeof window === undefinedType || !storageKey) return;
 
           try {
               storedLevel = window.localStorage[storageKey];
 
     var _loggersByName = {};
     defaultLogger.getLogger = function getLogger(name) {
-        if (typeof name !== "string" || name === "") {
+        if ((typeof name !== "symbol" && typeof name !== "string") || name === "") {
           throw new TypeError("You must supply a name when creating a logger.");
         }
 
         return _loggersByName;
     };
 
+    // ES6 default export, for compatibility
+    defaultLogger['default'] = defaultLogger;
+
     return defaultLogger;
 }));
index 118d679..ea2defa 100644 (file)
@@ -102,6 +102,8 @@ define([
         this.bodyJS = null;
         this.footerJS = null;
         this.modalCount = modalCounter++;
+        this.attachmentPoint = document.createElement('div');
+        document.body.append(this.attachmentPoint);
 
         if (!this.root.is(SELECTORS.CONTAINER)) {
             Notification.exception({message: 'Element is not a modal container'});
@@ -648,7 +650,7 @@ define([
      * @returns {jQuery}
      */
     Modal.prototype.getAttachmentPoint = function() {
-        return $(Fullscreen.getElement() || document.body);
+        return $(Fullscreen.getElement() || this.attachmentPoint);
     };
 
     /**
@@ -753,6 +755,7 @@ define([
         this.hide();
         this.root.remove();
         this.root.trigger(ModalEvents.destroyed, this);
+        this.attachmentPoint.remove();
     };
 
     /**
@@ -797,7 +800,11 @@ define([
             }
 
             if (e.keyCode == KeyCodes.escape) {
-                this.hide();
+                if (this.removeOnClose) {
+                    this.destroy();
+                } else {
+                    this.hide();
+                }
             }
         }.bind(this));
 
index 1cbc1fd..7cdd2d1 100644 (file)
@@ -37,6 +37,8 @@ define(['jquery', 'core/templates', 'core/notification', 'core/fullscreen'],
     var ModalBackdrop = function(root) {
         this.root = $(root);
         this.isAttached = false;
+        this.attachmentPoint = document.createElement('div');
+        document.body.append(this.attachmentPoint);
 
         if (!this.root.is(SELECTORS.ROOT)) {
             Notification.exception({message: 'Element is not a modal backdrop'});
@@ -59,7 +61,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core/fullscreen'],
       * @returns {jQuery}
       */
      ModalBackdrop.prototype.getAttachmentPoint = function() {
-         return $(Fullscreen.getElement() || document.body);
+         return $(Fullscreen.getElement() || this.attachmentPoint);
      };
 
     /**
@@ -155,6 +157,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core/fullscreen'],
      */
     ModalBackdrop.prototype.destroy = function() {
         this.root.remove();
+        this.attachmentPoint.remove();
     };
 
     return ModalBackdrop;
index 5e21a6a..fcf1a0b 100644 (file)
@@ -32,14 +32,66 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
     var sessionTimeout = false;
     // 1/10 of session timeout, max of 10 minutes.
     var checkFrequency = Math.min((Config.sessiontimeout / 10), 600) * 1000;
-    // 1/5 of sessiontimeout.
-    var warningLimit = checkFrequency * 2;
-
+    // Check if sessiontimeoutwarning is set or double the checkFrequency.
+    var warningLimit = (Config.sessiontimeoutwarning > 0) ? (Config.sessiontimeoutwarning * 1000) : (checkFrequency * 2);
+    // First wait is minimum of remaining time or half of the session timeout.
+    var firstWait = (Config.sessiontimeoutwarning > 0) ?
+        Math.min((Config.sessiontimeout - Config.sessiontimeoutwarning) * 1000, checkFrequency * 5) : checkFrequency * 5;
     /**
      * The session time has expired - we can't extend it now.
+     * @param {Modal} modal
      */
-    var timeoutSessionExpired = function() {
+    var timeoutSessionExpired = function(modal) {
         sessionTimeout = true;
+        warningDisplayed = false;
+        closeModal(modal);
+        displaySessionExpired();
+    };
+
+    /**
+     * Close modal - this relies on modal object passed from Notification.confirm.
+     *
+     * @param {Modal} modal
+     */
+    var closeModal = function(modal) {
+        modal.destroy();
+    };
+
+    /**
+     * The session time has expired - we can't extend it now.
+     * @return {Promise}
+     */
+    var displaySessionExpired = function() {
+        // Check again if its already extended before displaying session expired popup in case multiple tabs are open.
+        var request = {
+            methodname: 'core_session_time_remaining',
+            args: { }
+        };
+
+        return Ajax.call([request], true, true, true)[0].then(function(args) {
+            if (args.timeremaining * 1000 > warningLimit) {
+                return false;
+            } else {
+                return Str.get_strings([
+                    {key: 'sessionexpired', component: 'error'},
+                    {key: 'sessionerroruser', component: 'error'},
+                    {key: 'loginagain', component: 'moodle'},
+                    {key: 'cancel', component: 'moodle'}
+                ]).then(function(strings) {
+                    Notification.confirm(
+                        strings[0], // Title.
+                        strings[1], // Message.
+                        strings[2], // Login Again.
+                        strings[3], // Cancel.
+                        function() {
+                            location.reload();
+                            return true;
+                        }
+                    );
+                    return true;
+                }).catch(Notification.exception);
+            }
+        });
     };
 
     /**
@@ -55,23 +107,14 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
 
         if (sessionTimeout) {
             // We timed out before we extended the session.
-            return Str.get_strings([
-                {key: 'sessionexpired', component: 'error'},
-                {key: 'sessionerroruser', component: 'error'}
-            ]).then(function(strings) {
-                Notification.alert(
-                    strings[0], // Title.
-                    strings[1] // Message.
-                );
-                return true;
-            }).fail(Notification.exception);
+            return displaySessionExpired();
         } else {
             return Ajax.call([request], true, true, false, requestTimeout)[0].then(function() {
                 if (keepAliveFrequency > 0) {
                     setTimeout(touchSession, keepAliveFrequency);
                 }
                 return true;
-            }).fail(function() {
+            }).catch(function() {
                 Notification.alert('', keepAliveMessage);
             });
         }
@@ -88,27 +131,14 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
             methodname: 'core_session_time_remaining',
             args: { }
         };
-
         sessionTimeout = false;
         return Ajax.call([request], true, true, true)[0].then(function(args) {
             if (args.userid <= 0) {
                 return false;
             }
-            if (args.timeremaining < 0) {
-                Str.get_strings([
-                    {key: 'sessionexpired', component: 'error'},
-                    {key: 'sessionerroruser', component: 'error'}
-                ]).then(function(strings) {
-                    Notification.alert(
-                        strings[0], // Title.
-                        strings[1] // Message.
-                    );
-                    return true;
-                }).fail(Notification.exception);
-
-            } else if (args.timeremaining * 1000 < warningLimit && !warningDisplayed) {
-                // If we don't extend the session before the timeout - warn.
-                setTimeout(timeoutSessionExpired, args.timeremaining * 1000);
+            if (args.timeremaining <= 0) {
+                return displaySessionExpired();
+            } else if (args.timeremaining * 1000 <= warningLimit && !warningDisplayed) {
                 warningDisplayed = true;
                 Str.get_strings([
                     {key: 'norecentactivity', component: 'moodle'},
@@ -116,7 +146,7 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
                     {key: 'extendsession', component: 'moodle'},
                     {key: 'cancel', component: 'moodle'}
                 ]).then(function(strings) {
-                    Notification.confirm(
+                     return Notification.confirm(
                         strings[0], // Title.
                         strings[1], // Message.
                         strings[2], // Extend session.
@@ -124,17 +154,20 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
                         function() {
                             touchSession();
                             warningDisplayed = false;
-                            // First wait is half the session timeout.
-                            setTimeout(checkSession, checkFrequency * 5);
+                            // First wait is minimum of remaining time or half of the session timeout.
+                            setTimeout(checkSession, firstWait);
                             return true;
                         },
                         function() {
-                            warningDisplayed = false;
+                            // User has cancelled notification.
                             setTimeout(checkSession, checkFrequency);
                         }
                     );
-                    return true;
-                }).fail(Notification.exception);
+                }).then(modal => {
+                    // If we don't extend the session before the timeout - warn.
+                    setTimeout(timeoutSessionExpired, args.timeremaining * 1000, modal);
+                    return;
+                }).catch(Notification.exception);
             } else {
                 setTimeout(checkSession, checkFrequency);
             }
@@ -151,8 +184,8 @@ define(['jquery', 'core/ajax', 'core/config', 'core/notification', 'core/str'],
         if (keepAliveFrequency > 0) {
             setTimeout(touchSession, keepAliveFrequency);
         } else {
-            // First wait is half the session timeout.
-            setTimeout(checkSession, checkFrequency * 5);
+            // First wait is minimum of remaining time or half of the session timeout.
+            setTimeout(checkSession, firstWait);
         }
     };
 
index 4ba8615..167e7bb 100644 (file)
@@ -349,12 +349,12 @@ define(['jquery'], function($) {
      * Handle a key down event - ie navigate the tree.
      *
      * @method handleKeyDown
-     * @param {Object} item is the jquery id of the parent item of the group.
      * @param {Event} e The event.
      */
      // This function should be simplified. In the meantime..
      // eslint-disable-next-line complexity
-    Tree.prototype.handleKeyDown = function(item, e) {
+    Tree.prototype.handleKeyDown = function(e) {
+        var item = $(e.target);
         var currentIndex = this.getVisibleItems().index(item);
 
         if ((e.altKey || e.ctrlKey || e.metaKey) || (e.shiftKey && e.keyCode != this.keys.tab)) {
@@ -483,16 +483,20 @@ define(['jquery'], function($) {
      * Handle a click (select).
      *
      * @method handleClick
-     * @param {Object} item The jquery id of the parent item of the group.
      * @param {Event} e The event.
      */
-    Tree.prototype.handleClick = function(item, e) {
-
+    Tree.prototype.handleClick = function(e) {
         if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
             // Do nothing.
             return;
         }
 
+        // Get the closest tree item from the event target.
+        var item = $(e.target).closest('[role="treeitem"]');
+        if (!item.is(e.currentTarget)) {
+            return;
+        }
+
         // Update the active item.
         item.focus();
 
@@ -506,12 +510,10 @@ define(['jquery'], function($) {
      * Handle a focus event.
      *
      * @method handleFocus
-     * @param {Object} item The jquery id of the parent item of the group.
      * @param {Event} e The event.
      */
-    Tree.prototype.handleFocus = function(item) {
-
-        this.setActiveItem(item);
+    Tree.prototype.handleFocus = function(e) {
+        this.setActiveItem($(e.target));
     };
 
     /**
@@ -520,20 +522,12 @@ define(['jquery'], function($) {
      * @method bindEventHandlers
      */
     Tree.prototype.bindEventHandlers = function() {
-        var thisObj = this;
-
         // Bind event handlers to the tree items. Use event delegates to allow
         // for dynamically loaded parts of the tree.
         this.treeRoot.on({
-            click: function(e) {
-              return thisObj.handleClick($(this), e);
-            },
-            keydown: function(e) {
-              return thisObj.handleKeyDown($(this), e);
-            },
-            focus: function() {
-              return thisObj.handleFocus($(this));
-            },
+            click: this.handleClick.bind(this),
+            keydown: this.handleKeyDown.bind(this),
+            focus: this.handleFocus.bind(this),
         }, SELECTORS.ITEM);
     };
 
index 1a807a2..193d2af 100644 (file)
@@ -101,7 +101,6 @@ class behat_field_manager {
      * @return behat_form_field
      */
     public static function get_field_instance($type, NodeElement $fieldnode, Session $session) {
-
         global $CFG;
 
         // If the field is not part of a moodleform, we should still try to find out
@@ -152,6 +151,10 @@ class behat_field_manager {
             $type = $fieldnode->getAttribute('type');
             switch ($type) {
                 case 'text':
+                    if ($fieldtype = $fieldnode->getAttribute('data-fieldtype')) {
+                        return self::normalise_fieldtype($fieldtype);
+                    }
+                    return 'text';
                 case 'password':
                 case 'email':
                 case 'file':
index 238bbee..b8144f0 100644 (file)
@@ -48,43 +48,75 @@ class behat_form_autocomplete extends behat_form_text {
             throw new coding_exception('Setting the value of an autocomplete field requires javascript.');
         }
 
-        // Set the value of the autocomplete's input.
-        // If this autocomplete offers suggestions then these should be fetched by setting the value and waiting for the
-        // JS to finish fetching those suggestions.
+        // Clear all current selections.
+        $rootnode = $this->field->getParent()->getParent();
+        $selections = $rootnode->findAll('css', '.form-autocomplete-selection [role=option]');
+        foreach (array_reverse($selections) as $selection) {
+            $selection->click();
+            $this->wait_for_pending_js();
+        }
 
-        $istagelement = $this->field->hasAttribute('data-tags') && $this->field->getAttribute('data-tags');
+        $allowscreation = $this->field->hasAttribute('data-tags') && !empty($this->field->getAttribute('data-tags'));
+        $hasmultiple = $this->field->hasAttribute('data-multiple') && !empty($this->field->getAttribute('data-multiple'));
 
-        if ($istagelement && false !== strpos($value, ',')) {
-            // Commas have a special meaning as a value separator in 'tag' autocomplete elements.
+        if ($hasmultiple && false !== strpos($value, ',')) {
+            // Commas have a special meaning as a value separator in 'multiple' autocomplete elements.
             // To handle this we break the value up by comma, and enter it in chunks.
             $values = explode(',', $value);
 
             while ($value = array_shift($values)) {
-                $this->set_value($value);
+                $this->add_value(trim($value), $allowscreation);
             }
         } else {
-            $this->field->setValue($value);
-            $this->wait_for_pending_js();
+            $this->add_value(trim($value), $allowscreation);
+        }
+    }
 
-            // If the autocomplete found suggestions, then it will have:
-            // 1) marked itself as expanded; and
-            // 2) have an aria-selected suggestion in the list.
-            $expanded = $this->field->getAttribute('aria-expanded');
-            $suggestion = $this->field->getParent()->find('css', '.form-autocomplete-suggestions > [aria-selected="true"]');
-
-            if ($expanded && null !== $suggestion) {
-                // A suggestion was found.
-                // Click on the first item in the list.
-                $suggestion->click();
-            } else {
-                // Press the return key to create a new tag.
-                behat_base::type_keys($this->session, [behat_keys::ENTER]);
-            }
-            $this->wait_for_pending_js();
+    /**
+     * Add a value to the autocomplete.
+     *
+     * @param   string $value
+     * @param   bool $allowscreation
+     */
+    protected function add_value(string $value, bool $allowscreation): void {
+        $value = trim($value);
 
-            // Press the escape to close the autocomplete suggestions list.
-            behat_base::type_keys($this->session, [behat_keys::ESCAPE]);
-            $this->wait_for_pending_js();
+        // Click into the field.
+        $this->field->click();
+
+        // Remove any existing text.
+        do {
+            behat_base::type_keys($this->session, [behat_keys::BACKSPACE, behat_keys::DELETE]);
+        } while (strlen($this->field->getValue()) > 0);
+        $this->wait_for_pending_js();
+
+        // Type in the new value.
+        behat_base::type_keys($this->session, str_split($value));
+        $this->wait_for_pending_js();
+
+        // If the autocomplete found suggestions, then it will have:
+        // 1) marked itself as expanded; and
+        // 2) have an aria-selected suggestion in the list.
+        $expanded = $this->field->getAttribute('aria-expanded');
+        $suggestion = $this->field->getParent()->getParent()->find('css', '.form-autocomplete-suggestions > [aria-selected="true"]');
+
+        if ($expanded && null !== $suggestion) {
+            // A suggestion was found.
+            // Click on the first item in the list.
+            $suggestion->click();
+        } else if ($allowscreation) {
+            // Press the return key to create a new entry.
+            behat_base::type_keys($this->session, [behat_keys::ENTER]);
+        } else {
+            throw new \InvalidArgumentException(
+                "Unable to find '{$value}' in the list of options, and unable to create a new option"
+            );
         }
+
+        $this->wait_for_pending_js();
+
+        // Press the escape to close the autocomplete suggestions list.
+        behat_base::type_keys($this->session, [behat_keys::ESCAPE]);
+        $this->wait_for_pending_js();
     }
 }
index 574ea94..6c49aaa 100644 (file)
@@ -473,7 +473,7 @@ class client extends \oauth2_client {
 
         // Store the access token and, if provided by the server, the new refresh token.
         $this->store_token($receivedtokens['access_token']);
-        if (isset($receivedtokens['refreshtoken'])) {
+        if (isset($receivedtokens['refresh_token'])) {
             $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token);
             $systemaccount->update();
         }
index a6a330c..112d4da 100644 (file)
@@ -72,6 +72,11 @@ class mustache_pix_helper {
         $text = strtok("");
         // Allow mustache tags in the last argument.
         $text = trim($helper->render($text));
+        // The $text has come from a template, so HTML special
+        // chars have been escaped. However, render_pix_icon
+        // assumes the alt arrives with no escaping. So we need
+        // ot un-escape here.
+        $text = htmlspecialchars_decode($text);
 
         return trim($this->renderer->pix_icon($key, $text, $component));
     }
index 559c68f..8a7cddd 100644 (file)
@@ -229,6 +229,7 @@ class manager {
         $record = self::record_from_scheduled_task($task);
         $record->id = $original->id;
         $record->nextruntime = $task->get_next_scheduled_time();
+        unset($record->lastruntime);
         $result = $DB->update_record('task_scheduled', $record);
 
         return $result;
@@ -511,6 +512,27 @@ class manager {
         return $tasks;
     }
 
+    /**
+     * This function will return a list of all adhoc tasks that have a faildelay
+     *
+     * @param int $delay filter how long the task has been delayed
+     * @return \core\task\adhoc_task[]
+     */
+    public static function get_failed_adhoc_tasks(int $delay = 0): array {
+        global $DB;
+
+        $tasks = [];
+        $records = $DB->get_records_sql('SELECT * from {task_adhoc} WHERE faildelay > ?', [$delay]);
+
+        foreach ($records as $record) {
+            $task = self::adhoc_task_from_record($record);
+            if ($task) {
+                $tasks[] = $task;
+            }
+        }
+        return $tasks;
+    }
+
     /**
      * Ensure quality of service for the ad hoc task queue.
      *
@@ -679,7 +701,6 @@ class manager {
 
         $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
                   AND (nextruntime IS NULL OR nextruntime < :timestart2)
-                  AND disabled = 0
                   ORDER BY lastruntime, id ASC";
         $params = array('timestart1' => $timestart, 'timestart2' => $timestart);
         $records = $DB->get_records_select('task_scheduled', $where, $params);
@@ -688,14 +709,15 @@ class manager {
 
         foreach ($records as $record) {
 
+            $task = self::scheduled_task_from_record($record);
+            // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
+            // Also check to see if task is disabled or enabled after applying overrides.
+            if (!$task || $task->get_disabled()) {
+                continue;
+            }
+
             if ($lock = $cronlockfactory->get_lock(($record->classname), 0)) {
                 $classname = '\\' . $record->classname;
-                $task = self::scheduled_task_from_record($record);
-                // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
-                if (!$task) {
-                    $lock->release();
-                    continue;
-                }
 
                 $task->set_lock($lock);
 
index 2861806..e7b352d 100644 (file)
@@ -476,4 +476,12 @@ $definitions = array(
         'simplekeys' => true,
         'simpledata' => false,
     ],
+
+    // Cache the grade setting for faster retrieval.
+    'gradesetting' => [
+        'mode'                   => cache_store::MODE_REQUEST,
+        'simplekeys'             => true,
+        'staticacceleration'     => true,
+        'staticaccelerationsize' => 100
+    ],
 );
index 815404f..17b2443 100644 (file)
@@ -821,6 +821,13 @@ $functions = array(
         'type'        => 'write',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_files_get_unused_draft_itemid' => array(
+        'classname' => 'core_files\external\get\unused_draft',
+        'methodname' => 'execute',
+        'description' => 'Generate a new draft itemid for the current user.',
+        'type'        => 'write',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
     'core_form_get_filetypes_browser_data' => array(
         'classname' => 'core_form\external',
         'methodname' => 'get_filetypes_browser_data',
index d64ad69..7524c00 100644 (file)
                     {{{label}}}
                 {{/text}}
             </label>
-            <span class="ml-2 d-flex align-items-center align-self-start">
+            <div class="ml-2 d-flex align-items-center align-self-start">
                 {{#required}}
                     <div class="text-danger" title="{{#str}}required{{/str}}">
                     {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                     </div>
                 {{/required}}
                 {{{helpbutton}}}
-            </span>
+            </div>
         </div>
         <div class="form-control-feedback invalid-feedback" id="{{element.iderror}}" {{#error}} style="display: block;"{{/error}}>
             {{{error}}}
index 4aacd4f..b3497a4 100644 (file)
                     {{{label}}}
                 {{/text}}
             </label>
-            <span class="ml-2 d-flex align-items-center align-self-start">
+            <div class="ml-2 d-flex align-items-center align-self-start">
                 {{#required}}
                     <div class="text-danger" title="{{#str}}required{{/str}}">
                     {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                     </div>
                 {{/required}}
                 {{{helpbutton}}}
-            </span>
+            </div>
         </div>
         <div class="form-control-feedback invalid-feedback" id="{{element.iderror}}" {{#error}} style="display: block;"{{/error}}>
             {{{error}}}
index 17e557e..0efeff5 100644 (file)
                         {{{label}}}
                     {{/text}}
                 </label>
-                <span class="ml-2 d-flex align-items-center align-self-start">
+                <div class="ml-2 d-flex align-items-center align-self-start">
                     {{#required}}
                         <div class="text-danger" title="{{#str}}required{{/str}}">
                         {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                         </div>
                     {{/required}}
                     {{{helpbutton}}}
-                </span>
+                </div>
         </div>
         <div class="form-control-feedback invalid-feedback" id="{{element.iderror}}" {{#error}} style="display: block;"{{/error}}>
             {{{error}}}
index afa6576..b77408b 100644 (file)
                 </span>
             {{/element.staticlabel}}
         {{/ label }}{{/ label}}
-        <span class="ml-1 ml-md-auto d-flex align-items-center align-self-start">
+        <div class="ml-1 ml-md-auto d-flex align-items-center align-self-start">
             {{#required}}
                 <div class="text-danger" title="{{#str}}required{{/str}}">
                 {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
                 </div>
             {{/required}}
             {{{helpbutton}}}
-        </span>
+        </div>
     </div>
     <div class="col-md-9 form-inline align-items-start felement" data-fieldtype="{{element.type}}">
         {{$ element }}
index 02919f1..05aee9f 100644 (file)
@@ -676,16 +676,18 @@ function grade_get_grades($courseid, $itemtype, $itemmodule, $iteminstance, $use
 function grade_get_setting($courseid, $name, $default=null, $resetcache=false) {
     global $DB;
 
-    static $cache = array();
+    $cache = cache::make('core', 'gradesetting');
+    $gradesetting = $cache->get($courseid) ?: array();
 
-    if ($resetcache or !array_key_exists($courseid, $cache)) {
-        $cache[$courseid] = array();
+    if ($resetcache or empty($gradesetting)) {
+        $gradesetting = array();
+        $cache->set($courseid, $gradesetting);
 
     } else if (is_null($name)) {
         return null;
 
-    } else if (array_key_exists($name, $cache[$courseid])) {
-        return $cache[$courseid][$name];
+    } else if (array_key_exists($name, $gradesetting)) {
+        return $gradesetting[$name];
     }
 
     if (!$data = $DB->get_record('grade_settings', array('courseid'=>$courseid, 'name'=>$name))) {
@@ -698,7 +700,8 @@ function grade_get_setting($courseid, $name, $default=null, $resetcache=false) {
         $result = $default;
     }
 
-    $cache[$courseid][$name] = $result;
+    $gradesetting[$name] = $result;
+    $cache->set($courseid, $gradesetting);
     return $result;
 }
 
index 6b73a06..1380682 100644 (file)
@@ -88,6 +88,9 @@ class Horde_Idna
      */
     protected static function _checkForError($info)
     {
+        if (!isset($info['errors'])) {
+            return;
+        }
         switch (true) {
         case $info['errors'] & IDNA_ERROR_EMPTY_LABEL:
             throw new Horde_Idna_Exception(Horde_Idna_Translation::t(
index 0970c8f..0fc857e 100644 (file)
@@ -120,6 +120,53 @@ class Horde_Imap_Client_Data_Thread implements Countable, Serializable
         return array();
     }
 
+    /**
+     * Returns array of all threads.
+     *
+     * @return array  Keys of thread arrays are indices, values are objects with the following
+     *                properties:
+     *   - base: (integer) Base ID of the thread. If null, thread is a single
+     *           message.
+     *   - last: (boolean) If true, this is the last index in the sublevel.
+     *   - level: (integer) The sublevel of the index.
+     */
+    public function getThreads()
+    {
+        $data = array();
+        foreach ($this->_thread as $v) {
+            reset($v);
+
+            $ob = new stdClass;
+            $ob->base = (count($v) > 1) ? key($v) : null;
+            $ob->last = false;
+
+            $levels = $out = array();
+            $last = 0;
+
+            while (($v2 = current($v)) !== false) {
+                $k2 = key($v);
+                $ob2 = clone $ob;
+                $ob2->level = $v2;
+                $out[$k2] = $ob2;
+
+                if (($last < $v2) && isset($levels[$v2])) {
+                    $out[$levels[$v2]]->last = true;
+                }
+                $levels[$v2] = $k2;
+                $last = $v2;
+                next($v);
+            }
+
+            foreach ($levels as $v) {
+                $out[$v]->last = true;
+            }
+
+            $data[] = $out;
+        }
+
+        return $data;
+    }
+
     /* Countable methods. */
 
     /**
index b8c2349..0a4307d 100644 (file)
@@ -595,7 +595,7 @@ class Horde_Imap_Client_Search_Query implements Serializable
      * Search for text in either the entire message, or just the body.
      *
      * @param string $text      The search text.
-     * @param string $bodyonly  If true, only search in the body of the
+     * @param boolean $bodyonly  If true, only search in the body of the
      *                          message. If false, also search in the headers.
      * @param boolean $not      If true, do a 'NOT' search of $text.
      * @param array $opts       Additional options:
index 240c773..f9864b0 100644 (file)
@@ -1543,12 +1543,9 @@ class Horde_Imap_Client_Socket extends Horde_Imap_Client_Base
 
         /* Add in STATUS return, if needed. */
         if (!empty($options['status']) && $this->_capability('LIST-STATUS')) {
-            foreach ($pattern as $val) {
-                $val_utf8 = Horde_Imap_Client_Utf7imap::Utf7ImapToUtf8($val);
-                if (isset($lr[$val_utf8])) {
-                    $lr[$val_utf8]['status'] = $this->_prepareStatusResponse($status_opts, $val_utf8);
-                }
-            }
+           foreach($lr as $val_utf8 => $tmp) {
+               $lr[$val_utf8]['status'] = $this->_prepareStatusResponse($status_opts, $val_utf8);
+           }
         }
 
         return $lr;
index 4030492..a93853a 100644 (file)
@@ -110,7 +110,7 @@ class Horde_Imap_Client_Socket_ClientSort
         }
 
         $mbox = $this->_socket->currentMailbox();
-        $fetch_res = $this->_socket->fetch($mbox['mailbox'], $query, array(
+        $fetch_res = $this->_socket->fetch(isset($mbox['mailbox']) ? $mbox['mailbox'] : null, $query, array(
             'ids' => $res
         ));
 
index 75064b5..cbec8ea 100644 (file)
@@ -394,10 +394,10 @@ class Horde_Mime_Mail
     /**
      * Sends this message.
      *
-     * @param Mail $mailer     A Mail object.
-     * @param boolean $resend  If true, the message id and date are re-used;
-     *                         If false, they will be updated.
-     * @param boolean $flowed  Send message in flowed text format.
+     * @param Horde_Mail_Transport $mailer A Horde_Mail_Transport object.
+     * @param boolean $resend              If true, the message id and date are re-used;
+     *                                     If false, they will be updated.
+     * @param boolean $flowed              Send message in flowed text format.
      *
      * @throws Horde_Mime_Exception
      */
@@ -488,7 +488,7 @@ class Horde_Mime_Mail
      * @param  boolean $stream  If true, return a stream resource, otherwise
      *                          a string is returned.
      *
-     * @return stream|string  The raw email data.
+     * @return resource|string  The raw email data.
      * @since 2.4.0
      */
     public function getRaw($stream = true)
index a7fde63..a38d223 100644 (file)
@@ -91,7 +91,7 @@ class Client
             $secure = false;
         }
 
-        $context = array_merge_recursive(
+        $context = array_replace_recursive(
             array(
                 'ssl' => array(
                     'verify_peer' => false,
index 4d16bb8..d6d111d 100644 (file)
@@ -114,7 +114,7 @@ class Horde_Util
     public static function dispelMagicQuotes($var)
     {
         if (is_null(self::$_magicquotes)) {
-            self::$_magicquotes = get_magic_quotes_gpc();
+            self::$_magicquotes = function_exists('get_magic_quotes_gpc') && @get_magic_quotes_gpc();
         }
 
         if (self::$_magicquotes) {
@@ -304,7 +304,7 @@ class Horde_Util
         /* Get the first 8 characters of a random string to use as a temporary
            directory name. */
         do {
-            $new_dir = $temp_dir . '/' . substr(base_convert(uniqid(mt_rand()), 10, 36), 0, 8);
+            $new_dir = $temp_dir . '/' . substr(base_convert(uniqid(mt_rand()), 16, 36), 0, 8);
         } while (file_exists($new_dir));
 
         $old_umask = umask(0000);
index c31e980..d4bcaa6 100644 (file)
@@ -12,10 +12,6 @@ Description of import of Horde libraries
 # Copy the following script and store it on /tmp, change it's execute bit(chmod 777), and run it,
   passing in your path to Horde (the directory you've cloned the repository):
     /tmp/copyhorde.sh ~/git/base/directory/from/step/2
-# Verify that these patches have been applied in the imported version. Apply them locally if not:
-    - https://github.com/horde/Mail/pull/1 (Mail component).
-    - https://github.com/horde/Imap_Client/pull/6 (IMAP Client component).
-    - https://github.com/horde/Crypt_Blowfish/pull/1 (PHP 7.4 compatibility, Crypt_Blowfish)
 
 ====
 #!/bin/sh
index b0f5114..59fcd1c 100644 (file)
@@ -3906,13 +3906,19 @@ function get_enabled_auth_plugins($fix=false) {
 
     if ($fix) {
         $auths = array_unique($auths);
+        $oldauthconfig = implode(',', $auths);
         foreach ($auths as $k => $authname) {
-            if (!exists_auth_plugin($authname) or in_array($authname, $default)) {
+            $authplugindoesnotexist = !exists_auth_plugin($authname);
+            if ($authplugindoesnotexist || in_array($authname, $default)) {
+                if ($authplugindoesnotexist) {
+                    debugging(get_string('authpluginnotfound', 'debug', $authname));
+                }
                 unset($auths[$k]);
             }
         }
         $newconfig = implode(',', $auths);
         if (!isset($CFG->auth) or $newconfig != $CFG->auth) {
+            add_to_config_log('auth', $oldauthconfig, $newconfig, 'core');
             set_config('auth', $newconfig);
         }
     }
index 9b99d8b..044d559 100644 (file)
@@ -339,34 +339,19 @@ function core_myprofile_navigation(core_user\output\myprofile\tree $tree, $user,
     }
 
     if ($user->skype && !isset($hiddenfields['skypeid'])) {
-        $imurl = 'skype:'.urlencode($user->skype).'?call';
-        $iconurl = new moodle_url('http://mystatus.skype.com/smallicon/'.urlencode($user->skype));
-        if (is_https()) {
-            // Bad luck, skype devs are lazy to set up SSL on their servers - see MDL-37233.
-            $statusicon = '';
-        } else {
-            $statusicon = html_writer::empty_tag('img',
-                array('src' => $iconurl, 'class' => 'icon icon-post', 'alt' => get_string('status')));
-        }
-
+        $imurl = 'skype:' . urlencode($user->skype) . '?call';
         $node = new core_user\output\myprofile\node('contact', 'skypeid', get_string('skypeid'), null, null,
-            html_writer::link($imurl, s($user->skype) . $statusicon));
+            html_writer::link($imurl, s($user->skype)));
         $tree->add_node($node);
     }
     if ($user->yahoo && !isset($hiddenfields['yahooid'])) {
-        $imurl = new moodle_url('https://edit.yahoo.com/config/send_webmesg', array('.target' => $user->yahoo, '.src' => 'pg'));
-        $iconurl = new moodle_url('http://opi.yahoo.com/online', array('u' => $user->yahoo, 'm' => 'g', 't' => '0'));
-        $statusicon = html_writer::tag('img', '',
-            array('src' => $iconurl, 'class' => 'iconsmall icon-post', 'alt' => get_string('status')));
-
         $node = new core_user\output\myprofile\node('contact', 'yahooid', get_string('yahooid'), null, null,
-            html_writer::link($imurl, s($user->yahoo) . $statusicon));
+            s($user->yahoo));
         $tree->add_node($node);
     }
     if ($user->aim && !isset($hiddenfields['aimid'])) {
-        $imurl = 'aim:goim?screenname='.urlencode($user->aim);
         $node = new core_user\output\myprofile\node('contact', 'aimid', get_string('aimid'), null, null,
-            html_writer::link($imurl, s($user->aim)));
+            s($user->aim));
         $tree->add_node($node);
     }
     if ($user->msn && !isset($hiddenfields['msnid'])) {
index b6277cb..ff5428b 100644 (file)
@@ -3407,6 +3407,15 @@ class block_contents {
     public function add_class($class) {
         $this->attributes['class'] .= ' '.$class;
     }
+
+    /**
+     * Check if the block is a fake block.
+     *
+     * @return boolean
+     */
+    public function is_fake() {
+        return isset($this->attributes['data-block']) && $this->attributes['data-block'] == '_fake';
+    }
 }
 
 
index 9f533ca..bfc93a0 100644 (file)
@@ -2451,22 +2451,22 @@ class theme_config {
      * @return string
      */
     protected function get_region_name($region, $theme) {
-        $regionstring = get_string('region-' . $region, 'theme_' . $theme);
-        // A name exists in this theme, so use it
-        if (substr($regionstring, 0, 1) != '[') {
-            return $regionstring;
+
+        $stringman = get_string_manager();
+
+        // Check if the name is defined in the theme.
+        if ($stringman->string_exists('region-' . $region, 'theme_' . $theme)) {
+            return get_string('region-' . $region, 'theme_' . $theme);
         }
 
-        // Otherwise, try to find one elsewhere
-        // Check parents, if any
+        // Check the theme parents.
         foreach ($this->parents as $parentthemename) {
-            $regionstring = get_string('region-' . $region, 'theme_' . $parentthemename);
-            if (substr($regionstring, 0, 1) != '[') {
-                return $regionstring;
+            if ($stringman->string_exists('region-' . $region, 'theme_' . $parentthemename)) {
+                return get_string('region-' . $region, 'theme_' . $parentthemename);
             }
         }
 
-        // Last resort, try the boost theme for names
+        // Last resort, try the boost theme for names.
         return get_string('region-' . $region, 'theme_boost');
     }
 
index 6b61cab..abfb603 100644 (file)
@@ -1817,9 +1817,10 @@ class core_renderer extends renderer_base {
      * Output all the blocks in a particular region.
      *
      * @param string $region the name of a region on this page.
+     * @param boolean $fakeblocksonly Output fake block only.
      * @return string the HTML to be output.
      */
-    public function blocks_for_region($region) {
+    public function blocks_for_region($region, $fakeblocksonly = false) {
         $blockcontents = $this->page->blocks->get_content_for_region($region, $this);
         $lastblock = null;
         $zones = array();
@@ -1832,10 +1833,16 @@ class core_renderer extends renderer_base {
 
         foreach ($blockcontents as $bc) {
             if ($bc instanceof block_contents) {
+                if ($fakeblocksonly && !$bc->is_fake()) {
+                    // Skip rendering real blocks if we only want to show fake blocks.
+                    continue;
+                }
                 $output .= $this->block($bc, $region);
                 $lastblock = $bc->title;
             } else if ($bc instanceof block_move_target) {
-                $output .= $this->block_move_target($bc, $zones, $lastblock, $region);
+                if (!$fakeblocksonly) {
+                    $output .= $this->block_move_target($bc, $zones, $lastblock, $region);
+                }
             } else {
                 throw new coding_exception('Unexpected type of thing (' . get_class($bc) . ') found in list of block contents.');
             }
@@ -3939,9 +3946,12 @@ EOD;
      *
      * @since Moodle 2.5.1 2.6
      * @param string $region The region to get HTML for.
+     * @param array $classes Wrapping tag classes.
+     * @param string $tag Wrapping tag.
+     * @param boolean $fakeblocksonly Include fake blocks only.
      * @return string HTML.
      */
-    public function blocks($region, $classes = array(), $tag = 'aside') {
+    public function blocks($region, $classes = array(), $tag = 'aside', $fakeblocksonly = false) {
         $displayregion = $this->page->apply_theme_region_manipulations($region);
         $classes = (array)$classes;
         $classes[] = 'block-region';
@@ -3952,7 +3962,7 @@ EOD;
             'data-droptarget' => '1'
         );
         if ($this->page->blocks->region_has_content($displayregion, $this)) {
-            $content = $this->blocks_for_region($displayregion);
+            $content = $this->blocks_for_region($displayregion, $fakeblocksonly);
         } else {
             $content = '';
         }
@@ -5084,9 +5094,10 @@ class core_renderer_maintenance extends core_renderer {
      * @param string $region
      * @param array $classes
      * @param string $tag
+     * @param boolean $fakeblocksonly
      * @return string
      */
-    public function blocks($region, $classes = array(), $tag = 'aside') {
+    public function blocks($region, $classes = array(), $tag = 'aside', $fakeblocksonly = false) {
         return '';
     }
 
@@ -5094,9 +5105,10 @@ class core_renderer_maintenance extends core_renderer {
      * Does nothing. The maintenance renderer cannot produce blocks.
      *
      * @param string $region
+     * @param boolean $fakeblocksonly Output fake block only.
      * @return string
      */
-    public function blocks_for_region($region) {
+    public function blocks_for_region($region, $fakeblocksonly = false) {
         return '';
     }
 
index 7bca2a7..d18b9f5 100644 (file)
@@ -319,20 +319,21 @@ class page_requirements_manager {
             }
 
             $this->M_cfg = array(
-                'wwwroot'             => $CFG->wwwroot,
-                'sesskey'             => sesskey(),
-                'sessiontimeout'      => $CFG->sessiontimeout,
-                'themerev'            => theme_get_revision(),
-                'slasharguments'      => (int)(!empty($CFG->slasharguments)),
-                'theme'               => $page->theme->name,
-                'iconsystemmodule'    => $iconsystem->get_amd_name(),
-                'jsrev'               => $this->get_jsrev(),
-                'admin'               => $CFG->admin,
-                'svgicons'            => $page->theme->use_svg_icons(),
-                'usertimezone'        => usertimezone(),
-                'contextid'           => $contextid,
-                'langrev'             => get_string_manager()->get_revision(),
-                'templaterev'         => $this->get_templaterev()
+                'wwwroot'               => $CFG->wwwroot,
+                'sesskey'               => sesskey(),
+                'sessiontimeout'        => $CFG->sessiontimeout,
+                'sessiontimeoutwarning' => $CFG->sessiontimeoutwarning,
+                'themerev'              => theme_get_revision(),
+                'slasharguments'        => (int)(!empty($CFG->slasharguments)),
+                'theme'                 => $page->theme->name,
+                'iconsystemmodule'      => $iconsystem->get_amd_name(),
+                'jsrev'                 => $this->get_jsrev(),
+                'admin'                 => $CFG->admin,
+                'svgicons'              => $page->theme->use_svg_icons(),
+                'usertimezone'          => usertimezone(),
+                'contextid'             => $contextid,
+                'langrev'               => get_string_manager()->get_revision(),
+                'templaterev'           => $this->get_templaterev()
             );
             if ($CFG->debugdeveloper) {
                 $this->M_cfg['developerdebug'] = true;
index 1326f13..210bdee 100644 (file)
@@ -801,6 +801,10 @@ if (CLI_SCRIPT) {
 if (empty($CFG->sessiontimeout)) {
     $CFG->sessiontimeout = 8 * 60 * 60;
 }
+// Set sessiontimeoutwarning 20 minutes.
+if (empty($CFG->sessiontimeoutwarning)) {
+    $CFG->sessiontimeoutwarning = 20 * 60;
+}
 \core\session\manager::start();
 
 // Set default content type and encoding, developers are still required to use
index ac76ed5..ae7d194 100644 (file)
 }}
 {{#showSuggestions}}
 <div class="d-md-inline-block mr-md-2 position-relative">
-    <input type="text" id="{{inputId}}" class="form-control" list="{{suggestionsId}}" placeholder="{{placeholder}}" role="combobox" aria-expanded="false" autocomplete="off" autocorrect="off" autocapitalize="off" aria-autocomplete="list" aria-owns="{{suggestionsId}} {{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
+    <input type="text"{{!
+    }} id="{{inputId}}"{{!
+    }} class="form-control"{{!
+    }} list="{{suggestionsId}}"{{!
+    }} placeholder="{{placeholder}}"{{!
+    }} role="combobox"{{!
+    }} aria-expanded="false"{{!
+    }} autocomplete="off"{{!
+    }} autocorrect="off"{{!
+    }} autocapitalize="off"{{!
+    }} aria-autocomplete="list"{{!
+    }} aria-owns="{{suggestionsId}} {{selectionId}}"{{!
+    }} {{#tags}}data-tags="1"{{/tags}}{{!
+    }} {{#multiple}}data-multiple="multiple"{{/multiple}}{{!
+    }}>
     <span class="form-autocomplete-downarrow position-absolute p-1" id="{{downArrowId}}">&#x25BC;</span>
 </div>
 {{/showSuggestions}}
 {{^showSuggestions}}
 <div class="d-md-inline-block mr-md-2">
-    <input type="text" id="{{inputId}}" class="form-control" placeholder="{{placeholder}}" role="textbox" aria-owns="{{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
+    <input type="text"{{!
+    }} id="{{inputId}}"{{!
+    }} class="form-control"{{!
+    }} placeholder="{{placeholder}}"{{!
+    }} role="textbox"{{!
+    }} aria-owns="{{selectionId}}"{{!
+    }} {{#tags}}data-tags="1"{{/tags}}{{!
+    }} {{#multiple}}data-multiple="multiple"{{/multiple}}{{!
+    }}>
 </div>
 {{/showSuggestions}}
 
index b308653..d7ea395 100644 (file)
     * rolename Name of the role rendered - must have been prepared for output with format_string,
                                            or more likely one of the role API functions like role_fix_names.
     * roleid Id of the role
-    * action WEhich action is done on click
+    * action Which action is done on click
     * spanclass class attribute of span
     * linkclass class attribute of link
     * adminurl moodle admin url
-    * imageurl moodle url for delete(x) image
+    * icon moodle icon for delete(x)
+    * iconalt alt text for the icon. Must have been HTML escaped.
 
     Example context (json):
-    {"rolename" : "Manager",
-     "roleid" : 1,
-     "action": "prevent",
-     "spanclass": "allowed",
-     "linkclass": "preventlink",
-     "adminurl" : "http://localhost/moodle/admin/"}
+    {
+        "rolename": "Manager",
+        "roleid": 1,
+        "action": "prevent",
+        "spanclass": "allowed",
+        "linkclass": "preventlink",
+        "adminurl": "http://localhost/moodle/admin/",
+        "icon": "t/delete",
+        "iconalt": "Delete Student role"
+    }
 }}
 <span style="display:inline-block;" class="{{spanclass}}">&nbsp;{{{rolename}}}&nbsp;
     <a href="{{adminurl}}roles/permissions.php" class="{{linkclass}}" data-role-id="{{roleid}}" data-action="{{action}}">
         {{#icon}}
-            {{#pix}}{{icon}}, core, {{iconalt}}{{/pix}}
+            {{#pix}}{{icon}}, core, {{{iconalt}}}{{/pix}}
         {{/icon}}
     </a>
 </span>
index f494a26..400a62e 100644 (file)
@@ -37,9 +37,9 @@
         ]
     }
 }}
-<div class="simplesearchform {{{ extraclasses }}}">
+<div class="simplesearchform {{ extraclasses }}">
     {{^inform}}
-    <form autocomplete="off" action="{{{ action }}}" method="get" accept-charset="utf-8" class="mform form-inline simplesearchform">
+    <form autocomplete="off" action="{{ action }}" method="get" accept-charset="utf-8" class="mform form-inline simplesearchform">
     {{/inform}}
     {{#hiddenfields}}
         <input type="hidden" name="{{ name }}" value="{{ value }}">
         <input type="text"
            id="searchinput-{{uniqid}}"
            class="form-control"
-           placeholder="{{{ searchstring }}}"
-           aria-label="{{{ searchstring }}}"
-           name="{{{ inputname }}}"
+           placeholder="{{ searchstring }}"
+           aria-label="{{ searchstring }}"
+           name="{{ inputname }}"
            data-region="input"
            autocomplete="off"
-           value="{{{ query }}}"
+           value="{{ query }}"
         >
         <div class="input-group-append">
-            <button type="submit" class="btn {{^btnclass}}btn-submit{{/btnclass}} {{{ btnclass }}} search-icon">
+            <button type="submit" class="btn {{^btnclass}}btn-submit{{/btnclass}} {{ btnclass }} search-icon">
                 {{#pix}} a/search, core {{/pix}}
-                <span class="sr-only">{{{ searchstring }}}</span>
+                <span class="sr-only">{{ searchstring }}</span>
             </button>
         </div>
 
index 7e89b09..d2cdc48 100644 (file)
@@ -49,6 +49,7 @@
            class="form-control withclear"
            placeholder="{{$placeholder}}{{#str}} search, core {{/str}}{{/placeholder}}"
            name="search"
+           value="{{$value}}{{/value}}"
            autocomplete="off"
         >
         <button
index 6f561ec..73953d8 100644 (file)
@@ -95,7 +95,7 @@ function(
     var container = $('#searchinput-navbar-' + uniqid);
     var opensearch = container.find('[data-action="opensearch"]');
     var input = container.find('[data-region="input"]');
-    var submit = container.find('[data-action="submit"');
+    var submit = container.find('[data-action="submit"]');
 
     submit.on('click', function(e) {
         if (input.val() === '') {
@@ -113,4 +113,4 @@ function(
         input.focus();
     });
 });
-{{/js}}
\ No newline at end of file
+{{/js}}
index 2532f94..2b92271 100644 (file)
@@ -693,18 +693,21 @@ class behat_forms extends behat_base {
         $xpathtarget = "//ul[@class='form-autocomplete-suggestions']//*[contains(concat('|', string(.), '|'),'|" . $item . "|')]";
 
         $this->execute('behat_general::i_click_on', [$xpathtarget, 'xpath_element']);
-
-        $this->execute('behat_general::i_press_key_in_element', ['13', 'body', 'xpath_element']);
     }
 
     /**
      * Open the auto-complete suggestions list (Assuming there is only one on the page.).
      *
-     * @Given /^I open the autocomplete suggestions list$/
+     * @Given I open the autocomplete suggestions list
+     * @Given I open the autocomplete suggestions list in the :container :containertype
      */
-    public function i_open_the_autocomplete_suggestions_list() {
+    public function i_open_the_autocomplete_suggestions_list($container = null, $containertype = null) {
         $csstarget = ".form-autocomplete-downarrow";
-        $this->execute('behat_general::i_click_on', [$csstarget, 'css_element']);
+        if ($container && $containertype) {
+            $this->execute('behat_general::i_click_on', [$csstarget, 'css_element', $container, $containertype]);
+        } else {
+            $this->execute('behat_general::i_click_on', [$csstarget, 'css_element']);
+        }
     }
 
     /**
index c46383b..0ff1c55 100644 (file)
@@ -650,4 +650,33 @@ EOF;
         $this->assertTrue(in_array(['name' => 'class', 'value' => $labelclass], $data->labelattributes));
         $this->assertTrue(in_array(['name' => 'style', 'value' => $labelstyle], $data->labelattributes));
     }
+
+    /**
+     * Data provider for test_block_contents_is_fake().
+     *
+     * @return array
+     */
+    public function block_contents_is_fake_provider() {
+        return [
+            'Null' => [null, false],
+            'Not set' => [false, false],
+            'Fake' => ['_fake', true],
+            'Real block' => ['activity_modules', false],
+        ];
+    }
+
+    /**
+     * Test block_contents is_fake() method.
+     *
+     * @dataProvider block_contents_is_fake_provider
+     * @param mixed $value Value for the data-block attribute
+     * @param boolean $expected The expected result
+     */
+    public function test_block_contents_is_fake($value, $expected) {
+        $bc = new block_contents(array());
+        if ($value !== false) {
+            $bc->attributes['data-block'] = $value;
+        }
+        $this->assertEquals($expected, $bc->is_fake());
+    }
 }
index a16e8e0..d57c352 100644 (file)
@@ -640,6 +640,47 @@ class core_scheduled_task_testcase extends advanced_testcase {
         }
     }
 
+    /**
+     * Check that an overridden task is sent to be processed.
+     */
+    public function test_scheduled_task_overridden_task_can_run(): void {
+        global $CFG, $DB;
+
+        $this->resetAfterTest();
+
+        // Delete all existing scheduled tasks.
+        $DB->delete_records('task_scheduled');
+
+        // Add overrides to the config.
+        $CFG->scheduled_tasks = [
+            '\core\task\scheduled_test_task' => [
+                'disabled' => 1
+            ],
+            '\core\task\scheduled_test2_task' => [
+                'disabled' => 0
+            ],
+        ];
+
+        // A task that runs once per hour.
+        $record = new stdClass();
+        $record->component = 'test_scheduled_task';
+        $record->classname = '\core\task\scheduled_test_task';
+        $record->disabled = 0;
+        $DB->insert_record('task_scheduled', $record);
+
+        // And disabled test.
+        $record->classname = '\core\task\scheduled_test2_task';
+        $record->disabled = 1;
+        $DB->insert_record('task_scheduled', $record);
+
+        $now = time();
+
+        $scheduledtask = \core\task\manager::get_next_scheduled_task($now);
+        $this->assertInstanceOf('\core\task\scheduled_test2_task', $scheduledtask);
+        $scheduledtask->execute();
+        \core\task\manager::scheduled_task_complete($scheduledtask);
+    }
+
     /**
      * Assert that the specified tasks are equal.
      *
@@ -691,4 +732,24 @@ class core_scheduled_task_testcase extends advanced_testcase {
 
         call_user_func_array([$this, 'assertNotEquals'], $args);
     }
+
+    /**
+     * Assert that the lastruntime column holds an original value after a scheduled task is reset.
+     */
+    public function test_reset_scheduled_tasks_for_component_keeps_original_lastruntime(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Set lastruntime for the scheduled task.
+        $DB->set_field('task_scheduled', 'lastruntime', 123456789, ['classname' => '\core\task\session_cleanup_task']);
+
+        // Reset the task.
+        \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+        // Fetch the task again.
+        $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // Confirm, that lastruntime is still in place.
+        $this->assertEquals(123456789, $taskafterreset->get_last_run_time());
+    }
 }
index 35f3df7..3308d07 100644 (file)
@@ -213,4 +213,16 @@ class core_theme_config_testcase extends advanced_testcase {
 
         $this->assertEquals($cssexpected, $cssactual);
     }
+
+    /**
+     * Test that {@see theme_config::get_all_block_regions()} returns localised list of region names.
+     */
+    public function test_get_all_block_regions() {
+        $this->resetAfterTest();
+
+        $theme = theme_config::load(theme_config::DEFAULT_THEME);
+        $regions = $theme->get_all_block_regions();
+
+        $this->assertEquals('Right', $regions['side-pre']);
+    }
 }
index a0e3b48..5298ad5 100644 (file)
@@ -275,6 +275,55 @@ class core_upgradelib_testcase extends advanced_testcase {
         $this->assertEquals(20150627, $CFG->{'gradebook_calculations_freeze_' . $course2->id});
     }
 
+    /**
+     * Test the upgrade function for final grade after setting grade max for category and grade item.
+     */
+    public function test_upgrade_update_category_grademax_regrade_final_grades() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+
+        // Create a new course.
+        $course = $generator->create_course();
+
+        // Set the course aggregation to weighted mean of grades.
+        $unitcategory = \grade_category::fetch_course_category($course->id);
+        $unitcategory->aggregation = GRADE_AGGREGATE_WEIGHTED_MEAN;
+        $unitcategory->update();
+
+        // Set grade max for category.
+        $gradecategoryitem = grade_item::fetch(array('iteminstance' => $unitcategory->id));
+        $gradecategoryitem->grademax = 50;
+        $gradecategoryitem->update();
+
+        // Make new grade item.
+        $gradeitem = new \grade_item($generator->create_grade_item([
+            'itemname'        => 'Grade item',
+            'idnumber'        => 'git1',
+            'courseid'        => $course->id,
+            'grademin'        => 0,
+            'grademax'        => 50,
+            'aggregationcoef' => 100.0,
+        ]));
+
+        // Set final grade.
+        $grade = $gradeitem->get_grade($user->id, true);
+        $grade->finalgrade = 20;
+        $grade->update();
+
+        $courseitem = \grade_item::fetch(['courseid' => $course->id, 'itemtype' => 'course']);
+        $gradeitem->force_regrading();
+
+        // Trigger regrade because the grade items needs to be updated.
+        grade_regrade_final_grades($course->id);
+
+        $coursegrade = new \grade_grade($courseitem->get_final($user->id), false);
+        $this->assertEquals(20, $coursegrade->finalgrade);
+    }
+
     function test_upgrade_calculated_grade_items_regrade() {
         global $DB, $CFG;
 
index 32ca880..768a7af 100644 (file)
     <location>horde</location>
     <name>Horde</name>
     <license>LGPL/BSD</license>
-    <version>5.2.22</version>
+    <version>5.2.23</version>
     <licenseversion>2.1</licenseversion>
   </library>
   <library>
     <location>amd/src/loglevel.js</location>
     <name>loglevel.js</name>
     <license>MIT</license>
-    <version>1.6.6</version>
+    <version>1.7.1</version>
   </library>
   <library>
     <location>mustache</location>
index 9f0d616..499aeda 100644 (file)
@@ -2,6 +2,7 @@ This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
 === 3.11 ===
+* The horde library has been updated to version 5.2.23.
 * New optional parameter $extracontent for print_collapsible_region_start(). This allows developers to add interactive HTML elements
   (e.g. a help icon) after the collapsible region's toggle link.
 * Final deprecation i_dock_block() in behat_deprecated.php
index a8f4161..17a6e91 100644 (file)
Binary files a/media/player/videojs/amd/build/video-lazy.min.js and b/media/player/videojs/amd/build/video-lazy.min.js differ
index 4c6a31d..463095d 100644 (file)
Binary files a/media/player/videojs/amd/build/video-lazy.min.js.map and b/media/player/videojs/amd/build/video-lazy.min.js.map differ
index 9e57c6c..9798383 100644 (file)
@@ -1,6 +1,6 @@
 /**
  * @license
- * Video.js 7.7.6 <http://videojs.com/>
+ * Video.js 7.10.0 <http://videojs.com/>
  * Copyright Brightcove, Inc. <https://www.brightcove.com/>
  * Available under Apache License Version 2.0
  * <https://github.com/videojs/video.js/blob/master/LICENSE>
   typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('global/window'), require('global/document')) :
   typeof define === 'function' && define.amd ? define(['./window', './document'], factory) :
   (global = global || self, global.videojs = factory(global.window, global.document));
-}(this, function (window$3, document) { 'use strict';
+}(this, (function (window$3, document) { 'use strict';
 
-  window$3 = window$3 && window$3.hasOwnProperty('default') ? window$3['default'] : window$3;
-  document = document && document.hasOwnProperty('default') ? document['default'] : document;
+  window$3 = window$3 && Object.prototype.hasOwnProperty.call(window$3, 'default') ? window$3['default'] : window$3;
+  document = document && Object.prototype.hasOwnProperty.call(document, 'default') ? document['default'] : document;
 
-  var version = "7.7.6";
+  var version = "7.10.0";
 
   /**
    * @file create-logger.js
    */
 
   function findPosition(el) {
-    var box;
-
-    if (el.getBoundingClientRect && el.parentNode) {
-      box = el.getBoundingClientRect();
-    }
-
-    if (!box) {
+    if (!el || el && !el.offsetParent) {
       return {
         left: 0,
-        top: 0
+        top: 0,
+        width: 0,
+        height: 0
       };
     }
 
-    var docEl = document.documentElement;
-    var body = document.body;
-    var clientLeft = docEl.clientLeft || body.clientLeft || 0;
-    var scrollLeft = window$3.pageXOffset || body.scrollLeft;
-    var left = box.left + scrollLeft - clientLeft;
-    var clientTop = docEl.clientTop || body.clientTop || 0;
-    var scrollTop = window$3.pageYOffset || body.scrollTop;
-    var top = box.top + scrollTop - clientTop; // Android sometimes returns slightly off decimal values, so need to round
+    var width = el.offsetWidth;
+    var height = el.offsetHeight;
+    var left = 0;
+    var top = 0;
+
+    do {
+      left += el.offsetLeft;
+      top += el.offsetTop;
+      el = el.offsetParent;
+    } while (el);
 
     return {
-      left: Math.round(left),
-      top: Math.round(top)
+      left: left,
+      top: top,
+      width: width,
+      height: height
     };
   }
   /**
 
   function getPointerPosition(el, event) {
     var position = {};
+    var boxTarget = findPosition(event.target);
     var box = findPosition(el);
-    var boxW = el.offsetWidth;
-    var boxH = el.offsetHeight;
-    var boxY = box.top;
-    var boxX = box.left;
-    var pageY = event.pageY;
-    var pageX = event.pageX;
+    var boxW = box.width;
+    var boxH = box.height;
+    var offsetY = event.offsetY - (box.top - boxTarget.top);
+    var offsetX = event.offsetX - (box.left - boxTarget.left);
 
     if (event.changedTouches) {
-      pageX = event.changedTouches[0].pageX;
-      pageY = event.changedTouches[0].pageY;
+      offsetX = event.changedTouches[0].pageX - box.left;
+      offsetY = event.changedTouches[0].pageY + box.top;
     }
 
-    position.y = Math.max(0, Math.min(1, (boxY - pageY + boxH) / boxH));
-    position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+    position.y = 1 - Math.max(0, Math.min(1, offsetY / boxH));
+    position.x = Math.max(0, Math.min(1, offsetX / boxW));
     return position;
   }
   /**
   var $$ = createQuerier('querySelectorAll');
 
   var Dom = /*#__PURE__*/Object.freeze({
+    __proto__: null,
     isReal: isReal,
     isEl: isEl,
     isInFrame: isInFrame,
   }
 
   var Events = /*#__PURE__*/Object.freeze({
+    __proto__: null,
     fixEvent: fixEvent,
     on: on,
     off: off,
     return result;
   }
 
+  var MapSham = /*#__PURE__*/function () {
+    function MapSham() {
+      this.map_ = {};
+    }
+
+    var _proto = MapSham.prototype;
+
+    _proto.has = function has(key) {
+      return key in this.map_;
+    };
+
+    _proto["delete"] = function _delete(key) {
+      var has = this.has(key);
+      delete this.map_[key];
+      return has;
+    };
+
+    _proto.set = function set(key, value) {
+      this.set_[key] = value;
+      return this;
+    };
+
+    _proto.forEach = function forEach(callback, thisArg) {
+      for (var key in this.map_) {
+        callback.call(thisArg, this.map_[key], key, this);
+      }
+    };
+
+    return MapSham;
+  }();
+
+  var Map$1 = window$3.Map ? window$3.Map : MapSham;
+
+  var SetSham = /*#__PURE__*/function () {
+    function SetSham() {
+      this.set_ = {};
+    }
+
+    var _proto = SetSham.prototype;
+
+    _proto.has = function has(key) {
+      return key in this.set_;
+    };
+
+    _proto["delete"] = function _delete(key) {
+      var has = this.has(key);
+      delete this.set_[key];
+      return has;
+    };
+
+    _proto.add = function add(key) {
+      this.set_[key] = 1;
+      return this;
+    };
+
+    _proto.forEach = function forEach(callback, thisArg) {
+      for (var key in this.set_) {
+        callback.call(thisArg, key, key, this);
+      }
+    };
+
+    return SetSham;
+  }();
+
+  var Set = window$3.Set ? window$3.Set : SetSham;
+
   /**
    * Player Component - Base class for all UI objects
    *
       this.children_ = [];
       this.childIndex_ = {};
       this.childNameIndex_ = {};
-      var SetSham;
-
-      if (!window$3.Set) {
-        SetSham = /*#__PURE__*/function () {
-          function SetSham() {
-            this.set_ = {};
-          }
-
-          var _proto2 = SetSham.prototype;
-
-          _proto2.has = function has(key) {
-            return key in this.set_;
-          };
-
-          _proto2["delete"] = function _delete(key) {
-            var has = this.has(key);
-            delete this.set_[key];
-            return has;
-          };
-
-          _proto2.add = function add(key) {
-            this.set_[key] = 1;
-            return this;
-          };
-
-          _proto2.forEach = function forEach(callback, thisArg) {
-            for (var key in this.set_) {
-              callback.call(thisArg, key, key, this);
-            }
-          };
-
-          return SetSham;
-        }();
-      }
-
-      this.setTimeoutIds_ = window$3.Set ? new Set() : new SetSham();
-      this.setIntervalIds_ = window$3.Set ? new Set() : new SetSham();
-      this.rafIds_ = window$3.Set ? new Set() : new SetSham();
+      this.setTimeoutIds_ = new Set();
+      this.setIntervalIds_ = new Set();
+      this.rafIds_ = new Set();
+      this.namedRafs_ = new Map$1();
       this.clearingTimersOnDispose_ = false; // Add any child components in options
 
       if (options.initChildren !== false) {
 
       return this.childNameIndex_[name];
     }
+    /**
+     * Returns the descendant `Component` following the givent
+     * descendant `names`. For instance ['foo', 'bar', 'baz'] would
+     * try to get 'foo' on the current component, 'bar' on the 'foo'
+     * component and 'baz' on the 'bar' component and return undefined
+     * if any of those don't exist.
+     *
+     * @param {...string[]|...string} names
+     *        The name of the child `Component` to get.
+     *
+     * @return {Component|undefined}
+     *         The descendant `Component` following the given descendant
+     *         `names` or undefined.
+     */
+    ;
+
+    _proto.getDescendant = function getDescendant() {
+      for (var _len = arguments.length, names = new Array(_len), _key = 0; _key < _len; _key++) {
+        names[_key] = arguments[_key];
+      }
+
+      // flatten array argument into the main array
+      names = names.reduce(function (acc, n) {
+        return acc.concat(n);
+      }, []);
+      var currentChild = this;
+
+      for (var i = 0; i < names.length; i++) {
+        currentChild = currentChild.getChild(names[i]);
+
+        if (!currentChild || !currentChild.getChild) {
+          return;
+        }
+      }
+
+      return currentChild;
+    }
     /**
      * Add a child `Component` inside the current `Component`.
      *
         // If inserting before a component, insert before that component's element
         var refNode = null;
 
-        if (this.children_[index + 1] && this.children_[index + 1].el_) {
-          refNode = this.children_[index + 1].el_;
+        if (this.children_[index + 1]) {
+          // Most children are components, but the video tech is an HTML element
+          if (this.children_[index + 1].el_) {
+            refNode = this.children_[index + 1].el_;
+          } else if (isEl(this.children_[index + 1])) {
+            refNode = this.children_[index + 1];
+          }
         }
 
         this.contentEl().insertBefore(component.el(), refNode);
       this.rafIds_.add(id);
       return id;
     }
+    /**
+     * Request an animation frame, but only one named animation
+     * frame will be queued. Another will never be added until
+     * the previous one finishes.
+     *
+     * @param {string} name
+     *        The name to give this requestAnimationFrame
+     *
+     * @param  {Component~GenericCallback} fn
+     *         A function that will be bound to this component and executed just
+     *         before the browser's next repaint.
+     */
+    ;
+
+    _proto.requestNamedAnimationFrame = function requestNamedAnimationFrame(name, fn) {
+      var _this4 = this;
+
+      if (this.namedRafs_.has(name)) {
+        return;
+      }
+
+      this.clearTimersOnDispose_();
+      fn = bind(this, fn);
+      var id = this.requestAnimationFrame(function () {
+        fn();
+
+        if (_this4.namedRafs_.has(name)) {
+          _this4.namedRafs_["delete"](name);
+        }
+      });
+      this.namedRafs_.set(name, id);
+      return name;
+    }
+    /**
+     * Cancels a current named animation frame if it exists.
+     *
+     * @param {string} name
+     *        The name of the requestAnimationFrame to cancel.
+     */
+    ;
+
+    _proto.cancelNamedAnimationFrame = function cancelNamedAnimationFrame(name) {
+      if (!this.namedRafs_.has(name)) {
+        return;
+      }
+
+      this.cancelAnimationFrame(this.namedRafs_.get(name));
+      this.namedRafs_["delete"](name);
+    }
     /**
      * Cancels a queued callback passed to {@link Component#requestAnimationFrame}
      * (rAF).
     ;
 
     _proto.clearTimersOnDispose_ = function clearTimersOnDispose_() {
-      var _this4 = this;
+      var _this5 = this;
 
       if (this.clearingTimersOnDispose_) {
         return;
 
       this.clearingTimersOnDispose_ = true;
       this.one('dispose', function () {
-        [['rafIds_', 'cancelAnimationFrame'], ['setTimeoutIds_', 'clearTimeout'], ['setIntervalIds_', 'clearInterval']].forEach(function (_ref) {
+        [['namedRafs_', 'cancelNamedAnimationFrame'], ['rafIds_', 'cancelAnimationFrame'], ['setTimeoutIds_', 'clearTimeout'], ['setIntervalIds_', 'clearInterval']].forEach(function (_ref) {
           var idName = _ref[0],
               cancelName = _ref[1];
 
-          _this4[idName].forEach(_this4[cancelName], _this4);
+          // for a `Set` key will actually be the value again
+          // so forEach((val, val) =>` but for maps we want to use
+          // the key.
+          _this5[idName].forEach(function (val, key) {
+            return _this5[cancelName](key);
+          });
         });
-        _this4.clearingTimersOnDispose_ = false;
+        _this5.clearingTimersOnDispose_ = false;
       });
     }
     /**
   var IS_ANY_SAFARI = (IS_SAFARI || IS_IOS) && !IS_CHROME;
 
   var browser = /*#__PURE__*/Object.freeze({
+    __proto__: null,
     IS_IPOD: IS_IPOD,
     IOS_VERSION: IOS_VERSION,
     IS_ANDROID: IS_ANDROID,
   };
 
   var Url = /*#__PURE__*/Object.freeze({
+    __proto__: null,
     parseUrl: parseUrl,
     getAbsoluteURL: getAbsoluteURL,
     getFileExtension: getFileExtension,
       opts.cors = crossOrigin;
     }
 
+    var withCredentials = track.tech_.crossOrigin() === 'use-credentials';
+
+    if (withCredentials) {
+      opts.withCredentials = withCredentials;
+    }
+
     xhr(opts, bind(this, function (err, response, responseBody) {
       if (err) {
         return log.error(err, response);
     ;
 
     _proto.reset = function reset() {}
+    /**
+     * Get the value of `crossOrigin` from the tech.
+     *
+     * @abstract
+     *
+     * @see {Html5#crossOrigin}
+     */
+    ;
+
+    _proto.crossOrigin = function crossOrigin() {}
+    /**
+     * Set the value of `crossOrigin` on the tech.
+     *
+     * @abstract
+     *
+     * @param {string} crossOrigin the crossOrigin value
+     * @see {Html5#setCrossOrigin}
+     */
+    ;
+
+    _proto.setCrossOrigin = function setCrossOrigin() {}
     /**
      * Get or set an error on the Tech.
      *
 
       return createTimeRanges();
     }
+    /**
+     * Set whether we are scrubbing or not
+     *
+     * @abstract
+     *
+     * @see {Html5#setScrubbing}
+     */
+    ;
+
+    _proto.setScrubbing = function setScrubbing() {}
     /**
      * Causes a manual time update to occur if {@link Tech#manualTimeUpdatesOn} was
      * previously called.
         return PromiseClass.reject();
       }
     }
+    /**
+     * A method to check for the value of the 'disablePictureInPicture' <video> property.
+     * Defaults to true, as it should be considered disabled if the tech does not support pip
+     *
+     * @abstract
+     */
+    ;
+
+    _proto.disablePictureInPicture = function disablePictureInPicture() {
+      return true;
+    }
+    /**
+     * A method to set or unset the 'disablePictureInPicture' <video> property.
+     *
+     * @abstract
+     */
+    ;
+
+    _proto.setDisablePictureInPicture = function setDisablePictureInPicture() {}
     /**
      * A method to set a poster from a `Tech`.
      *
     m4a: 'audio/mp4',
     mp3: 'audio/mpeg',
     aac: 'audio/aac',
+    caf: 'audio/x-caf',
+    flac: 'audio/flac',
     oga: 'audio/ogg',
+    wav: 'audio/wav',
     m3u8: 'application/x-mpegURL',
     jpg: 'image/jpeg',
     jpeg: 'image/jpeg',
           cueDiv.style.fontSize = fontSize * overrides.fontPercent + 'px';
           cueDiv.style.height = 'auto';
           cueDiv.style.top = 'auto';
-          cueDiv.style.bottom = '2px';
         }
 
         if (overrides.fontFamily && overrides.fontFamily !== 'default') {
       }
 
       this.formattedTime_ = time;
-      this.requestAnimationFrame(function () {
+      this.requestNamedAnimationFrame('TimeDisplay#updateTextNode_', function () {
         if (!_this2.contentEl_) {
           return;
         }
      */
     ;
 
-    _proto.updateLiveEdgeStatus = function updateLiveEdgeStatus(e) {
+    _proto.updateLiveEdgeStatus = function updateLiveEdgeStatus() {
       // default to live edge
       if (!this.player_.liveTracker || this.player_.liveTracker.atLiveEdge()) {
         this.setAttribute('aria-disabled', true);
       }
 
       this.progress_ = progress;
-      this.requestAnimationFrame(function () {
+      this.requestNamedAnimationFrame('Slider#update', function () {
         // Set the new bar width or height
         var sizeKey = _this2.vertical() ? 'height' : 'width'; // Convert to a percentage for css value
 
     _proto.update = function update(event) {
       var _this2 = this;
 
-      this.requestAnimationFrame(function () {
+      this.requestNamedAnimationFrame('LoadProgressBar#update', function () {
         var liveTracker = _this2.player_.liveTracker;
 
         var buffered = _this2.player_.buffered();
     ;
 
     _proto.update = function update(seekBarRect, seekBarPoint, content) {
-      var tooltipRect = getBoundingClientRect(this.el_);
+      var tooltipRect = findPosition(this.el_);
       var playerRect = getBoundingClientRect(this.player_.el());
       var seekBarPointPx = seekBarRect.width * seekBarPoint; // do nothing if either rect isn't available
       // for example, if the player isn't in the DOM for testing
     _proto.updateTime = function updateTime(seekBarRect, seekBarPoint, time, cb) {
       var _this2 = this;
 
-      // If there is an existing rAF ID, cancel it so we don't over-queue.
-      if (this.rafId_) {
-        this.cancelAnimationFrame(this.rafId_);
-      }
-
-      this.rafId_ = this.requestAnimationFrame(function () {
+      this.requestNamedAnimationFrame('TimeTooltip#updateTime', function () {
         var content;
 
         var duration = _this2.player_.duration();
     };
 
     _proto.disableInterval_ = function disableInterval_(e) {
-      if (this.player_.liveTracker && this.player_.liveTracker.isLive() && e.type !== 'ended') {
+      if (this.player_.liveTracker && this.player_.liveTracker.isLive() && e && e.type !== 'ended') {
         return;
       }
 
 
       var percent = _Slider.prototype.update.call(this);
 
-      this.requestAnimationFrame(function () {
+      this.requestNamedAnimationFrame('SeekBar#update', function () {
         var currentTime = _this2.player_.ended() ? _this2.player_.duration() : _this2.getCurrentTime_();
         var liveTracker = _this2.player_.liveTracker;
 
           newTime = newTime - 0.1;
         }
       } else {
+        if (distance >= 0.99) {
+          liveTracker.seekToLiveEdge();
+          return;
+        }
+
         var seekableStart = liveTracker.seekableStart();
         var seekableEnd = liveTracker.liveCurrentTime();
         newTime = seekableStart + distance * liveTracker.liveWindow(); // Don't let video end while scrubbing.
       }
     };
 
+    _proto.dispose = function dispose() {
+      this.disableInterval_();
+      this.off(this.player_, ['ended', 'durationchange', 'timeupdate'], this.update);
+
+      if (this.player_.liveTracker) {
+        this.on(this.player_.liveTracker, 'liveedgechange', this.update);
+      }
+
+      this.off(this.player_, ['playing'], this.enableInterval_);
+      this.off(this.player_, ['ended', 'pause', 'waiting'], this.disableInterval_); // we don't need to update the play progress if the document is hidden,
+      // also, this causes the CPU to spike and eventually crash the page on IE11.
+
+      if ('hidden' in document && 'visibilityState' in document) {
+        this.off(document, 'visibilitychange', this.toggleVisibility_);
+      }
+
+      _Slider.prototype.dispose.call(this);
+    };
+
     return SeekBar;
   }(Slider);
   /**
       }
 
       var seekBarEl = seekBar.el();
-      var seekBarRect = getBoundingClientRect(seekBarEl);
+      var seekBarRect = findPosition(seekBarEl);
       var seekBarPoint = getPointerPosition(seekBarEl, event).x; // The default skin has a gap on either side of the `SeekBar`. This means
       // that it's possible to trigger this behavior outside the boundaries of
       // the `SeekBar`. This ensures we stay within it at all times.
 
-      seekBarPoint = clamp(0, 1, seekBarPoint);
+      seekBarPoint = clamp(seekBarPoint, 0, 1);
 
       if (mouseTimeDisplay) {
         mouseTimeDisplay.update(seekBarRect, seekBarPoint);
 
       _this = _Button.call(this, player, options) || this;
 
-      _this.on(player, ['enterpictureinpicture', 'leavepictureinpicture'], _this.handlePictureInPictureChange); // TODO: Activate button on player loadedmetadata event.
-      // TODO: Deactivate button on player emptied event.
-      // TODO: Deactivate button if disablepictureinpicture attribute is present.
+      _this.on(player, ['enterpictureinpicture', 'leavepictureinpicture'], _this.handlePictureInPictureChange);
 
+      _this.on(player, ['disablepictureinpicturechanged', 'loadedmetadata'], _this.handlePictureInPictureEnabledChange); // TODO: Deactivate button on player emptied event.
 
-      if (!document.pictureInPictureEnabled) {
-        _this.disable();
-      }
+
+      _this.disable();
 
       return _this;
     }
     _proto.buildCSSClass = function buildCSSClass() {
       return "vjs-picture-in-picture-control " + _Button.prototype.buildCSSClass.call(this);
     }
+    /**
+     * Enables or disables button based on document.pictureInPictureEnabled property value
+     * or on value returned by player.disablePictureInPicture() method.
+     */
+    ;
+
+    _proto.handlePictureInPictureEnabledChange = function handlePictureInPictureEnabledChange() {
+      if (document.pictureInPictureEnabled && this.player_.disablePictureInPicture() === false) {
+        this.enable();
+      } else {
+        this.disable();
+      }
+    }
     /**
      * Handles enterpictureinpicture and leavepictureinpicture on the player and change control text accordingly.
      *
       } else {
         this.controlText('Picture-in-Picture');
       }
+
+      this.handlePictureInPictureEnabledChange();
     }
     /**
      * This gets called when an `PictureInPictureToggle` is "clicked". See
 
   Component.registerComponent('ResizeManager', ResizeManager);
 
-  /**
-   * Computes the median of an array.
-   *
-   * @param {number[]} arr
-   *        Input array of numbers.
-   *
-   * @return {number}
-   *        Median value.
-   */
-  var median = function median(arr) {
-    var mid = Math.floor(arr.length / 2);
-    var sortedList = [].concat(arr).sort(function (a, b) {
-      return a - b;
-    });
-    return arr.length % 2 !== 0 ? sortedList[mid] : (sortedList[mid - 1] + sortedList[mid]) / 2;
-  };
-
   var defaults = {
-    // Number of seconds of live window (seekableEnd - seekableStart) that
-    // a video needs to have before the liveui will be shown.
-    trackingThreshold: 30
+    trackingThreshold: 30,
+    liveTolerance: 15
   };
-  /* track when we are at the live edge, and other helpers for live playback */
+  /*
+    track when we are at the live edge, and other helpers for live playback */
+
+  /**
+   * A class for checking live current time and determining when the player
+   * is at or behind the live edge.
+   */
 
   var LiveTracker = /*#__PURE__*/function (_Component) {
     inheritsLoose(LiveTracker, _Component);
 
+    /**
+     * Creates an instance of this class.
+     *
+     * @param {Player} player
+     *        The `Player` that this class should be attached to.
+     *
+     * @param {Object} [options]
+     *        The key/value store of player options.
+     *
+     * @param {number} [options.trackingThreshold=30]
+     *        Number of seconds of live window (seekableEnd - seekableStart) that
+     *        media needs to have before the liveui will be shown.
+     *
+     * @param {number} [options.liveTolerance=15]
+     *        Number of seconds behind live that we have to be
+     *        before we will be considered non-live. Note that this will only
+     *        be used when playing at the live edge. This allows large seekable end
+     *        changes to not effect wether we are live or not.
+     */
     function LiveTracker(player, options) {
       var _this;
 
 
       return _this;
     }
+    /**
+     * toggle tracking based on document visiblility
+     */
+
 
     var _proto = LiveTracker.prototype;
 
       } else {
         this.startTracking();
       }
-    };
-
-    _proto.isBehind_ = function isBehind_() {
-      // don't report that we are behind until a timeupdate has been seen
-      if (!this.timeupdateSeen_) {
-        return false;
-      }
-
-      var liveCurrentTime = this.liveCurrentTime();
-      var currentTime = this.player_.currentTime(); // the live edge window is the amount of seconds away from live
-      // that a player can be, but still be considered live.
-      // we add 0.07 because the live tracking happens every 30ms
-      // and we want some wiggle room for short segment live playback
-
-      var liveEdgeWindow = this.seekableIncrement_ * 2 + 0.07; // on Android liveCurrentTime can bee Infinity, because seekableEnd
-      // can be Infinity, so we handle that case.
-
-      return liveCurrentTime !== Infinity && liveCurrentTime - liveEdgeWindow >= currentTime;
-    } // all the functionality for tracking when seek end changes
-    // and for tracking how far past seek end we should be
+    }
+    /**
+     * all the functionality for tracking when seek end changes
+     * and for tracking how far past seek end we should be
+     */
     ;
 
     _proto.trackLive_ = function trackLive_() {
-      this.pastSeekEnd_ = this.pastSeekEnd_;
       var seekable = this.player_.seekable(); // skip undefined seekable
 
       if (!seekable || !seekable.length) {
         return;
       }
 
-      var newSeekEnd = this.seekableEnd(); // we can only tell if we are behind live, when seekable changes
-      // once we detect that seekable has changed we check the new seek
-      // end against current time, with a fudge value of half a second.
-
-      if (newSeekEnd !== this.lastSeekEnd_) {
-        if (this.lastSeekEnd_) {
-          // we try to get the best fit value for the seeking increment
-          // variable from the last 12 values.
-          this.seekableIncrementList_ = this.seekableIncrementList_.slice(-11);
-          this.seekableIncrementList_.push(Math.abs(newSeekEnd - this.lastSeekEnd_));
-
-          if (this.seekableIncrementList_.length > 3) {
-            this.seekableIncrement_ = median(this.seekableIncrementList_);
-          }
-        }
-
-        this.pastSeekEnd_ = 0;
-        this.lastSeekEnd_ = newSeekEnd;
-        this.trigger('seekableendchange');
-      } // we should reset pastSeekEnd when the value
-      // is much higher than seeking increment.
+      var newTime = Number(window$3.performance.now().toFixed(4));
+      var deltaTime = this.lastTime_ === -1 ? 0 : (newTime - this.lastTime_) / 1000;
+      this.lastTime_ = newTime;
+      this.pastSeekEnd_ = this.pastSeekEnd() + deltaTime;
+      var liveCurrentTime = this.liveCurrentTime();
+      var currentTime = this.player_.currentTime(); // we are behind live if any are true
+      // 1. the player is paused
+      // 2. the user seeked to a location 2 seconds away from live
+      // 3. the difference between live and current time is greater
+      //    liveTolerance which defaults to 15s
 
+      var isBehind = this.player_.paused() || this.seekedBehindLive_ || Math.abs(liveCurrentTime - currentTime) > this.options_.liveTolerance; // we cannot be behind if
+      // 1. until we have not seen a timeupdate yet
+      // 2. liveCurrentTime is Infinity, which happens on Android
 
-      if (this.pastSeekEnd() > this.seekableIncrement_ * 1.5) {
-        this.pastSeekEnd_ = 0;
-      } else {
-        this.pastSeekEnd_ = this.pastSeekEnd() + 0.03;
+      if (!this.timeupdateSeen_ || liveCurrentTime === Infinity) {
+        isBehind = false;
       }
 
-      if (this.isBehind_() !== this.behindLiveEdge()) {
-        this.behindLiveEdge_ = this.isBehind_();
+      if (isBehind !== this.behindLiveEdge_) {
+        this.behindLiveEdge_ = isBehind;
         this.trigger('liveedgechange');
       }
     }
     ;
 
     _proto.startTracking = function startTracking() {
-      var _this2 = this;
-
       if (this.isTracking()) {
         return;
       } // If we haven't seen a timeupdate, we need to check whether playback
 
       this.trackingInterval_ = this.setInterval(this.trackLive_, UPDATE_REFRESH_INTERVAL);
       this.trackLive_();
-      this.on(this.player_, 'play', this.trackLive_);
-      this.on(this.player_, 'pause', this.trackLive_); // this is to prevent showing that we are not live
-      // before a video starts to play
+      this.on(this.player_, ['play', 'pause'], this.trackLive_);
 
       if (!this.timeupdateSeen_) {
         this.one(this.player_, 'play', this.handlePlay);
+        this.one(this.player_, 'timeupdate', this.handleFirstTimeupdate);
+      } else {
+        this.on(this.player_, 'seeked', this.handleSeeked);
+      }
+    }
+    /**
+     * handle the first timeupdate on the player if it wasn't already playing
+     * when live tracker started tracking.
+     */
+    ;
 
-        this.handleTimeupdate = function () {
-          _this2.timeupdateSeen_ = true;
-          _this2.handleTimeupdate = null;
-        };
+    _proto.handleFirstTimeupdate = function handleFirstTimeupdate() {
+      this.timeupdateSeen_ = true;
+      this.on(this.player_, 'seeked', this.handleSeeked);
+    }
+    /**
+     * Keep track of what time a seek starts, and listen for seeked
+     * to find where a seek ends.
+     */
+    ;
 
-        this.one(this.player_, 'timeupdate', this.handleTimeupdate);
-      }
-    };
+    _proto.handleSeeked = function handleSeeked() {
+      var timeDiff = Math.abs(this.liveCurrentTime() - this.player_.currentTime());
+      this.seekedBehindLive_ = this.skipNextSeeked_ ? false : timeDiff > 2;
+      this.skipNextSeeked_ = false;
+      this.trackLive_();
+    }
+    /**
+     * handle the first play on the player, and make sure that we seek
+     * right to the live edge.
+     */
+    ;
 
     _proto.handlePlay = function handlePlay() {
       this.one(this.player_, 'timeupdate', this.seekToLiveEdge);
     ;
 
     _proto.reset_ = function reset_() {
+      this.lastTime_ = -1;
       this.pastSeekEnd_ = 0;
-      this.lastSeekEnd_ = null;
-      this.behindLiveEdge_ = null;
+      this.lastSeekEnd_ = -1;
+      this.behindLiveEdge_ = true;
       this.timeupdateSeen_ = false;
+      this.seekedBehindLive_ = false;
+      this.skipNextSeeked_ = false;
       this.clearInterval(this.trackingInterval_);
       this.trackingInterval_ = null;
-      this.seekableIncrement_ = 12;
-      this.seekableIncrementList_ = [];
-      this.off(this.player_, 'play', this.trackLive_);
-      this.off(this.player_, 'pause', this.trackLive_);
+      this.off(this.player_, ['play', 'pause'], this.trackLive_);
+      this.off(this.player_, 'seeked', this.handleSeeked);
       this.off(this.player_, 'play', this.handlePlay);
+      this.off(this.player_, 'timeupdate', this.handleFirstTimeupdate);
       this.off(this.player_, 'timeupdate', this.seekToLiveEdge);
-
-      if (this.handleTimeupdate) {
-        this.off(this.player_, 'timeupdate', this.handleTimeupdate);
-        this.handleTimeupdate = null;
-      }
     }
     /**
      * stop tracking live playback
       }
 
       this.reset_();
+      this.trigger('liveedgechange');
     }
     /**
      * A helper to get the player seekable end
      * so that we don't have to null check everywhere
+     *
+     * @return {number}
+     *         The furthest seekable end or Infinity.
      */
     ;
 
     /**
      * A helper to get the player seekable start
      * so that we don't have to null check everywhere
+     *
+     * @return {number}
+     *         The earliest seekable start or 0.
      */
     ;
 
       return seekableStarts.length ? seekableStarts.sort()[0] : 0;
     }
     /**
-     * Get the live time window
+     * Get the live time window aka
+     * the amount of time between seekable start and
+     * live current time.
+     *
+     * @return {number}
+     *         The amount of seconds that are seekable in
+     *         the live video.
      */
     ;
 
     /**
      * Determines if the player is live, only checks if this component
      * is tracking live playback or not
+     *
+     * @return {boolean}
+     *         Wether liveTracker is tracking
      */
     ;
 
     /**
      * Determines if currentTime is at the live edge and won't fall behind
      * on each seekableendchange
+     *
+     * @return {boolean}
+     *         Wether playback is at the live edge
      */
     ;
 
     }
     /**
      * get what we expect the live current time to be
+     *
+     * @return {number}
+     *         The expected live current time
      */
     ;
 
       return this.pastSeekEnd() + this.seekableEnd();
     }
     /**
-     * Returns how far past seek end we expect current time to be
+     * The number of seconds that have occured after seekable end
+     * changed. This will be reset to 0 once seekable end changes.
+     *
+     * @return {number}
+     *         Seconds past the current seekable end
      */
     ;
 
     _proto.pastSeekEnd = function pastSeekEnd() {
+      var seekableEnd = this.seekableEnd();
+
+      if (this.lastSeekEnd_ !== -1 && seekableEnd !== this.lastSeekEnd_) {
+        this.pastSeekEnd_ = 0;
+      }
+
+      this.lastSeekEnd_ = seekableEnd;
       return this.pastSeekEnd_;
     }
     /**
      * If we are currently behind the live edge, aka currentTime will be
      * behind on a seekableendchange
+     *
+     * @return {boolean}
+     *         If we are behind the live edge
      */
     ;
 
     _proto.behindLiveEdge = function behindLiveEdge() {
       return this.behindLiveEdge_;
-    };
+    }
+    /**
+     * Wether live tracker is currently tracking or not.
+     */
+    ;
 
     _proto.isTracking = function isTracking() {
       return typeof this.trackingInterval_ === 'number';
     ;
 
     _proto.seekToLiveEdge = function seekToLiveEdge() {
+      this.seekedBehindLive_ = false;
+
       if (this.atLiveEdge()) {
         return;
-      }
+      } // skipNextSeeked_
 
-      this.player_.currentTime(this.liveCurrentTime());
 
-      if (this.player_.paused()) {
-        this.player_.play();
-      }
-    };
+      this.skipNextSeeked_ = true;
+      this.player_.currentTime(this.liveCurrentTime());
+    }
+    /**
+     * Dispose of liveTracker
+     */
+    ;
 
     _proto.dispose = function dispose() {
+      this.off(document, 'visibilitychange', this.handleVisibilityChange);
       this.stopTracking();
 
       _Component.prototype.dispose.call(this);
 
       if (typeof this.options_.preload !== 'undefined') {
         setAttribute(el, 'preload', this.options_.preload);
+      }
+
+      if (this.options_.disablePictureInPicture !== undefined) {
+        el.disablePictureInPicture = this.options_.disablePictureInPicture;
       } // Update specific tag settings, in case they were overridden
       // `autoplay` has to be *last* so that `muted` and `playsinline` are present
       // when iOS/Safari or other browsers attempt to autoplay.
         }, this);
       });
     }
+    /**
+     * Set whether we are scrubbing or not.
+     * This is used to decide whether we should use `fastSeek` or not.
+     * `fastSeek` is used to provide trick play on Safari browsers.
+     *
+     * @param {boolean} isScrubbing
+     *                  - true for we are currently scrubbing
+     *                  - false for we are no longer scrubbing
+     */
+    ;
+
+    _proto.setScrubbing = function setScrubbing(isScrubbing) {
+      this.isScrubbing_ = isScrubbing;
+    }
     /**
      * Set current time for the `HTML5` tech.
      *
 
     _proto.setCurrentTime = function setCurrentTime(seconds) {
       try {
-        this.el_.currentTime = seconds;
+        if (this.isScrubbing_ && this.el_.fastSeek && IS_ANY_SAFARI) {
+          this.el_.fastSeek(seconds);
+        } else {
+          this.el_.currentTime = seconds;
+        }
       } catch (e) {
         log(e, 'Video is not ready. (Video.js)'); // this.warning(VideoJS.warnings.videoNotReady);
       }
         if ('webkitPresentationMode' in this.el_ && this.el_.webkitPresentationMode !== 'picture-in-picture') {
           this.one('webkitendfullscreen', endFn);
           this.trigger('fullscreenchange', {
-            isFullscreen: true
+            isFullscreen: true,
+            // set a flag in case another tech triggers fullscreenchange
+            nativeIOSFullscreen: true
           });
         }
       };
       if (video.paused && video.networkState <= video.HAVE_METADATA) {
         // attempt to prime the video element for programmatic access
         // this isn't necessary on the desktop but shouldn't hurt
-        this.el_.play(); // playing and pausing synchronously during the transition to fullscreen
+        silencePromise(this.el_.play()); // playing and pausing synchronously during the transition to fullscreen
         // can get iOS ~6.1 devices into a play/pause loop
 
         this.setTimeout(function () {
           video.pause();
-          video.webkitEnterFullScreen();
+
+          try {
+            video.webkitEnterFullScreen();
+          } catch (e) {
+            this.trigger('fullscreenerror', e);
+          }
         }, 0);
       } else {
-        video.webkitEnterFullScreen();
+        try {
+          video.webkitEnterFullScreen();
+        } catch (e) {
+          this.trigger('fullscreenerror', e);
+        }
       }
     }
     /**
     ;
 
     _proto.exitFullScreen = function exitFullScreen() {
+      if (!this.el_.webkitDisplayingFullscreen) {
+        this.trigger('fullscreenerror', new Error('The video is not fullscreen'));
+        return;
+      }
+
       this.el_.webkitExitFullScreen();
     }
     /**
   'muted',
   /**
    * Set the value of `defaultMuted` on the media element. `defaultMuted` indicates that the current
-   * audio level should be silent, but will only effect the muted level on intial playback..
+   * audio level should be silent, but will only effect the muted level on initial playback..
    *
    * @method Html5.prototype.setDefaultMuted
    * @param {boolean} defaultMuted
   }); // Wrap native properties with a getter
   // The list is as followed
   // paused, currentTime, buffered, volume, poster, preload, error, seeking
-  // seekable, ended, playbackRate, defaultPlaybackRate, played, networkState
-  // readyState, videoWidth, videoHeight
+  // seekable, ended, playbackRate, defaultPlaybackRate, disablePictureInPicture
+  // played, networkState, readyState, videoWidth, videoHeight, crossOrigin
 
   [
   /**
    * @see [Spec]{@link https://www.w3.org/TR/html5/embedded-content-0.html#dom-media-playbackrate}
    */
   'defaultPlaybackRate',
+  /**
+   * Get the value of 'disablePictureInPicture' from the video element.
+   *
+   * @method Html5#disablePictureInPicture
+   * @return {boolean} value
+   *         - The value of `disablePictureInPicture` from the video element.
+   *         - True indicates that the video can't be played in Picture-In-Picture mode
+   *         - False indicates that the video can be played in Picture-In-Picture mode
+   *
+   * @see [Spec]{@link https://w3c.github.io/picture-in-picture/#disable-pip}
+   */
+  'disablePictureInPicture',
   /**
    * Get the value of `played` from the media element. `played` returns a `TimeRange`
    * object representing points in the media timeline that have been played.
    *
    * @see [Spec] {@link https://www.w3.org/TR/html5/embedded-content-0.html#dom-video-videowidth}
    */
-  'videoHeight'].forEach(function (prop) {
+  'videoHeight',
+  /**
+   * Get the value of `crossOrigin` from the media element. `crossOrigin` indicates
+   * to the browser that should sent the cookies along with the requests for the
+   * different assets/playlists
+   *
+   * @method Html5#crossOrigin
+   * @return {string}
+   *         - anonymous indicates that the media should not sent cookies.
+   *         - use-credentials indicates that the media should sent cookies along the requests.
+   *
+   * @see [Spec]{@link https://html.spec.whatwg.org/#attr-media-crossorigin}
+   */
+  'crossOrigin'].forEach(function (prop) {
     Html5.prototype[prop] = function () {
       return this.el_[prop];
     };
   }); // Wrap native properties with a setter in this format:
   // set + toTitleCase(name)
   // The list is as follows:
-  // setVolume, setSrc, setPoster, setPreload, setPlaybackRate, setDefaultPlaybackRate
+  // setVolume, setSrc, setPoster, setPreload, setPlaybackRate, setDefaultPlaybackRate,
+  // setDisablePictureInPicture, setCrossOrigin
 
   [
   /**
    *
    * @see [Spec]{@link https://www.w3.org/TR/html5/embedded-content-0.html#dom-media-defaultplaybackrate}
    */
-  'defaultPlaybackRate'].forEach(function (prop) {
+  'defaultPlaybackRate',
+  /**
+   * Prevents the browser from suggesting a Picture-in-Picture context menu
+   * or to request Picture-in-Picture automatically in some cases.
+   *
+   * @method Html5#setDisablePictureInPicture
+   * @param {boolean} value
+   *         The true value will disable Picture-in-Picture mode.
+   *
+   * @see [Spec]{@link https://w3c.github.io/picture-in-picture/#disable-pip}
+   */
+  'disablePictureInPicture',
+  /**
+   * Set the value of `crossOrigin` from the media element. `crossOrigin` indicates
+   * to the browser that should sent the cookies along with the requests for the
+   * different assets/playlists
+   *
+   * @method Html5#setCrossOrigin
+   * @param {string} crossOrigin
+   *         - anonymous indicates that the media should&