Merge branch 'MDL-55299-master' of git://github.com/ferranrecio/moodle into master
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 4 Aug 2020 10:33:51 +0000 (12:33 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 4 Aug 2020 10:33:51 +0000 (12:33 +0200)
156 files changed:
.gitignore
admin/cli/install.php
admin/cli/uninstall_plugins.php
admin/settings/development.php
admin/tool/dataprivacy/classes/data_request.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/createdatarequest_form.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/uploadcourse/classes/course.php
admin/tool/uploadcourse/classes/helper.php
admin/tool/uploadcourse/lang/en/tool_uploadcourse.php
admin/tool/uploadcourse/tests/course_test.php
admin/tool/usertours/amd/build/tour.min.js
admin/tool/usertours/amd/build/tour.min.js.map
admin/tool/usertours/amd/src/tour.js
admin/tool/xmldb/actions/check_defaults/check_defaults.class.php
analytics/classes/manager.php
auth/email/tests/behat/behat_auth_email.php
auth/tests/behat/behat_auth.php
backup/util/ui/tests/behat/behat_backup.php
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/build/view.min.js.map
blocks/myoverview/amd/src/view.js
blocks/navigation/amd/build/ajax_response_renderer.min.js
blocks/navigation/amd/build/ajax_response_renderer.min.js.map
blocks/navigation/amd/src/ajax_response_renderer.js
blocks/recentlyaccessedcourses/amd/build/main.min.js
blocks/recentlyaccessedcourses/amd/build/main.min.js.map
blocks/recentlyaccessedcourses/amd/src/main.js
calendar/tests/behat/behat_calendar.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/src/activitychooser.js
course/classes/local/service/content_item_service.php
course/format/topics/backup/moodle2/restore_format_topics_plugin.class.php
course/format/topics/classes/privacy/provider.php
course/format/topics/db/upgrade.php
course/format/topics/format.js
course/format/topics/format.php
course/format/topics/lang/en/format_topics.php
course/format/topics/lib.php
course/format/topics/renderer.php
course/format/topics/tests/format_topics_test.php
course/format/topics/version.php
course/templates/local/activitychooser/error.mustache [new file with mode: 0644]
customfield/field/textarea/classes/data_controller.php
customfield/field/textarea/tests/plugin_test.php
grade/import/csv/classes/load_data.php
install/lang/bar/admin.php [new file with mode: 0644]
install/lang/bar/langconfig.php [new file with mode: 0644]
install/lang/hi/moodle.php
install/lang/hi_kids/langconfig.php [new file with mode: 0644]
lang/en/admin.php
lang/en/grades.php
lib/adminlib.php
lib/amd/build/aria.min.js [new file with mode: 0644]
lib/amd/build/aria.min.js.map [new file with mode: 0644]
lib/amd/build/drawer.min.js
lib/amd/build/drawer.min.js.map
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/build/local/aria/aria-hidden.min.js [new file with mode: 0644]
lib/amd/build/local/aria/aria-hidden.min.js.map [new file with mode: 0644]
lib/amd/build/local/aria/focuslock.min.js
lib/amd/build/local/aria/focuslock.min.js.map
lib/amd/build/local/aria/selectors.min.js [new file with mode: 0644]
lib/amd/build/local/aria/selectors.min.js.map [new file with mode: 0644]
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/normalise.min.js [new file with mode: 0644]
lib/amd/build/normalise.min.js.map [new file with mode: 0644]
lib/amd/build/tooltip.min.js
lib/amd/build/tooltip.min.js.map
lib/amd/build/tree.min.js
lib/amd/build/tree.min.js.map
lib/amd/src/aria.js [new file with mode: 0644]
lib/amd/src/drawer.js
lib/amd/src/form-autocomplete.js
lib/amd/src/local/aria/aria-hidden.js [new file with mode: 0644]
lib/amd/src/local/aria/focuslock.js
lib/amd/src/local/aria/selectors.js [new file with mode: 0644]
lib/amd/src/modal.js
lib/amd/src/normalise.js [new file with mode: 0644]
lib/amd/src/tooltip.js
lib/amd/src/tree.js
lib/behat/behat_base.php
lib/behat/form_field/behat_form_editor.php
lib/behat/form_field/behat_form_passwordunmask.php
lib/classes/event/grade_item_deleted.php [new file with mode: 0644]
lib/coursecatlib.php [deleted file]
lib/db/upgrade.php
lib/dml/moodle_database.php
lib/dml/tests/dml_test.php
lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php [new file with mode: 0644]
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js
lib/editor/atto/plugins/image/yui/src/button/js/button.js
lib/grade/grade_item.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/tablelib.php
lib/templates/paging_bar.mustache
lib/tests/behat/behat_app.php
lib/tests/behat/behat_forms.php
lib/tests/behat/behat_general.php
lib/tests/behat/behat_hooks.php
lib/tests/behat/behat_navigation.php
lib/tests/event/grade_item_deleted_test.php [new file with mode: 0644]
lib/upgrade.txt
login/index.php
message/amd/build/message_drawer_router.min.js
message/amd/build/message_drawer_router.min.js.map
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js.map
message/amd/src/message_drawer_router.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/classes/api.php
mod/assign/amd/build/grading_navigation.min.js
mod/assign/amd/build/grading_navigation.min.js.map
mod/assign/amd/src/grading_navigation.js
mod/assign/feedback/editpdf/tests/behat/behat_assignfeedback_editpdf.php
mod/assign/gradingoptionsform.php
mod/assign/tests/behat/grading_app_filters.feature [new file with mode: 0644]
mod/choice/backup/moodle2/backup_choice_stepslib.php
mod/choice/classes/external.php
mod/choice/db/install.xml
mod/choice/db/upgrade.php
mod/choice/lang/en/choice.php
mod/choice/lib.php
mod/choice/mod_form.php
mod/choice/renderer.php
mod/choice/tests/behat/limit_responses.feature
mod/choice/tests/behat/multiple_options.feature
mod/choice/version.php
mod/forum/classes/subscriptions.php
mod/forum/discuss.php
mod/forum/tests/behat/behat_mod_forum.php
mod/label/lib.php
mod/lti/tests/externallib_test.php
mod/quiz/accessrule/seb/db/install.xml
mod/quiz/lib.php
mod/workshop/allocation/manual/tests/behat/behat_workshopallocation_manual.php
question/type/ddmarker/tests/behat/behat_qtype_ddmarker.php
search/classes/engine.php
search/engine/solr/classes/engine.php
search/tests/behat/behat_search.php
theme/boost/amd/build/drawer.min.js
theme/boost/amd/build/drawer.min.js.map
theme/boost/amd/src/drawer.js
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/tests/behat/table_column_visibility.feature [new file with mode: 0644]
version.php

index b96359f..9d02c9b 100644 (file)
@@ -44,3 +44,4 @@ composer.phar
 /lib/yuilib/*/*/*-coverage.js
 atlassian-ide-plugin.xml
 /node_modules/
+/.vscode/
index 5c68d52..2c2ff88 100644 (file)
@@ -267,6 +267,7 @@ list($options, $unrecognized) = cli_get_params(
 );
 
 $interactive = empty($options['non-interactive']);
+$skipdatabase = $options['skip-database'];
 
 // set up language
 $lang = clean_param($options['lang'], PARAM_SAFEDIR);
@@ -638,96 +639,100 @@ do {
     }
 } while ($hintdatabase !== '');
 
-// ask for fullname
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('fullsitename', 'moodle'));
+// If --skip-database option is provided, we do not need to ask for site fullname, shortname, adminuser, adminpass, adminemail.
+// These fields will be requested during the database install part.
+if (!$skipdatabase) {
+    // Ask for fullname.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('fullsitename', 'moodle'));
 
-    if ($options['fullname'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['fullname']);
+        if ($options['fullname'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['fullname']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+
+        do {
+            $options['fullname'] = cli_input($prompt, $options['fullname']);
+        } while (empty($options['fullname']));
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        if (empty($options['fullname'])) {
+            $a = (object)['option' => 'fullname', 'value' => $options['fullname']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
 
-    do {
-        $options['fullname'] = cli_input($prompt, $options['fullname']);
-    } while (empty($options['fullname']));
-} else {
-    if (empty($options['fullname'])) {
-        $a = (object)array('option'=>'fullname', 'value'=>$options['fullname']);
-        cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
-    }
-}
+    // Ask for shortname.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('shortsitename', 'moodle'));
 
-// ask for shortname
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('shortsitename', 'moodle'));
+        if ($options['shortname'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['shortname']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
 
-    if ($options['shortname'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['shortname']);
+        do {
+            $options['shortname'] = cli_input($prompt, $options['shortname']);
+        } while (empty($options['shortname']));
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        if (empty($options['shortname'])) {
+            $a = (object)['option' => 'shortname', 'value' => $options['shortname']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
 
-    do {
-        $options['shortname'] = cli_input($prompt, $options['shortname']);
-    } while (empty($options['shortname']));
-} else {
-    if (empty($options['shortname'])) {
-        $a = (object)array('option'=>'shortname', 'value'=>$options['shortname']);
-        cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+    // Ask for admin user name.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('cliadminusername', 'install'));
+        if (!empty($options['adminuser'])) {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['adminuser']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        do {
+            $options['adminuser'] = cli_input($prompt, $options['adminuser']);
+        } while (empty($options['adminuser']) or $options['adminuser'] === 'guest');
+    } else {
+        if ((empty($options['adminuser']) || $options['adminuser'] === 'guest')) {
+            $a = (object)['option' => 'adminuser', 'value' => $options['adminuser']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
-}
 
-// ask for admin user name
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('cliadminusername', 'install'));
-    if (!empty($options['adminuser'])) {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['adminuser']);
-    } else {
+    // Ask for admin user password.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('cliadminpassword', 'install'));
         $prompt = get_string('clitypevalue', 'admin');
+        do {
+            $options['adminpass'] = cli_input($prompt);
+        } while (empty($options['adminpass']) or $options['adminpass'] === 'admin');
+    } else {
+        if ((empty($options['adminpass']) or $options['adminpass'] === 'admin')) {
+            $a = (object)['option' => 'adminpass', 'value' => $options['adminpass']];
+            cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+        }
     }
-    do {
-        $options['adminuser'] = cli_input($prompt, $options['adminuser']);
-    } while (empty($options['adminuser']) or $options['adminuser'] === 'guest');
-} else {
-    if (empty($options['adminuser']) or $options['adminuser'] === 'guest') {
-        $a = (object)array('option'=>'adminuser', 'value'=>$options['adminuser']);
-        cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
+
+    // Ask for the admin email address.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('cliadminemail', 'install'));
+        $prompt = get_string('clitypevaluedefault', 'admin', $options['adminemail']);
+        $options['adminemail'] = cli_input($prompt, $options['adminemail']);
     }
-}
 
-// ask for admin user password
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('cliadminpassword', 'install'));
-    $prompt = get_string('clitypevalue', 'admin');
-    do {
-        $options['adminpass'] = cli_input($prompt);
-    } while (empty($options['adminpass']) or $options['adminpass'] === 'admin');
-} else {
-    if (empty($options['adminpass']) or $options['adminpass'] === 'admin') {
-        $a = (object)array('option'=>'adminpass', 'value'=>$options['adminpass']);
+    // Validate that the address provided was an e-mail address.
+    if (!empty($options['adminemail']) && !validate_email($options['adminemail'])) {
+        $a = (object)['option' => 'adminemail', 'value' => $options['adminemail']];
         cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
     }
 }
 
-// Ask for the admin email address.
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('cliadminemail', 'install'));
-    $prompt = get_string('clitypevaluedefault', 'admin', $options['adminemail']);
-    $options['adminemail'] = cli_input($prompt, $options['adminemail']);
-}
-
-// Validate that the address provided was an e-mail address.
-if (!empty($options['adminemail']) && !validate_email($options['adminemail'])) {
-    $a = (object) array('option' => 'adminemail', 'value' => $options['adminemail']);
-    cli_error(get_string('cliincorrectvalueerror', 'admin', $a));
-}
-
 // Ask for the upgrade key.
 if ($interactive) {
     cli_separator();
@@ -746,22 +751,26 @@ if ($options['upgradekey'] !== '') {
     $CFG->upgradekey = $options['upgradekey'];
 }
 
-if ($interactive) {
-    if (!$options['agree-license']) {
-        cli_separator();
-        cli_heading(get_string('copyrightnotice'));
-        echo "Moodle  - Modular Object-Oriented Dynamic Learning Environment\n";
-        echo get_string('gpl3')."\n\n";
-        echo get_string('doyouagree')."\n";
-        $prompt = get_string('cliyesnoprompt', 'admin');
-        $input = cli_input($prompt, '', array(get_string('clianswerno', 'admin'), get_string('cliansweryes', 'admin')));
-        if ($input == get_string('clianswerno', 'admin')) {
-            exit(1);
+// The user does not also need to pass agree-license when --skip-database is provided as the user will need to accept
+// the license again in the database install part.
+if (!$skipdatabase) {
+    if ($interactive) {
+        if (!$options['agree-license']) {
+            cli_separator();
+            cli_heading(get_string('copyrightnotice'));
+            echo "Moodle  - Modular Object-Oriented Dynamic Learning Environment\n";
+            echo get_string('gpl3')."\n\n";
+            echo get_string('doyouagree')."\n";
+            $prompt = get_string('cliyesnoprompt', 'admin');
+            $input = cli_input($prompt, '', array(get_string('clianswerno', 'admin'), get_string('cliansweryes', 'admin')));
+            if ($input == get_string('clianswerno', 'admin')) {
+                exit(1);
+            }
+        }
+    } else {
+        if (!$options['agree-license'] && !$skipdatabase) {
+            cli_error(get_string('climustagreelicense', 'install'));
         }
-    }
-} else {
-    if (!$options['agree-license']) {
-        cli_error(get_string('climustagreelicense', 'install'));
     }
 }
 
@@ -810,7 +819,7 @@ if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
     cli_error(get_string('pluginschecktodo', 'admin'));
 }
 
-if (!$options['skip-database']) {
+if (!$skipdatabase) {
     install_cli_database($options, $interactive);
     // This needs to happen at the end to ensure it occurs after all caches
     // have been purged for the last time.
index e093d89..3e16237 100644 (file)
@@ -34,6 +34,7 @@ $help = "Command line tool to uninstall plugins.
 Options:
     -h --help                   Print this help.
     --show-all                  Displays a list of all installed plugins.
+    --show-contrib              Displays a list of all third-party installed plugins.
     --show-missing              Displays a list of plugins missing from disk.
     --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
@@ -44,6 +45,9 @@ Examples:
     # php uninstall_plugins.php  --show-all
         Prints tab-separated list of all installed plugins.
 
+    # php uninstall_plugins.php  --show-contrib
+        Prints tab-separated list of all third-party installed plugins.
+
     # php uninstall_plugins.php  --show-missing
         Prints tab-separated list of all missing from disk plugins.
 
@@ -63,6 +67,7 @@ Examples:
 list($options, $unrecognised) = cli_get_params([
     'help' => false,
     'show-all' => false,
+    'show-contrib' => false,
     'show-missing' => false,
     'purge-missing' => false,
     'plugins' => false,
@@ -84,12 +89,15 @@ if ($options['help']) {
 $pluginman = core_plugin_manager::instance();
 $plugininfo = $pluginman->get_plugins();
 
-if ($options['show-all'] || $options['show-missing']) {
+if ($options['show-all'] || $options['show-missing'] || $options['show-contrib']) {
     foreach ($plugininfo as $type => $plugins) {
         foreach ($plugins as $name => $plugin) {
+            if ($options['show-contrib'] && $plugin->is_standard()) {
+                continue;
+            }
             $pluginstring = $plugin->component . "\t" . $plugin->displayname;
 
-            if ($options['show-all']) {
+            if ($options['show-all'] || $options['show-contrib']) {
                 cli_writeln($pluginstring);
             } else {
                 if ($plugin->get_status() === core_plugin_manager::PLUGIN_STATUS_MISSING) {
index 46206fc..3844fe9 100644 (file)
@@ -41,6 +41,13 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configcheckbox('debugdisplay', new lang_string('debugdisplay', 'admin'), new lang_string('configdebugdisplay', 'admin'), ini_get_bool('display_errors')));
     $temp->add(new admin_setting_configcheckbox('perfdebug', new lang_string('perfdebug', 'admin'), new lang_string('configperfdebug', 'admin'), '7', '15', '7'));
     $temp->add(new admin_setting_configcheckbox('debugstringids', new lang_string('debugstringids', 'admin'), new lang_string('debugstringids_desc', 'admin'), 0));
+    $temp->add(new admin_setting_configselect('debugsqltrace',
+            new lang_string('debugsqltrace', 'admin'),
+            new lang_string('debugsqltrace_desc', 'admin'), 0, array(
+               0 => new lang_string('disabled', 'admin'),
+               1 => new lang_string('debugsqltrace1', 'admin'),
+               2 => new lang_string('debugsqltrace2', 'admin'),
+             100 => new lang_string('debugsqltrace100', 'admin'))));
     $temp->add(new admin_setting_configcheckbox('debugvalidators', new lang_string('debugvalidators', 'admin'), new lang_string('configdebugvalidators', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('debugpageinfo', new lang_string('debugpageinfo', 'admin'), new lang_string('configdebugpageinfo', 'admin'), 0));
     $ADMIN->add('development', $temp);
index 6a94fad..02edc7c 100644 (file)
@@ -26,10 +26,11 @@ namespace tool_dataprivacy;
 
 defined('MOODLE_INTERNAL') || die();
 
+use lang_string;
 use core\persistent;
 
 /**
- * Class for loading/storing competencies from the DB.
+ * Class for loading/storing data requests from the DB.
  *
  * @copyright  2018 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -62,6 +63,7 @@ class data_request extends persistent {
             ],
             'comments' => [
                 'type' => PARAM_TEXT,
+                'message' => new lang_string('errorinvalidrequestcomments', 'tool_dataprivacy'),
                 'default' => ''
             ],
             'commentsformat' => [
@@ -75,7 +77,10 @@ class data_request extends persistent {
                 'default' => FORMAT_PLAIN
             ],
             'userid' => [
-                'default' => 0,
+                'default' => function() {
+                    global $USER;
+                    return $USER->id;
+                },
                 'type' => PARAM_INT
             ],
             'requestedby' => [
index c29f197..a81df11 100644 (file)
@@ -67,8 +67,8 @@ if (!$manage && !\tool_dataprivacy\api::can_contact_dpo()) {
     redirect($returnurl, get_string('contactdpoviaprivacypolicy', 'tool_dataprivacy'), 0, \core\output\notification::NOTIFY_ERROR);
 }
 
-$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage)]);
-$mform->set_data(['type' => $requesttype]);
+$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage),
+    'persistent' => new \tool_dataprivacy\data_request(0, (object) ['type' => $requesttype])]);
 
 // Data request cancelled.
 if ($mform->is_cancelled()) {
index c91213c..c708a88 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 use tool_dataprivacy\api;
+use tool_dataprivacy\data_request;
 use tool_dataprivacy\local\helper;
 
 defined('MOODLE_INTERNAL') || die();
@@ -36,7 +37,10 @@ require_once($CFG->libdir.'/formslib.php');
  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
  * @package tool_dataprivacy
  */
-class tool_dataprivacy_data_request_form extends moodleform {
+class tool_dataprivacy_data_request_form extends \core\form\persistent {
+
+    /** @var string Name of the persistent class. */
+    protected static $persistentclass = data_request::class;
 
     /** @var bool Flag to indicate whether this form is being rendered for managing data requests or for regular requests. */
     protected $manage = false;
@@ -96,14 +100,13 @@ class tool_dataprivacy_data_request_form extends moodleform {
             api::DATAREQUEST_TYPE_EXPORT => get_string('requesttypeexport', 'tool_dataprivacy'),
             api::DATAREQUEST_TYPE_DELETE => get_string('requesttypedelete', 'tool_dataprivacy')
         ];
+
         $mform->addElement('select', 'type', get_string('requesttype', 'tool_dataprivacy'), $options);
-        $mform->setType('type', PARAM_INT);
         $mform->addHelpButton('type', 'requesttype', 'tool_dataprivacy');
 
         // Request comments text area.
         $textareaoptions = ['cols' => 60, 'rows' => 10];
         $mform->addElement('textarea', 'comments', get_string('requestcomments', 'tool_dataprivacy'), $textareaoptions);
-        $mform->setType('type', PARAM_ALPHANUM);
         $mform->addHelpButton('comments', 'requestcomments', 'tool_dataprivacy');
 
         // Action buttons.
@@ -129,34 +132,49 @@ class tool_dataprivacy_data_request_form extends moodleform {
         }
     }
 
+    /**
+     * Get the default data. Unset the default userid if managing data requests
+     *
+     * @return stdClass
+     */
+    protected function get_default_data() {
+        $data = parent::get_default_data();
+        if ($this->manage) {
+            unset($data->userid);
+        }
+
+        return $data;
+    }
+
     /**
      * Form validation.
      *
-     * @param array $data
+     * @param stdClass $data
      * @param array $files
+     * @param array $errors
      * @return array
      * @throws coding_exception
      * @throws dml_exception
      */
-    public function validation($data, $files) {
+    public function extra_validation($data, $files, array &$errors) {
         global $USER;
-        $errors = [];
 
         $validrequesttypes = [
             api::DATAREQUEST_TYPE_EXPORT,
             api::DATAREQUEST_TYPE_DELETE
         ];
-        if (!in_array($data['type'], $validrequesttypes)) {
+        if (!in_array($data->type, $validrequesttypes)) {
             $errors['type'] = get_string('errorinvalidrequesttype', 'tool_dataprivacy');
         }
 
-        if (api::has_ongoing_request($data['userid'], $data['type'])) {
+        $userid = $data->userid;
+
+        if (api::has_ongoing_request($userid, $data->type)) {
             $errors['type'] = get_string('errorrequestalreadyexists', 'tool_dataprivacy');
         }
 
         // Check if current user can create data deletion request.
-        $userid = $data['userid'];
-        if ($data['type'] == api::DATAREQUEST_TYPE_DELETE) {
+        if ($data->type == api::DATAREQUEST_TYPE_DELETE) {
             if ($userid == $USER->id) {
                 if (!api::can_create_data_deletion_request_for_self()) {
                     $errors['type'] = get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy');
index b5f3e60..63d33fe 100644 (file)
@@ -135,6 +135,7 @@ $string['effectiveretentionperioduser'] = '{$a} (since the last time the user ac
 $string['emailsalutation'] = 'Dear {$a},';
 $string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
 $string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
+$string['errorinvalidrequestcomments'] = 'Please ensure your comment contains plain text only.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
index 88cd888..fd7569e 100644 (file)
@@ -770,7 +770,20 @@ class tool_uploadcourse_course {
 
         // Saving data.
         $this->data = $coursedata;
+
+        // Get enrolment data. Where the course already exists, we can also perform validation.
         $this->enrolmentdata = tool_uploadcourse_helper::get_enrolment_data($this->rawdata);
+        if ($exists) {
+            $errors = $this->validate_enrolment_data($coursedata['id'], $this->enrolmentdata);
+
+            if (!empty($errors)) {
+                foreach ($errors as $key => $message) {
+                    $this->error($key, $message);
+                }
+
+                return false;
+            }
+        }
 
         if (isset($this->rawdata['tags']) && strval($this->rawdata['tags']) !== '') {
             $this->data['tags'] = preg_split('/\s*,\s*/', trim($this->rawdata['tags']), -1, PREG_SPLIT_NO_EMPTY);
@@ -871,6 +884,71 @@ class tool_uploadcourse_course {
         $context->mark_dirty();
     }
 
+    /**
+     * Validate passed enrolment data against an existing course
+     *
+     * @param int $courseid
+     * @param array[] $enrolmentdata
+     * @return lang_string[] Errors keyed on error code
+     */
+    protected function validate_enrolment_data(int $courseid, array $enrolmentdata): array {
+        // Nothing to validate.
+        if (empty($enrolmentdata)) {
+            return [];
+        }
+
+        $errors = [];
+
+        $enrolmentplugins = tool_uploadcourse_helper::get_enrolment_plugins();
+        $instances = enrol_get_instances($courseid, false);
+
+        foreach ($enrolmentdata as $method => $options) {
+            $plugin = $enrolmentplugins[$method];
+
+            // Find matching instances by enrolment method.
+            $methodinstances = array_filter($instances, static function(stdClass $instance) use ($method) {
+                return (strcmp($instance->enrol, $method) == 0);
+            });
+
+            if (!empty($options['delete'])) {
+                // Ensure user is able to delete the instances.
+                foreach ($methodinstances as $methodinstance) {
+                    if (!$plugin->can_delete_instance($methodinstance)) {
+                        $errors['errorcannotdeleteenrolment'] = new lang_string('errorcannotdeleteenrolment', 'tool_uploadcourse',
+                            $plugin->get_instance_name($methodinstance));
+
+                        break;
+                    }
+                }
+            } else if (!empty($options['disable'])) {
+                // Ensure user is able to toggle instance statuses.
+                foreach ($methodinstances as $methodinstance) {
+                    if (!$plugin->can_hide_show_instance($methodinstance)) {
+                        $errors['errorcannotdisableenrolment'] =
+                            new lang_string('errorcannotdisableenrolment', 'tool_uploadcourse',
+                                $plugin->get_instance_name($methodinstance));
+
+                        break;
+                    }
+                }
+            } else {
+                // Ensure user is able to create/update instance.
+                $methodinstance = empty($methodinstances) ? null : reset($methodinstances);
+                if ((empty($methodinstance) && !$plugin->can_add_instance($courseid)) ||
+                        (!empty($methodinstance) && !$plugin->can_edit_instance($methodinstance))) {
+
+                    $errors['errorcannotcreateorupdateenrolment'] =
+                        new lang_string('errorcannotcreateorupdateenrolment', 'tool_uploadcourse',
+                            $plugin->get_instance_name($methodinstance));
+
+                    break;
+                }
+            }
+        }
+
+        return $errors;
+    }
+
     /**
      * Add the enrolment data for the course.
      *
@@ -907,7 +985,16 @@ class tool_uploadcourse_course {
                 foreach ($instances as $instance) {
                     if ($instance->enrol == $enrolmethod) {
                         $plugin = $enrolmentplugins[$instance->enrol];
-                        $plugin->delete_instance($instance);
+
+                        // Ensure user is able to delete the instance.
+                        if ($plugin->can_delete_instance($instance)) {
+                            $plugin->delete_instance($instance);
+                        } else {
+                            $this->error('errorcannotdeleteenrolment',
+                                new lang_string('errorcannotdeleteenrolment', 'tool_uploadcourse',
+                                    $plugin->get_instance_name($instance)));
+                        }
+
                         break;
                     }
                 }
@@ -916,22 +1003,37 @@ class tool_uploadcourse_course {
                 foreach ($instances as $instance) {
                     if ($instance->enrol == $enrolmethod) {
                         $plugin = $enrolmentplugins[$instance->enrol];
-                        $plugin->update_status($instance, ENROL_INSTANCE_DISABLED);
-                        $enrol_updated = true;
+
+                        // Ensure user is able to toggle instance status.
+                        if ($plugin->can_hide_show_instance($instance)) {
+                            $plugin->update_status($instance, ENROL_INSTANCE_DISABLED);
+                        } else {
+                            $this->error('errorcannotdisableenrolment',
+                                new lang_string('errorcannotdisableenrolment', 'tool_uploadcourse',
+                                    $plugin->get_instance_name($instance)));
+                        }
+
                         break;
                     }
                 }
             } else {
-                $plugin = null;
-                if (empty($instance)) {
-                    $plugin = $enrolmentplugins[$enrolmethod];
+                // Create/update enrolment.
+                $plugin = $enrolmentplugins[$enrolmethod];
+
+                // Ensure user is able to create/update instance.
+                if (empty($instance) && $plugin->can_add_instance($course->id)) {
                     $instance = new stdClass();
                     $instance->id = $plugin->add_default_instance($course);
                     $instance->roleid = $plugin->get_config('roleid');
                     $instance->status = ENROL_INSTANCE_ENABLED;
-                } else {
-                    $plugin = $enrolmentplugins[$instance->enrol];
+                } else if (!empty($instance) && $plugin->can_edit_instance($instance)) {
                     $plugin->update_status($instance, ENROL_INSTANCE_ENABLED);
+                } else {
+                    $this->error('errorcannotcreateorupdateenrolment',
+                        new lang_string('errorcannotcreateorupdateenrolment', 'tool_uploadcourse',
+                            $plugin->get_instance_name($instance)));
+
+                    break;
                 }
 
                 // Now update values.
index 1011c37..a91e790 100644 (file)
@@ -175,7 +175,7 @@ class tool_uploadcourse_helper {
      *
      * The result is cached for faster execution.
      *
-     * @return array
+     * @return enrol_plugin[]
      */
     public static function get_enrolment_plugins() {
         $cache = cache::make('tool_uploadcourse', 'helper');
index b44b4f3..9a96d10 100644 (file)
@@ -78,6 +78,9 @@ $string['defaultvalues'] = 'Default course values';
 $string['defaultvaluescustomfieldcategory'] = 'Default values for \'{$a}\'';
 $string['encoding'] = 'Encoding';
 $string['encoding_help'] = 'Encoding of the CSV file.';
+$string['errorcannotcreateorupdateenrolment'] = 'Cannot create or update enrolment method \'{$a}\'';
+$string['errorcannotdeleteenrolment'] = 'Cannot delete enrolment method \'{$a}\'';
+$string['errorcannotdisableenrolment'] = 'Cannot disable enrolment method \'{$a}\'';
 $string['errorwhilerestoringcourse'] = 'Error while restoring the course';
 $string['errorwhiledeletingcourse'] = 'Error while deleting the course';
 $string['generatedshortnameinvalid'] = 'The generated shortname is invalid';
index 7d811cb..9bd1f4e 100644 (file)
@@ -1055,6 +1055,9 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
     public function test_enrolment_data() {
         $this->resetAfterTest(true);
 
+        // We need to set the current user as one with the capability to edit manual enrolment instances in the new course.
+        $this->setAdminUser();
+
         $mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
         $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
         $data = array('shortname' => 'c1', 'summary' => 'S', 'fullname' => 'FN', 'category' => '1');
@@ -1081,6 +1084,123 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
         $this->assertEquals(strtotime('12th July 2013'), $enroldata['manual']->enrolenddate);
     }
 
+    /**
+     * Data provider for testing enrolment errors
+     *
+     * @return array
+     */
+    public function enrolment_uploaddata_error_provider(): array {
+        return [
+            ['errorcannotcreateorupdateenrolment', [
+                'shortname' => 'C1',
+                'enrolment_1' => 'manual',
+            ]],
+            ['errorcannotdeleteenrolment', [
+                'shortname' => 'C1',
+                'enrolment_1' => 'manual',
+                'enrolment_1_delete' => '1',
+            ]],
+            ['errorcannotdisableenrolment', [
+                'shortname' => 'C1',
+                'enrolment_1' => 'manual',
+                'enrolment_1_disable' => '1',
+            ]],
+        ];
+    }
+
+    /**
+     * Test that user without permission, cannot modify enrolment instances when creating courses
+     *
+     * @param string $expectederror
+     * @param array $uploaddata
+     *
+     * @dataProvider enrolment_uploaddata_error_provider
+     */
+    public function test_enrolment_error_create_course(string $expectederror, array $uploaddata): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create category in which to create the new course.
+        $category = $this->getDataGenerator()->create_category();
+        $categorycontext = context_coursecat::instance($category->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // Assign the user as a manager of the category, disable ability to configure manual enrolment instances.
+        $roleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+        role_assign($roleid, $user->id, $categorycontext);
+        role_change_permission($roleid, $categorycontext, 'enrol/manual:config', CAP_PROHIBIT);
+
+        $mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
+        $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
+
+        $upload = new tool_uploadcourse_course($mode, $updatemode, array_merge($uploaddata, [
+            'category' => $category->id,
+            'fullname' => 'My course',
+        ]));
+
+        // Enrolment validation isn't performed during 'prepare' for new courses.
+        $this->assertTrue($upload->prepare());
+        $upload->proceed();
+
+        $errors = $upload->get_errors();
+        $this->assertArrayHasKey($expectederror, $errors);
+
+        $this->assertEquals(get_string($expectederror, 'tool_uploadcourse', 'Manual enrolments'),
+            (string) $errors[$expectederror]);
+    }
+
+    /**
+     * Test that user without permission, cannot modify enrolment instances when updating courses
+     *
+     * @param string $expectederror
+     * @param array $uploaddata
+     *
+     * @dataProvider enrolment_uploaddata_error_provider
+     */
+    public function test_enrolment_error_update_course(string $expectederror, array $uploaddata): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create category in which to create the new course.
+        $category = $this->getDataGenerator()->create_category();
+        $categorycontext = context_coursecat::instance($category->id);
+
+        $course = $this->getDataGenerator()->create_course([
+            'category' => $category->id,
+            'shortname' => $uploaddata['shortname'],
+        ]);
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // Assign the user as a manager of the category, disable ability to configure manual enrolment instances.
+        $roleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+        role_assign($roleid, $user->id, $categorycontext);
+        role_change_permission($roleid, $categorycontext, 'enrol/manual:config', CAP_PROHIBIT);
+
+        // Sanity check.
+        $instances = enrol_get_instances($course->id, true);
+        $this->assertCount(1, $instances);
+        $this->assertEquals('manual', reset($instances)->enrol);
+
+        $mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
+        $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
+
+        $upload = new tool_uploadcourse_course($mode, $updatemode, $uploaddata);
+
+        $this->assertFalse($upload->prepare());
+
+        $errors = $upload->get_errors();
+        $this->assertArrayHasKey($expectederror, $errors);
+
+        $this->assertEquals(get_string($expectederror, 'tool_uploadcourse', 'Manual enrolments'),
+            (string) $errors[$expectederror]);
+    }
+
     /**
      * Test upload processing of course custom fields
      */
index 5be936a..e1f5986 100644 (file)
Binary files a/admin/tool/usertours/amd/build/tour.min.js and b/admin/tool/usertours/amd/build/tour.min.js differ
index bc48f5c..382273a 100644 (file)
Binary files a/admin/tool/usertours/amd/build/tour.min.js.map and b/admin/tool/usertours/amd/build/tour.min.js.map differ
index 4a2fd90..4e6936a 100644 (file)
@@ -21,6 +21,7 @@
  */
 
 import $ from 'jquery';
+import * as Aria from 'core/aria';
 import Popper from 'core/popper';
 
 /**
@@ -594,7 +595,7 @@ export default class Tour {
             });
         }
 
-        this.listeners.forEach(function (listener) {
+        this.listeners.forEach(function(listener) {
             listener.node.on.apply(listener.node, listener.args);
         });
 
@@ -1498,7 +1499,7 @@ export default class Tour {
             let hidden = child.attr(attrName);
             if (!hidden) {
                 child.attr(stateHolder, true);
-                child.attr(attrName, true);
+                Aria.hide(child);
             }
         };
 
@@ -1519,12 +1520,11 @@ export default class Tour {
      */
     accessibilityHide() {
         let stateHolder = 'data-has-hidden';
-        let attrName = 'aria-hidden';
         let showFunction = function(child) {
             let hidden = child.attr(stateHolder);
             if (typeof hidden !== 'undefined') {
                 child.removeAttr(stateHolder);
-                child.removeAttr(attrName);
+                Aria.unhide(child);
             }
         };
 
index c37ab36..ea21a12 100644 (file)
@@ -93,6 +93,17 @@ class check_defaults extends XMLDBCheckAction {
                     $physicaldefault = null;
                 }
 
+                // For number fields there are issues with type differences, so let's convert
+                // everything to a float.
+                if ($xmldbfield->getType() === XMLDB_TYPE_NUMBER) {
+                    if ($physicaldefault !== null) {
+                        $physicaldefault = (float) $physicaldefault;
+                    }
+                    if ($xmldbdefault !== null) {
+                        $xmldbdefault = (float) $xmldbdefault;
+                    }
+                }
+
                 // There *is* a default and it's wrong.
                 if ($physicaldefault !== $xmldbdefault) {
                     $xmldbtext = self::display_default($xmldbdefault);
index 131c782..faea917 100644 (file)
@@ -624,9 +624,25 @@ class manager {
                         LEFT JOIN {context} ctx ON ap.contextid = ctx.id
                             WHERE ctx.id IS NULL)");
 
-        $contextsql = "SELECT id FROM {context} ctx";
-        $DB->delete_records_select('analytics_predictions', "contextid NOT IN ($contextsql)");
-        $DB->delete_records_select('analytics_indicator_calc', "contextid NOT IN ($contextsql)");
+        // Cleanup analaytics predictions/calcs with MySQL friendly sub-select.
+        $DB->execute("DELETE FROM {analytics_predictions} WHERE id IN (
+                        SELECT oldpredictions.id
+                        FROM (
+                            SELECT p.id
+                            FROM {analytics_predictions} p
+                            LEFT JOIN {context} ctx ON p.contextid = ctx.id
+                            WHERE ctx.id IS NULL
+                        ) oldpredictions
+                    )");
+
+        $DB->execute("DELETE FROM {analytics_indicator_calc} WHERE id IN (
+                        SELECT oldcalcs.id FROM (
+                            SELECT c.id
+                            FROM {analytics_indicator_calc} c
+                            LEFT JOIN {context} ctx ON c.contextid = ctx.id
+                            WHERE ctx.id IS NULL
+                        ) oldcalcs
+                    )");
 
         // Clean up stuff that depends on analysable ids that do not exist anymore.
 
index b3fe2fb..83924f7 100644 (file)
@@ -49,6 +49,6 @@ class behat_auth_email extends behat_base {
         $confirmationpath = $confirmationurl->out_as_local_url(false);
         $url = $confirmationpath .  '?' . 'data='. $secret .'/'. $username;
 
-        $this->getSession()->visit($this->locate_path($url));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index e7b65e5..6233454 100644 (file)
@@ -58,7 +58,7 @@ class behat_auth extends behat_base {
         }
 
         // Visit login page.
-        $this->getSession()->visit($this->locate_path($loginurl->out_as_local_url()));
+        $this->execute('behat_general::i_visit', [$loginurl]);
 
         // Enter username and password.
         $this->execute('behat_forms::i_set_the_field_to', array('Username', $this->escape($username)));
index a5ead37..fe5f575 100644 (file)
@@ -56,7 +56,7 @@ class behat_backup extends behat_base {
         // table elements are used, and we need to catch exceptions contantly.
 
         // Go to homepage.
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
         $this->execute("behat_general::wait_until_the_page_is_ready");
 
         // Click the course link.
@@ -98,7 +98,7 @@ class behat_backup extends behat_base {
         // table elements are used, and we need to catch exceptions contantly.
 
         // Go to homepage.
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
 
         // Click the course link.
         $this->execute("behat_general::click_link", $backupcourse);
@@ -134,7 +134,7 @@ class behat_backup extends behat_base {
         // table elements are used, and we need to catch exceptions contantly.
 
         // Go to homepage.
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
         $this->execute("behat_general::wait_until_the_page_is_ready");
 
         // Click the course link.
index 7ee4ae4..8e18c99 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index 581a46a..723993f 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js.map and b/blocks/myoverview/amd/build/view.min.js.map differ
index 9d637b7..524c4ba 100644 (file)
@@ -33,6 +33,7 @@ define(
     'core_course/events',
     'block_myoverview/selectors',
     'core/paged_content_events',
+    'core/aria',
 ],
 function(
     $,
@@ -44,7 +45,8 @@ function(
     Templates,
     CourseEvents,
     Selectors,
-    PagedContentEvents
+    PagedContentEvents,
+    Aria
 ) {
 
     var SELECTORS = {
@@ -174,12 +176,14 @@ function(
      */
     var hideFavouriteIcon = function(root, courseId) {
         var iconContainer = getFavouriteIconContainer(root, courseId);
+
         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
         isFavouriteIcon.addClass('hidden');
-        isFavouriteIcon.attr('aria-hidden', true);
+        Aria.hide(isFavouriteIcon);
+
         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
         notFavourteIcon.removeClass('hidden');
-        notFavourteIcon.attr('aria-hidden', false);
+        Aria.unhide(notFavourteIcon);
     };
 
     /**
@@ -190,12 +194,14 @@ function(
      */
     var showFavouriteIcon = function(root, courseId) {
         var iconContainer = getFavouriteIconContainer(root, courseId);
+
         var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
         isFavouriteIcon.removeClass('hidden');
-        isFavouriteIcon.attr('aria-hidden', false);
+        Aria.unhide(isFavouriteIcon);
+
         var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
         notFavourteIcon.addClass('hidden');
-        notFavourteIcon.attr('aria-hidden', true);
+        Aria.hide(notFavourteIcon);
     };
 
     /**
index dead3b8..7be3a49 100644 (file)
Binary files a/blocks/navigation/amd/build/ajax_response_renderer.min.js and b/blocks/navigation/amd/build/ajax_response_renderer.min.js differ
index 2bc3203..c729061 100644 (file)
Binary files a/blocks/navigation/amd/build/ajax_response_renderer.min.js.map and b/blocks/navigation/amd/build/ajax_response_renderer.min.js.map differ
index 99f2e28..e92ed46 100644 (file)
  * @copyright  2015 John Okely <john@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/templates', 'core/notification', 'core/url'], function($, Templates, Notification, Url) {
+define([
+    'jquery',
+    'core/templates',
+    'core/notification',
+    'core/url',
+    'core/aria',
+], function(
+    $,
+    Templates,
+    Notification,
+    Url,
+    Aria
+) {
 
     // Mappings for the different types of nodes coming from the navigation.
     // Copied from lib/navigationlib.php navigation_node constants.
@@ -43,7 +55,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core/url'], function($
     function buildDOM(rootElement, nodes) {
         var ul = $('<ul></ul>');
         ul.attr('role', 'group');
-        ul.attr('aria-hidden', true);
+        Aria.hide(ul);
 
         $.each(nodes, function(index, node) {
             if (typeof node !== 'object') {
@@ -153,7 +165,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core/url'], function($
                 var group = element.find('#' + item.attr('aria-owns'));
 
                 item.attr('aria-expanded', true);
-                group.attr('aria-hidden', false);
+                Aria.unhide(group);
             } else {
                 if (element.parent().hasClass('contains_branch')) {
                     element.parent().removeClass('contains_branch');
index 4d8e69f..54f17c8 100644 (file)
Binary files a/blocks/recentlyaccessedcourses/amd/build/main.min.js and b/blocks/recentlyaccessedcourses/amd/build/main.min.js differ
index 6fdffbc..7d74c0f 100644 (file)
Binary files a/blocks/recentlyaccessedcourses/amd/build/main.min.js.map and b/blocks/recentlyaccessedcourses/amd/build/main.min.js.map differ
index ca3186b..9af85cb 100644 (file)
@@ -32,6 +32,7 @@ define(
         'core/templates',
         'core_course/events',
         'core_course/repository',
+        'core/aria',
     ],
     function(
         $,
@@ -41,7 +42,8 @@ define(
         PagedContentPagingBar,
         Templates,
         CourseEvents,
-        CoursesRepository
+        CoursesRepository,
+        Aria
     ) {
 
         // Constants.
@@ -96,7 +98,7 @@ define(
             var pagingBar = root.find(SELECTORS.PAGING_BAR);
             pagingBar.css('opacity', 1);
             pagingBar.css('visibility', 'visible');
-            pagingBar.attr('aria-hidden', 'false');
+            Aria.unhide(pagingBar);
         };
 
         /**
@@ -108,7 +110,7 @@ define(
             var pagingBar = root.find(SELECTORS.PAGING_BAR);
             pagingBar.css('opacity', 0);
             pagingBar.css('visibility', 'hidden');
-            pagingBar.attr('aria-hidden', 'true');
+            Aria.hide(pagingBar);
         };
 
         /**
index 1c6cb4c..e089afc 100644 (file)
@@ -121,7 +121,7 @@ class behat_calendar extends behat_base {
      */
     public function i_view_the_calendar_for($month, $year) {
         $time = make_timestamp($year, $month, 1);
-        $this->getSession()->visit($this->locate_path('/calendar/view.php?view=month&course=1&time='.$time));
+        $this->execute('behat_general::i_visit', ['/calendar/view.php?view=month&course=1&time='.$time]);
 
     }
 
@@ -134,6 +134,6 @@ class behat_calendar extends behat_base {
      */
     public function i_am_viewing_site_calendar() {
         $url = new moodle_url('/calendar/view.php', ['view' => 'month']);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index 0ea080e..da00bcf 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 6519c83..b4be364 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index a1d1bbc..ab56979 100644 (file)
@@ -133,7 +133,18 @@ const registerListenerEvents = (courseId, chooserConfig) => {
                 const sectionModal = buildModal(bodyPromise, footerData);
 
                 // Now we have a modal we should start fetching data.
-                const data = await fetchModuleData();
+                // If an error occurs while fetching the data, display the error within the modal.
+                const data = await fetchModuleData().catch(async(e) => {
+                    const errorTemplateData = {
+                        'errormessage': e.message
+                    };
+                    bodyPromiseResolver(await Templates.render('core_course/local/activitychooser/error', errorTemplateData));
+                });
+
+                // Early return if there is no module data.
+                if (!data) {
+                    return;
+                }
 
                 // Apply the section id to all the module instance links.
                 const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid, caller.dataset.sectionreturnid);
index b1dfddd..20209ae 100644 (file)
@@ -105,6 +105,11 @@ class content_item_service {
             return $favmods;
         }
 
+        // Make sure the guest user exists in the database.
+        if (!\core_user::get_user($CFG->siteguest)) {
+            throw new \coding_exception('The guest user does not exist in the database.');
+        }
+
         $favourites = $this->get_content_favourites(self::RECOMMENDATION_PREFIX, \context_user::instance($CFG->siteguest));
 
         $recommendationcache->set($CFG->siteguest, $favourites);
index 0e918c9..076b99f 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Specialised restore for format_topics
+ * Specialised restore for Topics course format.
  *
  * @package   format_topics
  * @category  backup
@@ -26,9 +26,9 @@
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * Specialised restore for format_topics
+ * Specialised restore for Topics course format.
  *
- * Processes 'numsections' from the old backup files and hides sections that used to be "orphaned"
+ * Processes 'numsections' from the old backup files and hides sections that used to be "orphaned".
  *
  * @package   format_topics
  * @category  backup
@@ -53,7 +53,7 @@ class restore_format_topics_plugin extends restore_format_plugin {
     }
 
     /**
-     * Creates a dummy path element in order to be able to execute code after restore
+     * Creates a dummy path element in order to be able to execute code after restore.
      *
      * @return restore_path_element[]
      */
@@ -76,16 +76,20 @@ class restore_format_topics_plugin extends restore_format_plugin {
     }
 
     /**
-     * Dummy process method
+     * Dummy process method.
+     *
+     * @return void
      */
     public function process_dummy_course() {
 
     }
 
     /**
-     * Executed after course restore is complete
+     * Executed after course restore is complete.
+     *
+     * This method is only executed if course configuration was overridden.
      *
-     * This method is only executed if course configuration was overridden
+     * @return void
      */
     public function after_restore_course() {
         global $DB;
index 5f365c1..dc769e3 100644 (file)
 //
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
 /**
- * Privacy Subsystem implementation for format_topics.
+ * Privacy Subsystem implementation for Topics course format.
  *
  * @package    format_topics
  * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+
 namespace format_topics\privacy;
+
 defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\null_provider;
+
 /**
- * Privacy Subsystem for format_topics implementing null_provider.
+ * Privacy Subsystem for Topics course format implementing null_provider.
  *
  * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\null_provider {
+class provider implements null_provider {
+
     /**
      * Get the language string identifier with the component's language
      * file to explain why this plugin stores no data.
      *
      * @return  string
      */
-    public static function get_reason() : string {
+    public static function get_reason(): string {
         return 'privacy:metadata';
     }
 }
\ No newline at end of file
index 131f927..d46360f 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Upgrade scripts for course format "Topics"
+ * Upgrade scripts for Topics course format.
  *
  * @package    format_topics
  * @copyright  2017 Marina Glancy
@@ -25,9 +25,9 @@
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * Upgrade script for format_topics
+ * Upgrade script for Topics course format.
  *
- * @param int $oldversion the version we are upgrading from
+ * @param int|float $oldversion the version we are upgrading from
  * @return bool result
  */
 function xmldb_format_topics_upgrade($oldversion) {
index 8d56504..ff122a8 100644 (file)
@@ -1,11 +1,11 @@
-// Javascript functions for Topics course format
+// Javascript functions for Topics course format.
 
 M.course = M.course || {};
 
 M.course.format = M.course.format || {};
 
 /**
- * Get sections config for this format
+ * Get sections config for this format.
  *
  * The section structure is:
  * <ul class="topics">
@@ -18,50 +18,48 @@ M.course.format = M.course.format || {};
  */
 M.course.format.get_config = function() {
     return {
-        container_node : 'ul',
-        container_class : 'topics',
-        section_node : 'li',
-        section_class : 'section'
+        container_node: 'ul',
+        container_class: 'topics',
+        section_node: 'li',
+        section_class: 'section'
     };
-}
+};
 
 /**
- * Swap section
+ * Swap section.
  *
  * @param {YUI} Y YUI3 instance
  * @param {string} node1 node to swap to
  * @param {string} node2 node to swap with
- * @return {NodeList} section list
  */
 M.course.format.swap_sections = function(Y, node1, node2) {
     var CSS = {
-        COURSECONTENT : 'course-content',
-        SECTIONADDMENUS : 'section_add_menus'
+        COURSECONTENT: 'course-content',
+        SECTIONADDMENUS: 'section_add_menus'
     };
 
-    var sectionlist = Y.Node.all('.'+CSS.COURSECONTENT+' '+M.course.format.get_section_selector(Y));
+    var sectionlist = Y.Node.all('.' + CSS.COURSECONTENT + ' ' + M.course.format.get_section_selector(Y));
     // Swap the non-ajax menus, noting these are not always present (depends on theme and user prefs).
-    if (sectionlist.item(node1).one('.'+CSS.SECTIONADDMENUS)) {
-        sectionlist.item(node1).one('.'+CSS.SECTIONADDMENUS).swap(sectionlist.item(node2).one('.'+CSS.SECTIONADDMENUS));
+    if (sectionlist.item(node1).one('.' + CSS.SECTIONADDMENUS)) {
+        sectionlist.item(node1).one('.' + CSS.SECTIONADDMENUS).swap(sectionlist.item(node2).one('.' + CSS.SECTIONADDMENUS));
     }
-}
+};
 
 /**
- * Process sections after ajax response
+ * Process sections after ajax response.
  *
  * @param {YUI} Y YUI3 instance
  * @param {NodeList} sectionlist of sections
  * @param {array} response ajax response
  * @param {string} sectionfrom first affected section
  * @param {string} sectionto last affected section
- * @return void
  */
 M.course.format.process_sections = function(Y, sectionlist, response, sectionfrom, sectionto) {
     var CSS = {
-        SECTIONNAME : 'sectionname'
+        SECTIONNAME: 'sectionname'
     },
     SELECTORS = {
-        SECTIONLEFTSIDE : '.left .section-handle .icon'
+        SECTIONLEFTSIDE: '.left .section-handle .icon'
     };
 
     if (response.action == 'move') {
@@ -78,15 +76,15 @@ M.course.format.process_sections = function(Y, sectionlist, response, sectionfro
         for (var i = sectionfrom; i <= sectionto; i++) {
             // Update section title.
             var content = Y.Node.create('<span>' + response.sectiontitles[i] + '</span>');
-            sectionlist.item(i).all('.'+CSS.SECTIONNAME).setHTML(content);
+            sectionlist.item(i).all('.' + CSS.SECTIONNAME).setHTML(content);
             // Update the drag handle.
             ele = sectionlist.item(i).one(SELECTORS.SECTIONLEFTSIDE).ancestor('.section-handle');
             str = ele.getAttribute('title');
             stridx = str.lastIndexOf(' ');
-            newstr = str.substr(0, stridx +1) + i;
+            newstr = str.substr(0, stridx + 1) + i;
             ele.setAttribute('title', newstr);
             // Update the aria-label for the section.
             sectionlist.item(i).setAttribute('aria-label', content.get('innerText').trim());
         }
     }
-}
+};
index 7af2851..065798c 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Topics course format.  Display the whole course as "topics" made of modules.
+ * Topics course format. Display the whole course as "topics" made of modules.
  *
  * @package format_topics
  * @copyright 2006 The Open University
@@ -28,20 +28,20 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->libdir.'/filelib.php');
 require_once($CFG->libdir.'/completionlib.php');
 
-// Horrible backwards compatible parameter aliasing..
+// Horrible backwards compatible parameter aliasing.
 if ($topic = optional_param('topic', 0, PARAM_INT)) {
     $url = $PAGE->url;
     $url->param('section', $topic);
     debugging('Outdated topic param passed to course/view.php', DEBUG_DEVELOPER);
     redirect($url);
 }
-// End backwards-compatible aliasing..
+// End backwards-compatible aliasing.
 
 $context = context_course::instance($course->id);
 // Retrieve course format option fields and add them to the $course object.
 $course = course_get_format($course)->get_course();
 
-if (($marker >=0) && has_capability('moodle/course:setcurrentsection', $context) && confirm_sesskey()) {
+if (($marker >= 0) && has_capability('moodle/course:setcurrentsection', $context) && confirm_sesskey()) {
     $course->marker = $marker;
     course_set_marker($course->id, $marker);
 }
@@ -57,5 +57,5 @@ if (!empty($displaysection)) {
     $renderer->print_multiple_section_page($course, null, null, null, null);
 }
 
-// Include course format js module
+// Include course format js module.
 $PAGE->requires->js('/course/format/topics/format.js');
index 0b7c16c..6f5306b 100644 (file)
@@ -1,5 +1,4 @@
 <?php
-
 // This file is part of Moodle - http://moodle.org/
 //
 // Moodle is free software: you can redistribute it and/or modify
@@ -16,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Strings for component 'format_topics', language 'en', branch 'MOODLE_20_STABLE'
+ * Strings for component Topics course format.
  *
  * @package   format_topics
  * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
index 547f218..d114994 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * This file contains main class for the course format Topic
+ * This file contains main class for Topics course format.
  *
  * @since     Moodle 2.0
  * @package   format_topics
 defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot. '/course/format/lib.php');
 
+use core\output\inplace_editable;
+
 /**
- * Main class for the Topics course format
+ * Main class for the Topics course format.
  *
  * @package    format_topics
  * @copyright  2012 Marina Glancy
@@ -36,7 +38,7 @@ require_once($CFG->dirroot. '/course/format/lib.php');
 class format_topics extends format_base {
 
     /**
-     * Returns true if this course format uses sections
+     * Returns true if this course format uses sections.
      *
      * @return bool
      */
@@ -47,7 +49,7 @@ class format_topics extends format_base {
     /**
      * Returns the display name of the given section that the course prefers.
      *
-     * Use section name is specified by user. Otherwise use default ("Topic #")
+     * Use section name is specified by user. Otherwise use default ("Topic #").
      *
      * @param int|stdClass $section Section object from database or just field section.section
      * @return string Display name that the course format prefers, e.g. "Topic 2"
@@ -56,7 +58,7 @@ class format_topics extends format_base {
         $section = $this->get_section($section);
         if ((string)$section->name !== '') {
             return format_string($section->name, true,
-                    array('context' => context_course::instance($this->courseid)));
+                ['context' => context_course::instance($this->courseid)]);
         } else {
             return $this->get_default_section_name($section);
         }
@@ -84,7 +86,7 @@ class format_topics extends format_base {
     }
 
     /**
-     * The URL to use for the specified course (with section)
+     * The URL to use for the specified course (with section).
      *
      * @param int|stdClass $section Section object from database or just field course_sections.section
      *     if omitted the course view page is returned
@@ -93,10 +95,10 @@ class format_topics extends format_base {
      *     'sr' (int) used by multipage formats to specify to which section to return
      * @return null|moodle_url
      */
-    public function get_view_url($section, $options = array()) {
+    public function get_view_url($section, $options = []) {
         global $CFG;
         $course = $this->get_course();
-        $url = new moodle_url('/course/view.php', array('id' => $course->id));
+        $url = new moodle_url('/course/view.php', ['id' => $course->id]);
 
         $sr = null;
         if (array_key_exists('sr', $options)) {
@@ -131,7 +133,7 @@ class format_topics extends format_base {
     }
 
     /**
-     * Returns the information about the ajax support in the given source format
+     * Returns the information about the ajax support in the given source format.
      *
      * The returned object's property (boolean)capable indicates that
      * the course format supports Moodle course ajax features.
@@ -145,14 +147,15 @@ class format_topics extends format_base {
     }
 
     /**
-     * Loads all of the course sections into the navigation
+     * Loads all of the course sections into the navigation.
      *
      * @param global_navigation $navigation
      * @param navigation_node $node The course node within the navigation
+     * @return void
      */
     public function extend_course_navigation($navigation, navigation_node $node) {
         global $PAGE;
-        // if section is specified in course/view.php, make sure it is expanded in navigation
+        // If section is specified in course/view.php, make sure it is expanded in navigation.
         if ($navigation->includesectionnum === false) {
             $selectedsection = optional_param('section', null, PARAM_INT);
             if ($selectedsection !== null && (!defined('AJAX_SCRIPT') || AJAX_SCRIPT == '0') &&
@@ -161,7 +164,7 @@ class format_topics extends format_base {
             }
         }
 
-        // check if there are callbacks to extend course navigation
+        // Check if there are callbacks to extend course navigation.
         parent::extend_course_navigation($navigation, $node);
 
         // We want to remove the general section if it is empty.
@@ -179,15 +182,15 @@ class format_topics extends format_base {
     }
 
     /**
-     * Custom action after section has been moved in AJAX mode
+     * Custom action after section has been moved in AJAX mode.
      *
      * Used in course/rest.php
      *
      * @return array This will be passed in ajax respose
      */
-    function ajax_section_move() {
+    public function ajax_section_move() {
         global $PAGE;
-        $titles = array();
+        $titles = [];
         $course = $this->get_course();
         $modinfo = get_fast_modinfo($course);
         $renderer = $this->get_renderer($PAGE);
@@ -196,24 +199,24 @@ class format_topics extends format_base {
                 $titles[$number] = $renderer->section_title($section, $course);
             }
         }
-        return array('sectiontitles' => $titles, 'action' => 'move');
+        return ['sectiontitles' => $titles, 'action' => 'move'];
     }
 
     /**
-     * Returns the list of blocks to be automatically added for the newly created course
+     * Returns the list of blocks to be automatically added for the newly created course.
      *
      * @return array of default blocks, must contain two keys BLOCK_POS_LEFT and BLOCK_POS_RIGHT
      *     each of values is an array of block names (for left and right side columns)
      */
     public function get_default_blocks() {
-        return array(
-            BLOCK_POS_LEFT => array(),
-            BLOCK_POS_RIGHT => array()
-        );
+        return [
+            BLOCK_POS_LEFT => [],
+            BLOCK_POS_RIGHT => [],
+        ];
     }
 
     /**
-     * Definitions of the additional options that this course format uses for course
+     * Definitions of the additional options that this course format uses for course.
      *
      * Topics format uses the following options:
      * - coursedisplay
@@ -226,44 +229,44 @@ class format_topics extends format_base {
         static $courseformatoptions = false;
         if ($courseformatoptions === false) {
             $courseconfig = get_config('moodlecourse');
-            $courseformatoptions = array(
-                'hiddensections' => array(
+            $courseformatoptions = [
+                'hiddensections' => [
                     'default' => $courseconfig->hiddensections,
                     'type' => PARAM_INT,
-                ),
-                'coursedisplay' => array(
+                ],
+                'coursedisplay' => [
                     'default' => $courseconfig->coursedisplay,
                     'type' => PARAM_INT,
-                ),
-            );
+                ],
+            ];
         }
         if ($foreditform && !isset($courseformatoptions['coursedisplay']['label'])) {
-            $courseformatoptionsedit = array(
-                'hiddensections' => array(
+            $courseformatoptionsedit = [
+                'hiddensections' => [
                     'label' => new lang_string('hiddensections'),
                     'help' => 'hiddensections',
                     'help_component' => 'moodle',
                     'element_type' => 'select',
-                    'element_attributes' => array(
-                        array(
+                    'element_attributes' => [
+                        [
                             0 => new lang_string('hiddensectionscollapsed'),
                             1 => new lang_string('hiddensectionsinvisible')
-                        )
-                    ),
-                ),
-                'coursedisplay' => array(
+                        ],
+                    ],
+                ],
+                'coursedisplay' => [
                     'label' => new lang_string('coursedisplay'),
                     'element_type' => 'select',
-                    'element_attributes' => array(
-                        array(
+                    'element_attributes' => [
+                        [
                             COURSE_DISPLAY_SINGLEPAGE => new lang_string('coursedisplay_single'),
-                            COURSE_DISPLAY_MULTIPAGE => new lang_string('coursedisplay_multi')
-                        )
-                    ),
+                            COURSE_DISPLAY_MULTIPAGE => new lang_string('coursedisplay_multi'),
+                        ],
+                    ],
                     'help' => 'coursedisplay',
                     'help_component' => 'moodle',
-                )
-            );
+                ],
+            ];
             $courseformatoptions = array_merge_recursive($courseformatoptions, $courseformatoptionsedit);
         }
         return $courseformatoptions;
@@ -301,7 +304,7 @@ class format_topics extends format_base {
     }
 
     /**
-     * Updates format options for a course
+     * Updates format options for a course.
      *
      * In case if course format was changed to 'topics', we try to copy options
      * 'coursedisplay' and 'hiddensections' from the previous format.
@@ -328,7 +331,7 @@ class format_topics extends format_base {
     }
 
     /**
-     * Whether this format allows to delete sections
+     * Whether this format allows to delete sections.
      *
      * Do not call this function directly, instead use {@link course_can_delete_section()}
      *
@@ -340,17 +343,17 @@ class format_topics extends format_base {
     }
 
     /**
-     * Prepares the templateable object to display section name
+     * Prepares the templateable object to display section name.
      *
      * @param \section_info|\stdClass $section
      * @param bool $linkifneeded
      * @param bool $editable
      * @param null|lang_string|string $edithint
      * @param null|lang_string|string $editlabel
-     * @return \core\output\inplace_editable
+     * @return inplace_editable
      */
     public function inplace_editable_render_section_name($section, $linkifneeded = true,
-                                                         $editable = null, $edithint = null, $editlabel = null) {
+            $editable = null, $edithint = null, $editlabel = null) {
         if (empty($edithint)) {
             $edithint = new lang_string('editsectionname', 'format_topics');
         }
@@ -383,6 +386,19 @@ class format_topics extends format_base {
         return !$section->section || $section->visible;
     }
 
+    /**
+     * Callback used in WS core_course_edit_section when teacher performs an AJAX action on a section (show/hide).
+     *
+     * Access to the course is already validated in the WS but the callback has to make sure
+     * that particular action is allowed by checking capabilities
+     *
+     * Course formats should register.
+     *
+     * @param section_info|stdClass $section
+     * @param string $action
+     * @param int $sr
+     * @return null|array any data for the Javascript post-processor (must be json-encodeable)
+     */
     public function section_action($section, $action, $sr) {
         global $PAGE;
 
@@ -413,12 +429,12 @@ class format_topics extends format_base {
 }
 
 /**
- * Implements callback inplace_editable() allowing to edit values in-place
+ * Implements callback inplace_editable() allowing to edit values in-place.
  *
  * @param string $itemtype
  * @param int $itemid
  * @param mixed $newvalue
- * @return \core\output\inplace_editable
+ * @return inplace_editable
  */
 function format_topics_inplace_editable($itemtype, $itemid, $newvalue) {
     global $DB, $CFG;
@@ -426,7 +442,7 @@ function format_topics_inplace_editable($itemtype, $itemid, $newvalue) {
     if ($itemtype === 'sectionname' || $itemtype === 'sectionnamenl') {
         $section = $DB->get_record_sql(
             'SELECT s.* FROM {course_sections} s JOIN {course} c ON s.course = c.id WHERE s.id = ? AND c.format = ?',
-            array($itemid, 'topics'), MUST_EXIST);
+            [$itemid, 'topics'], MUST_EXIST);
         return course_get_format($section->course)->inplace_editable_update_section_name($section, $itemtype, $newvalue);
     }
 }
index 5d11be9..8b735c3 100644 (file)
@@ -23,7 +23,6 @@
  * @since Moodle 2.3
  */
 
-
 defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot.'/course/format/renderer.php');
 
@@ -36,7 +35,7 @@ require_once($CFG->dirroot.'/course/format/renderer.php');
 class format_topics_renderer extends format_section_renderer_base {
 
     /**
-     * Constructor method, calls the parent constructor
+     * Constructor method, calls the parent constructor.
      *
      * @param moodle_page $page
      * @param string $target one of rendering target constants
@@ -44,21 +43,24 @@ class format_topics_renderer extends format_section_renderer_base {
     public function __construct(moodle_page $page, $target) {
         parent::__construct($page, $target);
 
-        // Since format_topics_renderer::section_edit_control_items() only displays the 'Highlight' control when editing mode is on
-        // we need to be sure that the link 'Turn editing mode on' is available for a user who does not have any other managing capability.
+        // Since format_topics_renderer::section_edit_control_items() only displays the 'Highlight' control
+        // when editing mode is on we need to be sure that the link 'Turn editing mode on' is available for a user
+        // who does not have any other managing capability.
         $page->set_other_editing_capability('moodle/course:setcurrentsection');
     }
 
     /**
-     * Generate the starting container html for a list of sections
+     * Generate the starting container html for a list of sections.
+     *
      * @return string HTML to output.
      */
     protected function start_section_list() {
-        return html_writer::start_tag('ul', array('class' => 'topics'));
+        return html_writer::start_tag('ul', ['class' => 'topics']);
     }
 
     /**
-     * Generate the closing container html for a list of sections
+     * Generate the closing container html for a list of sections.
+     *
      * @return string HTML to output.
      */
     protected function end_section_list() {
@@ -66,7 +68,8 @@ class format_topics_renderer extends format_section_renderer_base {
     }
 
     /**
-     * Generate the title for this section page
+     * Generate the title for this section page.
+     *
      * @return string the page title
      */
     protected function page_title() {
@@ -74,9 +77,9 @@ class format_topics_renderer extends format_section_renderer_base {
     }
 
     /**
-     * Generate the section title, wraps it in a link to the section page if page is to be displayed on a separate page
+     * Generate the section title, wraps it in a link to the section page if page is to be displayed on a separate page.
      *
-     * @param stdClass $section The course_section entry from DB
+     * @param section_info|stdClass $section The course_section entry from DB
      * @param stdClass $course The course entry from DB
      * @return string HTML to output.
      */
@@ -85,10 +88,10 @@ class format_topics_renderer extends format_section_renderer_base {
     }
 
     /**
-     * Generate the section title to be displayed on the section page, without a link
+     * Generate the section title to be displayed on the section page, without a link.
      *
-     * @param stdClass $section The course_section entry from DB
-     * @param stdClass $course The course entry from DB
+     * @param section_info|stdClass $section The course_section entry from DB
+     * @param int|stdClass $course The course entry from DB
      * @return string HTML to output.
      */
     public function section_title_without_link($section, $course) {
@@ -96,16 +99,16 @@ class format_topics_renderer extends format_section_renderer_base {
     }
 
     /**
-     * Generate the edit control items of a section
+     * Generate the edit control items of a section.
      *
-     * @param stdClass $course The course entry from DB
-     * @param stdClass $section The course_section entry from DB
+     * @param int|stdClass $course The course entry from DB
+     * @param section_info|stdClass $section The course_section entry from DB
      * @param bool $onsectionpage true if being printed on a section page
      * @return array of edit control items
      */
     protected function section_edit_control_items($course, $section, $onsectionpage = false) {
         if (!$this->page->user_is_editing()) {
-            return array();
+            return [];
         }
 
         $coursecontext = context_course::instance($course->id);
@@ -117,24 +120,34 @@ class format_topics_renderer extends format_section_renderer_base {
         }
         $url->param('sesskey', sesskey());
 
-        $controls = array();
+        $controls = [];
         if ($section->section && has_capability('moodle/course:setcurrentsection', $coursecontext)) {
             if ($course->marker == $section->section) {  // Show the "light globe" on/off.
                 $url->param('marker', 0);
                 $highlightoff = get_string('highlightoff');
-                $controls['highlight'] = array('url' => $url, "icon" => 'i/marked',
-                                               'name' => $highlightoff,
-                                               'pixattr' => array('class' => ''),
-                                               'attr' => array('class' => 'editing_highlight',
-                                                   'data-action' => 'removemarker'));
+                $controls['highlight'] = [
+                    'url' => $url,
+                    'icon' => 'i/marked',
+                    'name' => $highlightoff,
+                    'pixattr' => ['class' => ''],
+                    'attr' => [
+                        'class' => 'editing_highlight',
+                        'data-action' => 'removemarker'
+                    ],
+                ];
             } else {
                 $url->param('marker', $section->section);
                 $highlight = get_string('highlight');
-                $controls['highlight'] = array('url' => $url, "icon" => 'i/marker',
-                                               'name' => $highlight,
-                                               'pixattr' => array('class' => ''),
-                                               'attr' => array('class' => 'editing_highlight',
-                                                   'data-action' => 'setmarker'));
+                $controls['highlight'] = [
+                    'url' => $url,
+                    'icon' => 'i/marker',
+                    'name' => $highlight,
+                    'pixattr' => ['class' => ''],
+                    'attr' => [
+                        'class' => 'editing_highlight',
+                        'data-action' => 'setmarker'
+                    ],
+                ];
             }
         }
 
@@ -142,7 +155,7 @@ class format_topics_renderer extends format_section_renderer_base {
 
         // If the edit key exists, we are going to insert our controls after it.
         if (array_key_exists("edit", $parentcontrols)) {
-            $merged = array();
+            $merged = [];
             // We can't use splice because we are using associative arrays.
             // Step through the array and merge the arrays.
             foreach ($parentcontrols as $key => $action) {
index 0e66249..5f2ead3 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * format_topics related unit tests
+ * Topics course format related unit tests.
  *
  * @package    format_topics
  * @copyright  2015 Marina Glancy
@@ -28,7 +28,7 @@ global $CFG;
 require_once($CFG->dirroot . '/course/lib.php');
 
 /**
- * format_topics related unit tests
+ * Topics course format related unit tests.
  *
  * @package    format_topics
  * @copyright  2015 Marina Glancy
@@ -38,6 +38,8 @@ class format_topics_testcase extends advanced_testcase {
 
     /**
      * Tests for format_topics::get_section_name method with default section names.
+     *
+     * @return void
      */
     public function test_get_section_name() {
         global $DB;
@@ -46,11 +48,11 @@ class format_topics_testcase extends advanced_testcase {
         // Generate a course with 5 sections.
         $generator = $this->getDataGenerator();
         $numsections = 5;
-        $course = $generator->create_course(array('numsections' => $numsections, 'format' => 'topics'),
-            array('createsections' => true));
+        $course = $generator->create_course(['numsections' => $numsections, 'format' => 'topics'],
+            ['createsections' => true]);
 
         // Get section names for course.
-        $coursesections = $DB->get_records('course_sections', array('course' => $course->id));
+        $coursesections = $DB->get_records('course_sections', ['course' => $course->id]);
 
         // Test get_section_name with default section names.
         $courseformat = course_get_format($course);
@@ -62,6 +64,8 @@ class format_topics_testcase extends advanced_testcase {
 
     /**
      * Tests for format_topics::get_section_name method with modified section names.
+     *
+     * @return void
      */
     public function test_get_section_name_customised() {
         global $DB;
@@ -70,11 +74,11 @@ class format_topics_testcase extends advanced_testcase {
         // Generate a course with 5 sections.
         $generator = $this->getDataGenerator();
         $numsections = 5;
-        $course = $generator->create_course(array('numsections' => $numsections, 'format' => 'topics'),
-            array('createsections' => true));
+        $course = $generator->create_course(['numsections' => $numsections, 'format' => 'topics'],
+            ['createsections' => true]);
 
         // Get section names for course.
-        $coursesections = $DB->get_records('course_sections', array('course' => $course->id));
+        $coursesections = $DB->get_records('course_sections', ['course' => $course->id]);
 
         // Modify section names.
         $customname = "Custom Section";
@@ -84,7 +88,7 @@ class format_topics_testcase extends advanced_testcase {
         }
 
         // Requery updated section names then test get_section_name.
-        $coursesections = $DB->get_records('course_sections', array('course' => $course->id));
+        $coursesections = $DB->get_records('course_sections', ['course' => $course->id]);
         $courseformat = course_get_format($course);
         foreach ($coursesections as $section) {
             // Assert that with modified section names, get_section_name returns the modified section name.
@@ -94,6 +98,8 @@ class format_topics_testcase extends advanced_testcase {
 
     /**
      * Tests for format_topics::get_default_section_name.
+     *
+     * @return void
      */
     public function test_get_default_section_name() {
         global $DB;
@@ -102,11 +108,11 @@ class format_topics_testcase extends advanced_testcase {
         // Generate a course with 5 sections.
         $generator = $this->getDataGenerator();
         $numsections = 5;
-        $course = $generator->create_course(array('numsections' => $numsections, 'format' => 'topics'),
-            array('createsections' => true));
+        $course = $generator->create_course(['numsections' => $numsections, 'format' => 'topics'],
+            ['createsections' => true]);
 
         // Get section names for course.
-        $coursesections = $DB->get_records('course_sections', array('course' => $course->id));
+        $coursesections = $DB->get_records('course_sections', ['course' => $course->id]);
 
         // Test get_default_section_name with default section names.
         $courseformat = course_get_format($course);
@@ -122,7 +128,9 @@ class format_topics_testcase extends advanced_testcase {
     }
 
     /**
-     * Test web service updating section name
+     * Test web service updating section name.
+     *
+     * @return void
      */
     public function test_update_inplace_editable() {
         global $CFG, $DB, $PAGE;
@@ -131,9 +139,9 @@ class format_topics_testcase extends advanced_testcase {
         $this->resetAfterTest();
         $user = $this->getDataGenerator()->create_user();
         $this->setUser($user);
-        $course = $this->getDataGenerator()->create_course(array('numsections' => 5, 'format' => 'topics'),
-            array('createsections' => true));
-        $section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => 2));
+        $course = $this->getDataGenerator()->create_course(['numsections' => 5, 'format' => 'topics'],
+            ['createsections' => true]);
+        $section = $DB->get_record('course_sections', ['course' => $course->id, 'section' => 2]);
 
         // Call webservice without necessary permissions.
         try {
@@ -145,41 +153,43 @@ class format_topics_testcase extends advanced_testcase {
         }
 
         // Change to teacher and make sure that section name can be updated using web service update_inplace_editable().
-        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
         $this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
 
         $res = core_external::update_inplace_editable('format_topics', 'sectionname', $section->id, 'New section name');
         $res = external_api::clean_returnvalue(core_external::update_inplace_editable_returns(), $res);
         $this->assertEquals('New section name', $res['value']);
-        $this->assertEquals('New section name', $DB->get_field('course_sections', 'name', array('id' => $section->id)));
+        $this->assertEquals('New section name', $DB->get_field('course_sections', 'name', ['id' => $section->id]));
     }
 
     /**
-     * Test callback updating section name
+     * Test callback updating section name.
+     *
+     * @return void
      */
     public function test_inplace_editable() {
         global $DB, $PAGE;
 
         $this->resetAfterTest();
         $user = $this->getDataGenerator()->create_user();
-        $course = $this->getDataGenerator()->create_course(array('numsections' => 5, 'format' => 'topics'),
-            array('createsections' => true));
-        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $course = $this->getDataGenerator()->create_course(['numsections' => 5, 'format' => 'topics'],
+            ['createsections' => true]);
+        $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
         $this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
         $this->setUser($user);
 
-        $section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => 2));
+        $section = $DB->get_record('course_sections', ['course' => $course->id, 'section' => 2]);
 
         // Call callback format_topics_inplace_editable() directly.
-        $tmpl = component_callback('format_topics', 'inplace_editable', array('sectionname', $section->id, 'Rename me again'));
+        $tmpl = component_callback('format_topics', 'inplace_editable', ['sectionname', $section->id, 'Rename me again']);
         $this->assertInstanceOf('core\output\inplace_editable', $tmpl);
         $res = $tmpl->export_for_template($PAGE->get_renderer('core'));
         $this->assertEquals('Rename me again', $res['value']);
-        $this->assertEquals('Rename me again', $DB->get_field('course_sections', 'name', array('id' => $section->id)));
+        $this->assertEquals('Rename me again', $DB->get_field('course_sections', 'name', ['id' => $section->id]));
 
         // Try updating using callback from mismatching course format.
         try {
-            $tmpl = component_callback('format_weeks', 'inplace_editable', array('sectionname', $section->id, 'New name'));
+            component_callback('format_weeks', 'inplace_editable', ['sectionname', $section->id, 'New name']);
             $this->fail('Exception expected');
         } catch (moodle_exception $e) {
             $this->assertEquals(1, preg_match('/^Can\'t find data record in database/', $e->getMessage()));
@@ -200,9 +210,9 @@ class format_topics_testcase extends advanced_testcase {
 
         $this->setTimezone('UTC');
 
-        $params = array('format' => 'topics', 'numsections' => 5, 'startdate' => 1445644800);
+        $params = ['format' => 'topics', 'numsections' => 5, 'startdate' => 1445644800];
         $course = $this->getDataGenerator()->create_course($params);
-        $category = $DB->get_record('course_categories', array('id' => $course->category));
+        $category = $DB->get_record('course_categories', ['id' => $course->category]);
 
         $args = [
             'course' => $course,
@@ -226,7 +236,9 @@ class format_topics_testcase extends advanced_testcase {
     }
 
     /**
-     * Test for get_view_url() to ensure that the url is only given for the correct cases
+     * Test for get_view_url() to ensure that the url is only given for the correct cases.
+     *
+     * @return void
      */
     public function test_get_view_url() {
         global $CFG;
@@ -236,8 +248,8 @@ class format_topics_testcase extends advanced_testcase {
 
         // Generate a course with two sections (0 and 1) and two modules.
         $generator = $this->getDataGenerator();
-        $course1 = $generator->create_course(array('format' => 'topics'));
-        course_create_sections_if_missing($course1, array(0, 1));
+        $course1 = $generator->create_course(['format' => 'topics']);
+        course_create_sections_if_missing($course1, [0, 1]);
 
         $data = (object)['id' => $course1->id];
         $format = course_get_format($course1);
index 199e150..324f585 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Version details
+ * Version details.
  *
- * @package    format
- * @subpackage topics
+ * @package    format_topics
  * @copyright  1999 onwards Martin Dougiamas (http://dougiamas.com)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
diff --git a/course/templates/local/activitychooser/error.mustache b/course/templates/local/activitychooser/error.mustache
new file mode 100644 (file)
index 0000000..98ac463
--- /dev/null
@@ -0,0 +1,39 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_course/local/activitychooser/error
+
+    Chooser error template.
+
+    Variables required for this template:
+    * errormessage - The error message
+
+    Example context (json):
+    {
+        "errormessage": "Error"
+    }
+}}
+<div class="p-2 px-sm-5 py-sm-4">
+    <div class="alert alert-danger" role="alert">
+        <h5 class="alert-heading">
+            <i class="fa fa-exclamation-circle fa-fw text-danger"></i>
+            {{#str}} error, error {{/str}}
+        </h5>
+        <hr>
+        <p class="text-break">{{{errormessage}}}</p>
+    </div>
+</div>
index 384918c..03d5656 100644 (file)
@@ -96,7 +96,7 @@ class data_controller extends \core_customfield\data_controller {
             $this->save();
         }
 
-        if ($fromform['text']) {
+        if (array_key_exists('text', $fromform)) {
             $textoptions = $this->value_editor_options();
             $data = (object) ['field_editor' => $fromform];
             $data = file_postupdate_standard_editor($data, 'field', $textoptions, $textoptions['context'],
index ee846ef..a5cc62d 100644 (file)
@@ -145,6 +145,41 @@ class customfield_textarea_plugin_testcase extends advanced_testcase {
         $handler->instance_form_save($data);
     }
 
+    /**
+     * Test that instance form save empties the field content for blank values
+     */
+    public function test_instance_form_save_clear(): void {
+        global $CFG;
+
+        require_once("{$CFG->dirroot}/customfield/tests/fixtures/test_instance_form.php");
+
+        $this->setAdminUser();
+
+        $handler = $this->cfcat->get_handler();
+
+        // Set our custom field to a known value.
+        $submitdata = (array) $this->courses[1] + [
+            'customfield_myfield1_editor' => ['text' => 'I can see it in your eyes', 'format' => FORMAT_HTML],
+            'customfield_myfield2_editor' => ['text' => 'I can see it in your smile', 'format' => FORMAT_HTML],
+        ];
+
+        core_customfield_test_instance_form::mock_submit($submitdata, []);
+        $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]);
+        $handler->instance_form_save($form->get_data());
+
+        $this->assertEquals($submitdata['customfield_myfield1_editor']['text'],
+            core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value());
+
+        // Now empty our non-required field.
+        $submitdata['customfield_myfield1_editor']['text'] = '';
+
+        core_customfield_test_instance_form::mock_submit($submitdata, []);
+        $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]);
+        $handler->instance_form_save($form->get_data());
+
+        $this->assertEmpty(core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value());
+    }
+
     /**
      * Test for data_controller::get_value and export_value
      */
index c1d5067..df8876a 100644 (file)
@@ -399,10 +399,7 @@ class gradeimport_csv_load_data {
             case 'useridnumber':
             case 'useremail':
             case 'username':
-                // Skip invalid row with blank user field.
-                if (!empty($value)) {
-                    $this->studentid = $this->check_user_exists($value, $userfields[$mappingidentifier]);
-                }
+                $this->studentid = $this->check_user_exists($value, $userfields[$mappingidentifier]);
             break;
             case 'new':
                 $this->import_new_grade_item($header, $key, $value);
diff --git a/install/lang/bar/admin.php b/install/lang/bar/admin.php
new file mode 100644 (file)
index 0000000..645fbf6
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['clianswerno'] = 'n';
+$string['cliansweryes'] = 'j';
+$string['cliincorrectvalueretry'] = 'Falscha Wert, probiers nummal';
+$string['cliyesnoprompt'] = 'druck j (bedeit ja) oda n (bedeit na)';
diff --git a/install/lang/bar/langconfig.php b/install/lang/bar/langconfig.php
new file mode 100644 (file)
index 0000000..02f2239
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'de';
+$string['thislanguage'] = 'Bairisch';
index 38af08e..0cc122b 100644 (file)
@@ -31,5 +31,6 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['language'] = 'भाषा';
+$string['moodlelogo'] = 'Moodle à¤²à¥‹à¤—ो';
 $string['next'] = 'अगला';
 $string['reload'] = 'सीमा à¤¸à¥‡ à¤…धिक à¤²à¤¾à¤¦à¤¨à¤¾';
diff --git a/install/lang/hi_kids/langconfig.php b/install/lang/hi_kids/langconfig.php
new file mode 100644 (file)
index 0000000..af46032
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'hi';
+$string['thislanguage'] = 'हिंदी';
index 9ee6a8b..2571bca 100644 (file)
@@ -462,6 +462,11 @@ $string['debugminimal'] = 'MINIMAL: Show only fatal errors';
 $string['debugnone'] = 'NONE: Do not show any errors or warnings';
 $string['debugnormal'] = 'NORMAL: Show errors, warnings and notices';
 $string['debugpageinfo'] = 'Show page information';
+$string['debugsqltrace'] = 'Show origin of SQL calls';
+$string['debugsqltrace1'] = 'Show only a single calling line';
+$string['debugsqltrace2'] = 'Show 2 lines of stack trace';
+$string['debugsqltrace100'] = 'Show full stack trace';
+$string['debugsqltrace_desc'] = 'If enabled adds either partial or full PHP stacktrace into the SQL as a comment';
 $string['debugstringids'] = 'Show origin of languages strings';
 $string['debugstringids_desc'] = 'If enabled, language string components and identifiers are displayed when ?strings=1 or &strings=1 is appended to the page URL.';
 $string['debugvalidators'] = 'Show validator links';
index 11d5b11..e6a73ae 100644 (file)
@@ -198,6 +198,7 @@ $string['errorupdatinggradecategoryaggregation'] = 'Error updating the aggregati
 $string['errorupdatinggradeitemaggregationcoef'] = 'Error updating the aggregation coefficient (weight or extra credit) of grade item ID {$a->id}';
 $string['eventgradedeleted'] = 'Grade deleted';
 $string['eventgradeitemcreated'] = 'Grade item created';
+$string['eventgradeitemdeleted'] = 'Grade item deleted';
 $string['eventgradeitemupdated'] = 'Grade item updated';
 $string['eventgradelettercreated'] = 'Grade letter created';
 $string['eventgradeletterdeleted'] = 'Grade letter deleted';
index 1a0235c..f687698 100644 (file)
@@ -1201,7 +1201,7 @@ class admin_externalpage implements part_of_admin_tree {
     /** @var string The external URL that we should link to when someone requests this external page. */
     public $url;
 
-    /** @var string The role capability/permission a user must have to access this external page. */
+    /** @var array The role capability/permission a user must have to access this external page. */
     public $req_capability;
 
     /** @var object The context in which capability/permission should be checked, default is site context. */
@@ -1425,7 +1425,7 @@ class admin_settingpage implements part_of_admin_tree {
     /** @var admin_settingdependency[] list of settings to hide when certain conditions are met */
     protected $dependencies = [];
 
-    /** @var string The role capability/permission a user must have to access this external page. */
+    /** @var array The role capability/permission a user must have to access this external page. */
     public $req_capability;
 
     /** @var object The context in which capability/permission should be checked, default is site context. */
diff --git a/lib/amd/build/aria.min.js b/lib/amd/build/aria.min.js
new file mode 100644 (file)
index 0000000..f07ac09
Binary files /dev/null and b/lib/amd/build/aria.min.js differ
diff --git a/lib/amd/build/aria.min.js.map b/lib/amd/build/aria.min.js.map
new file mode 100644 (file)
index 0000000..c6a555d
Binary files /dev/null and b/lib/amd/build/aria.min.js.map differ
index 760a674..c1e1118 100644 (file)
Binary files a/lib/amd/build/drawer.min.js and b/lib/amd/build/drawer.min.js differ
index 82cdd89..c8884f7 100644 (file)
Binary files a/lib/amd/build/drawer.min.js.map and b/lib/amd/build/drawer.min.js.map differ
index 8ef27f0..58ca652 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 77c999b..8ab6a51 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js.map and b/lib/amd/build/form-autocomplete.min.js.map differ
diff --git a/lib/amd/build/local/aria/aria-hidden.min.js b/lib/amd/build/local/aria/aria-hidden.min.js
new file mode 100644 (file)
index 0000000..f549360
Binary files /dev/null and b/lib/amd/build/local/aria/aria-hidden.min.js differ
diff --git a/lib/amd/build/local/aria/aria-hidden.min.js.map b/lib/amd/build/local/aria/aria-hidden.min.js.map
new file mode 100644 (file)
index 0000000..38aa7e2
Binary files /dev/null and b/lib/amd/build/local/aria/aria-hidden.min.js.map differ
index 0791632..29dfd21 100644 (file)
Binary files a/lib/amd/build/local/aria/focuslock.min.js and b/lib/amd/build/local/aria/focuslock.min.js differ
index 81ad9a2..f924b72 100644 (file)
Binary files a/lib/amd/build/local/aria/focuslock.min.js.map and b/lib/amd/build/local/aria/focuslock.min.js.map differ
diff --git a/lib/amd/build/local/aria/selectors.min.js b/lib/amd/build/local/aria/selectors.min.js
new file mode 100644 (file)
index 0000000..2d113d7
Binary files /dev/null and b/lib/amd/build/local/aria/selectors.min.js differ
diff --git a/lib/amd/build/local/aria/selectors.min.js.map b/lib/amd/build/local/aria/selectors.min.js.map
new file mode 100644 (file)
index 0000000..92e6872
Binary files /dev/null and b/lib/amd/build/local/aria/selectors.min.js.map differ
index 69414dc..530e371 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 19c89db..7935256 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
diff --git a/lib/amd/build/normalise.min.js b/lib/amd/build/normalise.min.js
new file mode 100644 (file)
index 0000000..b31b2d0
Binary files /dev/null and b/lib/amd/build/normalise.min.js differ
diff --git a/lib/amd/build/normalise.min.js.map b/lib/amd/build/normalise.min.js.map
new file mode 100644 (file)
index 0000000..c9598b2
Binary files /dev/null and b/lib/amd/build/normalise.min.js.map differ
index 2771c34..30b444e 100644 (file)
Binary files a/lib/amd/build/tooltip.min.js and b/lib/amd/build/tooltip.min.js differ
index 43e02fb..94aa7cb 100644 (file)
Binary files a/lib/amd/build/tooltip.min.js.map and b/lib/amd/build/tooltip.min.js.map differ
index 295ca56..7fadd34 100644 (file)
Binary files a/lib/amd/build/tree.min.js and b/lib/amd/build/tree.min.js differ
index 21b9d89..b7d4e17 100644 (file)
Binary files a/lib/amd/build/tree.min.js.map and b/lib/amd/build/tree.min.js.map differ
diff --git a/lib/amd/src/aria.js b/lib/amd/src/aria.js
new file mode 100644 (file)
index 0000000..394effe
--- /dev/null
@@ -0,0 +1,31 @@
+// 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/>.
+
+/**
+ * Helpers to perform ARIA compliance changes to the DOM.
+ *
+ * @module     core/aria
+ * @class      aria
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+export {
+    hide,
+    unhide,
+    hideSiblings,
+    unhideSiblings,
+} from './local/aria/aria-hidden';
index 3c77606..00742af 100644 (file)
@@ -22,6 +22,7 @@
  */
 import $ from 'jquery';
 import * as PubSub from 'core/pubsub';
+import * as Aria from 'core/aria';
 import DrawerEvents from 'core/drawer_events';
 
 /**
@@ -29,10 +30,13 @@ import DrawerEvents from 'core/drawer_events';
  *
  * @param {Object} root The drawer container.
  */
-const show = (root) => {
+const show = root => {
+    // Ensure that it is a jQuery.
+    root = $(root);
+
+    Aria.unhide(root.get());
     root.removeClass('hidden');
     root.attr('aria-expanded', true);
-    root.removeAttr('aria-hidden');
     root.focus();
 
     PubSub.publish(DrawerEvents.DRAWER_SHOWN, root);
@@ -43,10 +47,14 @@ const show = (root) => {
  *
  * @param {Object} root The drawer container.
  */
-const hide = (root) => {
+const hide = root => {
+    // Ensure that it is a jQuery.
+    root = $(root);
+
     root.addClass('hidden');
     root.attr('aria-expanded', false);
-    root.attr('aria-hidden', true);
+    Aria.hide(root.get());
+
     PubSub.publish(DrawerEvents.DRAWER_HIDDEN, root);
 };
 
index b91c5fb..f90ff66 100644 (file)
@@ -25,8 +25,8 @@
  */
 /* globals require: false */
 define(
-    ['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification', 'core/loadingicon'],
-function($, log, str, templates, notification, LoadingIcon) {
+    ['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification', 'core/loadingicon', 'core/aria'],
+function($, log, str, templates, notification, LoadingIcon, Aria) {
 
     // Private functions and variables.
     /** @var {Object} KEYS - List of keycode constants. */
@@ -200,14 +200,14 @@ function($, log, str, templates, notification, LoadingIcon) {
         var suggestionsElement = $(document.getElementById(state.suggestionsId));
 
         // Count the visible items.
-        var length = suggestionsElement.children('[aria-hidden=false]').length;
+        var length = suggestionsElement.children(':not([aria-hidden])').length;
         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
         index = index % length;
         while (index < 0) {
             index += length;
         }
         // Find the specified element.
-        var element = $(suggestionsElement.children('[aria-hidden=false]').get(index));
+        var element = $(suggestionsElement.children(':not([aria-hidden])').get(index));
         // Find the index of this item in the full list of suggestions (including hidden).
         var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
         // Create an id we can assign to this element.
@@ -244,7 +244,7 @@ function($, log, str, templates, notification, LoadingIcon) {
         // Find the active one.
         var element = suggestionsElement.children('[aria-selected=true]');
         // Find it's index.
-        var current = suggestionsElement.children('[aria-hidden=false]').index(element);
+        var current = suggestionsElement.children(':not([aria-hidden])').index(element);
         // Activate the next one.
         return activateItem(current + 1, state);
     };
@@ -315,7 +315,7 @@ function($, log, str, templates, notification, LoadingIcon) {
         var element = suggestionsElement.children('[aria-selected=true]');
 
         // Find it's index.
-        var current = suggestionsElement.children('[aria-hidden=false]').index(element);
+        var current = suggestionsElement.children(':not([aria-hidden])').index(element);
 
         // Activate the previous one.
         return activateItem(current - 1, state);
@@ -338,7 +338,8 @@ function($, log, str, templates, notification, LoadingIcon) {
         inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId);
 
         // Hide the suggestions list (from screen readers too).
-        suggestionsElement.hide().attr('aria-hidden', true);
+        Aria.hide(suggestionsElement.get());
+        suggestionsElement.hide();
 
         return $.Deferred().resolve();
     };
@@ -385,17 +386,22 @@ function($, log, str, templates, notification, LoadingIcon) {
 
             // Get the element again.
             suggestionsElement = $(document.getElementById(state.suggestionsId));
+
             // Show it if it is hidden.
-            suggestionsElement.show().attr('aria-hidden', false);
+            Aria.unhide(suggestionsElement.get());
+            suggestionsElement.show();
+
             // For each option in the list, hide it if it doesn't match the query.
             suggestionsElement.children().each(function(index, node) {
                 node = $(node);
                 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
                         (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
-                    node.show().attr('aria-hidden', false);
+                    Aria.unhide(node.get());
+                    node.show();
                     matchingElements = true;
                 } else {
-                    node.hide().attr('aria-hidden', true);
+                    node.hide();
+                    Aria.hide(node.get());
                 }
             });
             // If we found any matches, show the list.
@@ -779,7 +785,7 @@ function($, log, str, templates, notification, LoadingIcon) {
             var element = $(e.currentTarget).closest('[role=option]');
             var suggestionsElement = $(document.getElementById(state.suggestionsId));
             // Find the index of the clicked on suggestion.
-            var current = suggestionsElement.children('[aria-hidden=false]').index(element);
+            var current = suggestionsElement.children(':not([aria-hidden])').index(element);
 
             // Activate it.
             activateItem(current, state)
@@ -1020,7 +1026,8 @@ function($, log, str, templates, notification, LoadingIcon) {
                 return false;
             }
 
-            originalSelect.css('visibility', 'hidden').attr('aria-hidden', true);
+            Aria.hide(originalSelect.get());
+            originalSelect.css('visibility', 'hidden');
 
             // Hide the original select.
 
@@ -1105,7 +1112,8 @@ function($, log, str, templates, notification, LoadingIcon) {
 
                 var suggestionsElement = $(document.getElementById(state.suggestionsId));
                 // Hide the suggestions by default.
-                suggestionsElement.hide().attr('aria-hidden', true);
+                suggestionsElement.hide();
+                Aria.hide(suggestionsElement.get());
 
                 return;
             })
diff --git a/lib/amd/src/local/aria/aria-hidden.js b/lib/amd/src/local/aria/aria-hidden.js
new file mode 100644 (file)
index 0000000..a5989a0
--- /dev/null
@@ -0,0 +1,267 @@
+// 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/>.
+
+/**
+ * ARIA helpers related to the aria-hidden attribute.
+ *
+ * @module     core/local/aria/aria-hidden.
+ * @class      aria
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import {getList} from 'core/normalise';
+import Selectors from './selectors';
+
+// The map of MutationObserver objects for an object.
+const childObserverMap = new Map();
+const siblingObserverMap = new Map();
+
+/**
+ * Determine whether the browser supports the MutationObserver system.
+ *
+ * @returns {Bool}
+ */
+const supportsMutationObservers = () => (MutationObserver && typeof MutationObserver === 'function');
+
+/**
+ * Disable element focusability, disabling the tabindex for child elements which are normally focusable.
+ *
+ * @param {HTMLElement} target
+ */
+const disableElementFocusability = target => {
+    if (!(target instanceof HTMLElement)) {
+        // This element is not an HTMLElement.
+        // This can happen for Text Nodes.
+        return;
+    }
+
+    if (target.matches(Selectors.elements.focusable)) {
+        disableAndStoreTabIndex(target);
+    }
+
+    target.querySelectorAll(Selectors.elements.focusable).forEach(disableAndStoreTabIndex);
+};
+
+/**
+ * Remove the current tab-index and store it for later restoration.
+ *
+ * @param {HTMLElement} element
+ */
+const disableAndStoreTabIndex = element => {
+    if (typeof element.dataset.ariaHiddenTabIndex !== 'undefined') {
+        // This child already has a hidden attribute.
+        // Do not modify it as the original value will be lost.
+        return;
+    }
+
+    // Store the old tabindex in a data attribute.
+    if (element.getAttribute('tabindex')) {
+        element.dataset.ariaHiddenTabIndex = element.getAttribute('tabindex');
+    } else {
+        element.dataset.ariaHiddenTabIndex = '';
+    }
+    element.setAttribute('tabindex', -1);
+};
+
+/**
+ * Re-enable element focusability, restoring any tabindex.
+ *
+ * @param {HTMLElement} target
+ */
+const enableElementFocusability = target => {
+    if (!(target instanceof HTMLElement)) {
+        // This element is not an HTMLElement.
+        // This can happen for Text Nodes.
+        return;
+    }
+
+    if (target.matches(Selectors.elements.focusableToUnhide)) {
+        restoreTabIndex(target);
+    }
+
+    target.querySelectorAll(Selectors.elements.focusableToUnhide).forEach(restoreTabIndex);
+};
+
+/**
+ * Restore the tab-index of the supplied element.
+ *
+ * When disabling focusability the current tab-index is stored in the ariaHiddenTabIndex data attribute.
+ * This is used to restore the tab-index, but only whilst the parent nodes remain unhidden.
+ *
+ * @param {HTMLElement} element
+ */
+const restoreTabIndex = element => {
+    if (element.closest(Selectors.aria.hidden)) {
+        // This item still has a hidden parent, or is hidden itself. Do not unhide it.
+        return;
+    }
+
+    const oldTabIndex = element.dataset.ariaHiddenTabIndex;
+    if (oldTabIndex === '') {
+        element.removeAttribute('tabindex');
+    } else {
+        element.setAttribute('tabindex', oldTabIndex);
+    }
+
+    delete element.dataset.ariaHiddenTabIndex;
+};
+
+/**
+ * Update the supplied DOM Module to be hidden.
+ *
+ * @param {HTMLElement} target
+ * @returns {Array}
+ */
+export const hide = target => getList(target).forEach(_hide);
+
+const _hide = target => {
+    if (!(target instanceof HTMLElement)) {
+        // This element is not an HTMLElement.
+        // This can happen for Text Nodes.
+        return;
+    }
+
+    if (target.closest(Selectors.aria.hidden)) {
+        // This Element, or a parent Element, is already hidden.
+        // Stop processing.
+        return;
+    }
+
+    // Set the aria-hidden attribute to true.
+    target.setAttribute('aria-hidden', true);
+
+    // Based on advice from https://dequeuniversity.com/rules/axe/3.3/aria-hidden-focus, upon setting the aria-hidden
+    // attribute, all focusable elements underneath that element should be modified such that they are not focusable.
+    disableElementFocusability(target);
+
+    if (supportsMutationObservers()) {
+        // Add a MutationObserver to check for new children to the tree.
+        const newNodeObserver = new MutationObserver(mutationList => {
+            mutationList.forEach(mutation => {
+                mutation.addedNodes.forEach(disableElementFocusability);
+            });
+        });
+
+        newNodeObserver.observe(target, {childList: true, subtree: true});
+        childObserverMap.set(target, newNodeObserver);
+    }
+};
+
+/**
+ * Reverse the effect of the hide action.
+ *
+ * @param {HTMLElement} target
+ * @returns {Array}
+ */
+export const unhide = target => getList(target).forEach(_unhide);
+
+const _unhide = target => {
+    if (!(target instanceof HTMLElement)) {
+        return;
+    }
+
+    // Note: The aria-hidden attribute should be removed, and not set to false.
+    // The presence of the attribute is sufficient for some browsers to treat it as being true, regardless of its value.
+    target.removeAttribute('aria-hidden');
+
+    // Restore the tabindex across all child nodes of the target.
+    enableElementFocusability(target);
+
+    // Remove the focusability MutationObserver watching this tree.
+    if (childObserverMap.has(target)) {
+        childObserverMap.get(target).disconnect();
+        childObserverMap.delete(target);
+    }
+};
+
+/**
+ * Correctly mark all siblings of the supplied target Element as hidden.
+ *
+ * @param {HTMLElement} target
+ * @returns {Array}
+ */
+export const hideSiblings = target => getList(target).forEach(_hideSiblings);
+
+const _hideSiblings = target => {
+    if (!(target instanceof HTMLElement)) {
+        return;
+    }
+
+    if (!target.parentElement) {
+        return;
+    }
+
+    target.parentElement.childNodes.forEach(node => {
+        if (node === target) {
+            // Skip self;
+            return;
+        }
+
+        hide(node);
+    });
+
+    if (supportsMutationObservers()) {
+        // Add a MutationObserver to check for new children to the tree.
+        const newNodeObserver = new MutationObserver(mutationList => {
+            mutationList.forEach(mutation => {
+                mutation.addedNodes.forEach(node => {
+                    if (target.contains(node)) {
+                        // Skip self, and children of self.
+                        return;
+                    }
+
+                    hide(node);
+                });
+            });
+        });
+
+        newNodeObserver.observe(target.parentElement, {childList: true, subtree: true});
+        siblingObserverMap.set(target.parentElement, newNodeObserver);
+    }
+};
+
+/**
+ * Correctly reverse the hide action of all children of the supplied target Element.
+ *
+ * @param {HTMLElement} target
+ * @returns {Array}
+ */
+export const unhideSiblings = target => getList(target).forEach(_unhideSiblings);
+
+const _unhideSiblings = target => {
+    if (!(target instanceof HTMLElement)) {
+        return;
+    }
+
+    if (!target.parentElement) {
+        return;
+    }
+
+    target.parentElement.childNodes.forEach(node => {
+        if (node === target) {
+            // Skip self;
+            return;
+        }
+
+        unhide(node);
+    });
+
+    // Remove the sibling MutationObserver watching this tree.
+    if (siblingObserverMap.has(target.parentElement)) {
+        siblingObserverMap.get(target.parentElement).disconnect();
+        siblingObserverMap.delete(target.parentElement);
+    }
+};
index c8c1191..c3e5748 100644 (file)
@@ -25,9 +25,7 @@
  * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-const selectors = {
-    focusable: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
-};
+import Selectors from './selectors';
 
 const lockRegionStack = [];
 const initialFocusElementStack = [];
@@ -90,7 +88,7 @@ const focusFirstDescendant = () => {
     // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
     // to capture this.
     // The use of Array.some just ensures that we stop as soon as we have a successful focus.
-    const focusableElements = Array.from(lockRegion.querySelectorAll(selectors.focusable));
+    const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable));
 
     // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
     // We must include it in the calculation of descendants to ensure that looping works correctly.
@@ -111,7 +109,7 @@ const focusLastDescendant = () => {
     // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
     // to capture this.
     // The use of Array.some just ensures that we stop as soon as we have a successful focus.
-    const focusableElements = Array.from(lockRegion.querySelectorAll(selectors.focusable)).reverse();
+    const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse();
 
     // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
     // We must include it in the calculation of descendants to ensure that looping works correctly.
diff --git a/lib/amd/src/local/aria/selectors.js b/lib/amd/src/local/aria/selectors.js
new file mode 100644 (file)
index 0000000..c7a45ef
--- /dev/null
@@ -0,0 +1,33 @@
+// 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/>.
+
+/**
+ * Selectors used for ARIA.
+ *
+ * @module     core/local/aria/selectors
+ * @class      selectors
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+export default {
+    aria: {
+        hidden: '[aria-hidden]',
+    },
+    elements: {
+        focusable: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
+        focusableToUnhide: '[data-aria-hidden-tab-index]',
+    },
+};
index 442c5b0..823adcd 100644 (file)
@@ -33,7 +33,8 @@ define([
     'core/modal_events',
     'core/local/aria/focuslock',
     'core/pending',
-], function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents, FocusLock, Pending) {
+    'core/aria',
+], function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents, FocusLock, Pending, Aria) {
 
     var SELECTORS = {
         CONTAINER: '[data-region="modal-container"]',
@@ -732,30 +733,11 @@ define([
      * @method accessibilityShow
      */
     Modal.prototype.accessibilityShow = function() {
-        // We need to get a list containing each sibling element and the shallowest
-        // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
-        // the fact that this dialogue is always appended to the document body therefore
-        // it's siblings are the shallowest non-ancestral nodes. If that changes then
-        // this code should also be updated.
-        $('body').children().each(function(index, child) {
-            // Skip the current modal.
-            if (!this.root.is(child)) {
-                child = $(child);
-                var hidden = child.attr('aria-hidden');
-                // If they are already hidden we can ignore them.
-                if (hidden !== 'true') {
-                    // Save their current state.
-                    child.data('previous-aria-hidden', hidden);
-                    this.hiddenSiblings.push(child);
-
-                    // Hide this node from screen readers.
-                    child.attr('aria-hidden', 'true');
-                }
-            }
-        }.bind(this));
-
         // Make us visible to screen readers.
-        this.root.attr('aria-hidden', 'false');
+        Aria.unhide(this.root.get());
+
+        // Hide siblings.
+        Aria.hideSiblings(this.root.get()[0]);
     };
 
     /**
@@ -766,24 +748,11 @@ define([
      * @method accessibilityHide
      */
     Modal.prototype.accessibilityHide = function() {
-        this.root.attr('aria-hidden', 'true');
-
-        // Restore the sibling nodes back to their original values.
-        $.each(this.hiddenSiblings, function(index, sibling) {
-            sibling = $(sibling);
-            var previousValue = sibling.data('previous-aria-hidden');
-            // If the element didn't previously have an aria-hidden attribute
-            // then we can just remove the one we set.
-            if (typeof previousValue == 'undefined') {
-                sibling.removeAttr('aria-hidden');
-            } else {
-                // Otherwise set it back to the old value (which will be false).
-                sibling.attr('aria-hidden', previousValue);
-            }
-        });
+        // Unhide siblings.
+        Aria.unhideSiblings(this.root.get()[0]);
 
-        // Clear the cache. No longer need to store these.
-        this.hiddenSiblings = [];
+        // Hide this modal.
+        Aria.hide(this.root.get());
     };
 
     /**
diff --git a/lib/amd/src/normalise.js b/lib/amd/src/normalise.js
new file mode 100644 (file)
index 0000000..e5afcaf
--- /dev/null
@@ -0,0 +1,51 @@
+// 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/>.
+
+/**
+ * Normalisation helpers.
+ *
+ * @module     core/normalise
+ * @class      normalise
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import jQuery from 'jquery';
+
+export const getList = nodes => {
+    if (nodes instanceof HTMLElement) {
+        // A single record to conver to a NodeList.
+        return [nodes];
+    }
+
+    if (nodes instanceof Array) {
+        // A single record to conver to a NodeList.
+        return nodes;
+    }
+
+    if (nodes instanceof NodeList) {
+        // Already a NodeList.
+        return Array.from(nodes);
+    }
+
+    if (nodes instanceof jQuery) {
+        // A jQuery object to a NodeList.
+        return nodes.get();
+    }
+
+    // Fallback to just having a go.
+    return Array.from(nodes);
+};
index 7faaf95..af25345 100644 (file)
@@ -1,4 +1,4 @@
-define(['jquery'], function($) {
+define(['jquery', 'core/aria'], function($, Aria) {
 
     /**
      * Tooltip class.
@@ -50,7 +50,7 @@ define(['jquery'], function($) {
             var tooltipele = $(document.getElementById(tooltipId));
 
             tooltipele.show();
-            tooltipele.attr('aria-hidden', 'false');
+            Aria.unhide(tooltipele);
 
             if (!tooltipele.is('.tooltip')) {
                 // Change the markup to a bootstrap tooltip.
@@ -79,7 +79,7 @@ define(['jquery'], function($) {
             var tooltipele = document.getElementById(tooltipId);
 
             $(tooltipele).hide();
-            $(tooltipele).attr('aria-hidden', 'true');
+            Aria.hide(tooltipele);
         }
     };
 
index 24fd7da..0839895 100644 (file)
@@ -303,7 +303,7 @@ define(['jquery'], function($) {
     Tree.prototype.finishExpandingGroup = function(item) {
         // Expand the group.
         var group = this.getGroupFromItem(item);
-        group.attr('aria-hidden', 'false');
+        group.removeAttr('aria-hidden');
         item.attr('aria-expanded', 'true');
 
         // Update the list of visible items.
index c97f492..b36272b 100644 (file)
@@ -482,12 +482,45 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     }
 
     /**
-     * Returns whether the scenario is running in a browser that can run Javascript or not.
+     * Whether Javascript is available in the current Session.
      *
      * @return boolean
      */
     protected function running_javascript() {
-        return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
+        return self::running_javascript_in_session($this->getSession());
+    }
+
+    /**
+     * Require that javascript be available in the current Session.
+     *
+     * @throws DriverException
+     */
+    protected function require_javascript() {
+        return self::require_javascript_in_session($this->getSession());
+    }
+
+    /**
+     * Whether Javascript is available in the specified Session.
+     *
+     * @param Session $session
+     * @return boolean
+     */
+    protected static function running_javascript_in_session(Session $session): bool {
+        return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
+    }
+
+    /**
+     * Require that javascript be available for the specified Session.
+     *
+     * @param Session $session
+     * @throws DriverException
+     */
+    protected static function require_javascript_in_session(Session $session): void {
+        if (self::running_javascript_in_session($session)) {
+            return;
+        }
+
+        throw new DriverException('Javascript is required');
     }
 
     /**
@@ -502,7 +535,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         }
 
         // Check on page to see if it's an app page. Safest way is to look for added JavaScript.
-        return $this->getSession()->evaluateScript('typeof window.behat') === 'object';
+        return $this->evaluate_script('return typeof window.behat') === 'object';
     }
 
     /**
@@ -738,14 +771,18 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
             // The window inner height will be as specified, which means the available viewport will
             // actually be smaller if there is a horizontal scrollbar. We assume that horizontal
             // scrollbars are rare so this doesn't matter.
-            $offset = $this->getSession()->getDriver()->evaluateScript(
-                    'return (function() { var before = document.body.style.overflowY;' .
-                    'document.body.style.overflowY = "scroll";' .
-                    'var result = {};' .
-                    'result.x = window.outerWidth - document.body.offsetWidth;' .
-                    'result.y = window.outerHeight - window.innerHeight;' .
-                    'document.body.style.overflowY = before;' .
-                    'return result; })();');
+            $js = <<<EOF
+return (function() {
+    var before = document.body.style.overflowY;
+    document.body.style.overflowY = "scroll";
+    var result = {};
+    result.x = window.outerWidth - document.body.offsetWidth;
+    result.y = window.outerHeight - window.innerHeight;
+    document.body.style.overflowY = before;
+    return result;
+})();
+EOF;
+            $offset = $this->evaluate_script($js);
             $width += $offset['x'];
             $height += $offset['y'];
         }
@@ -794,8 +831,8 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                         } else {
                             return "incomplete"
                         }
-                    }());'));
-                $pending = $session->evaluateScript($jscode);
+                    })()'));
+                $pending = self::evaluate_script_in_session($session, $jscode);
             } catch (NoSuchWindow $nsw) {
                 // We catch an exception here, in case we just closed the window we were interacting with.
                 // No javascript is running if there is no window right?
@@ -1206,4 +1243,70 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     public static function get_named_replacements(): array {
         return [];
     }
+
+    /**
+     * Evaluate the supplied script in the current session, returning the result.
+     *
+     * @param string $script
+     * @return mixed
+     */
+    public function evaluate_script(string $script) {
+        return self::evaluate_script_in_session($this->getSession(), $script);
+    }
+
+    /**
+     * Evaluate the supplied script in the specified session, returning the result.
+     *
+     * @param Session $session
+     * @param string $script
+     * @return mixed
+     */
+    public static function evaluate_script_in_session(Session $session, string $script) {
+        self::require_javascript_in_session($session);
+
+        return $session->evaluateScript($script);
+    }
+
+    /**
+     * Execute the supplied script in the current session.
+     *
+     * No result will be returned.
+     *
+     * @param string $script
+     */
+    public function execute_script(string $script): void {
+        self::execute_script_in_session($this->getSession(), $script);
+    }
+
+    /**
+     * Excecute the supplied script in the specified session.
+     *
+     * No result will be returned.
+     *
+     * @param Session $session
+     * @param string $script
+     */
+    public static function execute_script_in_session(Session $session, string $script): void {
+        self::require_javascript_in_session($session);
+
+        $session->executeScript($script);
+    }
+
+    /**
+     * Get the session key for the current session via Javascript.
+     *
+     * @return string
+     */
+    public function get_sesskey(): string {
+        $script = <<<EOF
+return (function() {
+if (M && M.cfg && M.cfg.sesskey) {
+    return M.cfg.sesskey;
+}
+return '';
+})()
+EOF;
+
+        return $this->evaluate_script($script);
+    }
 }
index 8f3ff72..3d5df09 100644 (file)
@@ -52,14 +52,16 @@ class behat_form_editor extends behat_form_textarea {
         if ($this->running_javascript()) {
             $value = addslashes($value);
             $js = '
-var editor = Y.one(document.getElementById("'.$editorid.'editable"));
-if (editor) {
-    editor.setHTML("' . $value . '");
-}
-editor = Y.one(document.getElementById("'.$editorid.'"));
-editor.set("value", "' . $value . '");
+(function() {
+    var editor = Y.one(document.getElementById("'.$editorid.'editable"));
+    if (editor) {
+        editor.setHTML("' . $value . '");
+    }
+    editor = Y.one(document.getElementById("'.$editorid.'"));
+    editor.set("value", "' . $value . '");
+})();
 ';
-            $this->session->executeScript($js);
+            behat_base::execute_script_in_session($this->session, $js);
         } else {
             parent::set_value($value);
         }
@@ -88,7 +90,7 @@ editor.set("value", "' . $value . '");
     r.selectNodeContents(e);
     s.setSingleRange(r);
 }()); ';
-        $this->session->executeScript($js);
+        behat_base::execute_script_in_session($this->session, $js);
     }
 
     /**
index 43cf372..cfc48c2 100644 (file)
@@ -51,12 +51,14 @@ class behat_form_passwordunmask extends behat_form_text {
         if ($this->running_javascript()) {
             $id = $this->field->getAttribute('id');
             $js = <<<JS
-require(["jquery"], function($) {
-    var wrapper = $(document.getElementById("{$id}")).closest('[data-passwordunmask="wrapper"]');
-        wrapper.find('[data-passwordunmask="edit"]').trigger("click");
-});
+(function() {
+    require(["jquery"], function($) {
+        var wrapper = $(document.getElementById("{$id}")).closest('[data-passwordunmask="wrapper"]');
+            wrapper.find('[data-passwordunmask="edit"]').trigger("click");
+    });
+})();
 JS;
-            $this->session->executeScript($js);
+            behat_base::execute_script_in_session($this->session, $js);
         }
 
         $this->field->setValue($value);
diff --git a/lib/classes/event/grade_item_deleted.php b/lib/classes/event/grade_item_deleted.php
new file mode 100644 (file)
index 0000000..484ceb2
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Grade item deleted event.
+ *
+ * @package    core
+ * @copyright  2020 Tom Dickman <tomdickman@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Grade item deleted event class.
+ *
+ * @package    core
+ * @copyright  2020 Tom Dickman <tomdickman@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grade_item_deleted extends grade_item_created {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'grade_items';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventgradeitemdeleted', 'core_grades');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '" . $this->userid . "' deleted a grade item with id '" . $this->objectid . "'" .
+            " of type '" . $this->other['itemtype'] . "' and name '" . $this->other['itemname'] . "'" .
+            " from the course with the id '" . $this->courseid . "'.";
+    }
+
+}
diff --git a/lib/coursecatlib.php b/lib/coursecatlib.php
deleted file mode 100644 (file)
index 2079e4d..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-<?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/>.
-
-/**
- * Deprecated file, classes moved to autoloaded locations
- *
- * @package    core
- * @subpackage course
- * @copyright  2013 Marina Glancy
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-debugging('Class coursecat is now alias to autoloaded class core_course_category, ' .
-    'course_in_list is an alias to core_course_list_element. '.
-    'Class coursecat_sortable_records is deprecated without replacement. Do not include coursecatlib.php',
-    DEBUG_DEVELOPER);
-
-/**
- * An array of records that is sortable by many fields.
- *
- * For more info on the ArrayObject class have a look at php.net.
- *
- * @package    core
- * @subpackage course
- * @copyright  2013 Sam Hemelryk
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class coursecat_sortable_records extends ArrayObject {
-
-    /**
-     * An array of sortable fields.
-     * Gets set temporarily when sort is called.
-     * @var array
-     */
-    protected $sortfields = array();
-
-    /**
-     * Sorts this array using the given fields.
-     *
-     * @param array $records
-     * @param array $fields
-     * @return array
-     */
-    public static function sort(array $records, array $fields) {
-        $records = new coursecat_sortable_records($records);
-        $records->sortfields = $fields;
-        $records->uasort(array($records, 'sort_by_many_fields'));
-        return $records->getArrayCopy();
-    }
-
-    /**
-     * Sorts the two records based upon many fields.
-     *
-     * This method should not be called itself, please call $sort instead.
-     * It has been marked as access private as such.
-     *
-     * @access private
-     * @param stdClass $a
-     * @param stdClass $b
-     * @return int
-     */
-    public function sort_by_many_fields($a, $b) {
-        foreach ($this->sortfields as $field => $mult) {
-            // Nulls first.
-            if (is_null($a->$field) && !is_null($b->$field)) {
-                return -$mult;
-            }
-            if (is_null($b->$field) && !is_null($a->$field)) {
-                return $mult;
-            }
-
-            if (is_string($a->$field) || is_string($b->$field)) {
-                // String fields.
-                if ($cmp = strcoll($a->$field, $b->$field)) {
-                    return $mult * $cmp;
-                }
-            } else {
-                // Int fields.
-                if ($a->$field > $b->$field) {
-                    return $mult;
-                }
-                if ($a->$field < $b->$field) {
-                    return -$mult;
-                }
-            }
-        }
-        return 0;
-    }
-}
index b0ccb2a..a61f5e9 100644 (file)
@@ -2509,5 +2509,35 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020071100.01);
     }
 
+    if ($oldversion < 2020072300.01) {
+        // Restore and set the guest user if it has been previously removed via GDPR, or set to an nonexistent
+        // user account.
+        $currentguestuser = $DB->get_record('user', array('id' => $CFG->siteguest));
+
+        if (!$currentguestuser) {
+            if (!$guest = $DB->get_record('user', array('username' => 'guest', 'mnethostid' => $CFG->mnet_localhost_id))) {
+                // Create a guest user account.
+                $guest = new stdClass();
+                $guest->auth        = 'manual';
+                $guest->username    = 'guest';
+                $guest->password    = hash_internal_user_password('guest');
+                $guest->firstname   = get_string('guestuser');
+                $guest->lastname    = ' ';
+                $guest->email       = 'root@localhost';
+                $guest->description = get_string('guestuserinfo');
+                $guest->mnethostid  = $CFG->mnet_localhost_id;
+                $guest->confirmed   = 1;
+                $guest->lang        = $CFG->lang;
+                $guest->timemodified= time();
+                $guest->id = $DB->insert_record('user', $guest);
+            }
+            // Set the guest user.
+            set_config('siteguest', $guest->id);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020072300.01);
+    }
+
     return true;
 }
index db0a31c..c46ed82 100644 (file)
@@ -889,6 +889,9 @@ abstract class moodle_database {
         // convert table names
         $sql = $this->fix_table_names($sql);
 
+        // Optionally add debug trace to sql as a comment.
+        $sql = $this->add_sql_debugging($sql);
+
         // cast booleans to 1/0 int and detect forbidden objects
         foreach ($params as $key => $value) {
             $this->detect_objects($value);
@@ -1030,6 +1033,50 @@ abstract class moodle_database {
         }
     }
 
+    /**
+     * Add an SQL comment to trace all sql calls back to the calling php code
+     * @param string $sql Original sql
+     * @return string Instrumented sql
+     */
+    protected function add_sql_debugging(string $sql): string {
+        global $CFG;
+
+        if (!property_exists($CFG, 'debugsqltrace')) {
+            return $sql;
+        }
+
+        $level = $CFG->debugsqltrace;
+
+        if (empty($level)) {
+            return $sql;
+        }
+
+        $callers = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
+
+        // Ignore moodle_database internals.
+        $callers = array_filter($callers, function($caller) {
+            return empty($caller['class']) || $caller['class'] != 'moodle_database';
+        });
+
+        $callers = array_slice($callers, 0, $level);
+
+        $text = trim(format_backtrace($callers, true));
+
+        // Convert all linebreaks to SQL comments, optionally
+        // also eating any * formatting.
+        $text = preg_replace("/(^|\n)\*?\s*/", "\n-- ", $text);
+
+        // Convert all ? to 'unknown' in the sql coment so these don't get
+        // caught by fix_sql_params().
+        $text = str_replace('?', 'unknown', $text);
+
+        // Convert tokens like :test to ::test for the same reason.
+        $text = preg_replace('/(?<!:):[a-z][a-z0-9_]*/', ':\0', $text);
+
+        return $sql . $text;
+    }
+
+
     /**
      * Ensures that limit params are numeric and positive integers, to be passed to the database.
      * We explicitly treat null, '' and -1 as 0 in order to provide compatibility with how limit
index c1fe0f8..c244a11 100644 (file)
@@ -438,6 +438,55 @@ class core_dml_testcase extends database_driver_testcase {
         $this->assertSame(array_values($params), array_values($inparams));
     }
 
+    /**
+     * Test the database debugging as SQL comment.
+     */
+    public function test_add_sql_debugging() {
+        global $CFG;
+        $DB = $this->tdb;
+
+        require_once($CFG->dirroot . '/lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php');
+        $fixture = new test_dml_sql_debugging_fixture($this);
+
+        $sql = "SELECT * FROM {users}";
+
+        $out = $fixture->four($sql);
+
+        $CFG->debugsqltrace = 0;
+        $this->assertEquals("SELECT * FROM {users}", $out);
+
+        $CFG->debugsqltrace = 1;
+        $out = $fixture->four($sql);
+        $expected = <<<EOD
+SELECT * FROM {users}
+-- line 65 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to ReflectionMethod->invoke()
+EOD;
+        $this->assertEquals($expected, $out);
+
+        $CFG->debugsqltrace = 2;
+        $out = $fixture->four($sql);
+        $expected = <<<EOD
+SELECT * FROM {users}
+-- line 65 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to ReflectionMethod->invoke()
+-- line 74 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to test_dml_sql_debugging_fixture->one()
+EOD;
+        $this->assertEquals($expected, $out);
+
+        $CFG->debugsqltrace = 5;
+        $out = $fixture->four($sql);
+        $expected = <<<EOD
+SELECT * FROM {users}
+-- line 65 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to ReflectionMethod->invoke()
+-- line 74 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to test_dml_sql_debugging_fixture->one()
+-- line 83 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to test_dml_sql_debugging_fixture->two()
+-- line 92 of /lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php: call to test_dml_sql_debugging_fixture->three()
+-- line 476 of /lib/dml/tests/dml_test.php: call to test_dml_sql_debugging_fixture->four()
+EOD;
+        $this->assertEquals($expected, $out);
+
+        $CFG->debugsqltrace = 0;
+    }
+
     public function test_strtok() {
         // Strtok was previously used by bound emulation, make sure it is not used any more.
         $DB = $this->tdb;
diff --git a/lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php b/lib/dml/tests/fixtures/test_dml_sql_debugging_fixture.php
new file mode 100644 (file)
index 0000000..2aba9de
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test SQL debugging fixture
+ *
+ * @package    core
+ * @category   dml
+ * @copyright  2020 Brendan Heywood <brendan@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test SQL debugging fixture
+ *
+ * @package    core
+ * @category   dml
+ * @copyright  2020 Brendan Heywood <brendan@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_dml_sql_debugging_fixture {
+    /** @var db handle */
+    private $db;
+
+    /**
+     * constructor
+     * @param testcase $testcase test object
+     */
+    public function __construct($testcase) {
+        $this->db = $testcase->getMockBuilder(\moodle_database::class)
+            ->getMockForAbstractClass();
+    }
+
+    /**
+     * Get db handle
+     * @return a db handle
+     */
+    public function get_mock() {
+        return $this->db;
+    }
+
+    /**
+     * Test caller in stacktrace
+     * @param string $sql original sql
+     * @return string sql with comments
+     */
+    public function one(string $sql) {
+        $method = new \ReflectionMethod($this->db, 'add_sql_debugging');
+        $method->setAccessible(true);
+        return $method->invoke($this->db, $sql);
+    }
+
+    /**
+     * Test caller in stacktrace
+     * @param string $sql original sql
+     * @return string sql with comments
+     */
+    public function two(string $sql) {
+        return $this->one($sql);
+    }
+
+    /**
+     * Test caller in stacktrace
+     * @param string $sql original sql
+     * @return string sql with comments
+     */
+    public function three(string $sql) {
+        return $this->two($sql);
+    }
+
+    /**
+     * Test caller in stacktrace
+     * @param string $sql original sql
+     * @return string sql with comments
+     */
+    public function four(string $sql) {
+        return $this->three($sql);
+    }
+
+}
index 26d283f..772f789 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js differ
index 15da779..f3ef511 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js differ
index 6a0b3c8..707fb72 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js differ
index bd67621..0c6c6af 100644 (file)
@@ -32,7 +32,7 @@
  */
 
 var CSS = {
-        RESPONSIVE: 'img-responsive',
+        RESPONSIVE: 'img-fluid',
         INPUTALIGNMENT: 'atto_image_alignment',
         INPUTALT: 'atto_image_altentry',
         INPUTHEIGHT: 'atto_image_heightentry',
@@ -424,7 +424,8 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
                         // Replace placeholder with actual image.
                         newhtml = template({
                             url: file.url,
-                            presentation: true
+                            presentation: true,
+                            classlist: CSS.RESPONSIVE
                         });
                         newimage = Y.Node.create(newhtml);
                         if (placeholder) {
index 1f21f76..511eb8b 100644 (file)
@@ -413,6 +413,12 @@ class grade_item extends grade_object {
         $this->delete_all_grades($source);
         $success = parent::delete($source);
         $transaction->allow_commit();
+
+        if ($success) {
+            $event = \core\event\grade_item_deleted::create_from_grade_item($this);
+            $event->trigger();
+        }
+
         return $success;
     }
 
index 11ddc73..457bab1 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 089870b..e2275f1 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index 899809b..a0246dd 100644 (file)
@@ -336,12 +336,13 @@ export const getLastInitial = tableRoot => getTableData(tableRoot).tableLastInit
  * @param {HTMLElement} tableRoot
  * @param {String} columnToHide
  * @param {Bool} refreshContent
+ * @returns {Promise}
  */
 export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
     const hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
     hiddenColumns.push(columnToHide);
 
-    updateTable(tableRoot, {hiddenColumns}, refreshContent);
+    return updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
 /**
@@ -350,12 +351,13 @@ export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
  * @param {HTMLElement} tableRoot
  * @param {String} columnToShow
  * @param {Bool} refreshContent
+ * @returns {Promise}
  */
 export const showColumn = (tableRoot, columnToShow, refreshContent = true) => {
     let hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
     hiddenColumns = hiddenColumns.filter(columnName => columnName !== columnToShow);
 
-    updateTable(tableRoot, {hiddenColumns}, refreshContent);
+    return updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
 /**
index 78bade9..63510a6 100644 (file)
@@ -1444,23 +1444,19 @@ class flexible_table {
             }
         }
 
-        // Now, update the column attributes for collapsed columns
-        foreach (array_keys($this->columns) as $column) {
-            if (!empty($this->prefs['collapse'][$column])) {
-                $this->column_style[$column]['width'] = '10px';
-            }
-        }
+        $this->set_hide_show_preferences();
+        $this->set_sorting_preferences();
+        $this->set_initials_preferences();
 
-        // Now, update the column attributes for collapsed columns
+        // Now, reduce the width of collapsed columns and remove the width from columns that should be expanded.
         foreach (array_keys($this->columns) as $column) {
             if (!empty($this->prefs['collapse'][$column])) {
                 $this->column_style[$column]['width'] = '10px';
+            } else {
+                unset($this->column_style[$column]['width']);
             }
         }
 
-        $this->set_sorting_preferences();
-        $this->set_initials_preferences();
-
         if (empty($this->baseurl)) {
             debugging('You should set baseurl when using flexible_table.');
             global $PAGE;
index 56a793c..be66847 100644 (file)
@@ -1,3 +1,54 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/paging_bar
+
+    This template renders the horizontal bar with page links, e.g.
+        | Â« | 1 | 2 | 3 | Â» |
+
+    Example context (json):
+    {
+        "previous": null,
+        "next": {
+            "page": 2,
+            "url": "./page.php?p=1"
+        },
+        "first": null,
+        "last": {
+            "page": 100,
+            "url": "./page.php?p=99"
+        },
+        "label": "Page",
+        "pages": [
+            {
+                "page": 1,
+                "active": true,
+                "url": null
+            },
+            {
+                "page": 2,
+                "active": false,
+                "url": "./page.php?p=1"
+            }
+        ],
+        "haspages": true,
+        "pagesize": 10
+    }
+}}
 {{#haspages}}
     <nav aria-label="{{label}}" class="pagination pagination-centered justify-content-center">
         <ul class="mt-1 pagination " data-page-size="{{pagesize}}">
@@ -14,7 +65,7 @@
                     <a href="{{url}}" class="page-link">{{page}}</a>
                 </li>
                 <li class="page-item disabled" data-page-number="{{page}}">
-                    <span class="page-link">&hellip;</a>
+                    <span class="page-link">&hellip;</span>
                 </li>
             {{/first}}
             {{#pages}}
@@ -29,7 +80,7 @@
             {{/pages}}
             {{#last}}
                 <li class="page-item disabled" data-page-number="{{page}}">
-                    <span class="page-link">&hellip;</a>
+                    <span class="page-link">&hellip;</span>
                 </li>
                 <li class="page-item" data-page-number="{{page}}">
                     <a href="{{url}}" class="page-link">{{page}}</a>
index af82844..ce9f392 100644 (file)
@@ -283,7 +283,7 @@ class behat_app extends behat_base {
         global $CFG;
 
         // Visit the Ionic URL and wait for it to load.
-        $this->getSession()->visit($url);
+        $this->execute('behat_general::i_visit', [$url]);
         $this->spin(
                 function($context, $args) {
                     $title = $context->getSession()->getPage()->find('xpath', '//title');
@@ -297,8 +297,7 @@ class behat_app extends behat_base {
                 }, false, 60);
 
         // Run the scripts to install Moodle 'pending' checks.
-        $this->getSession()->executeScript(
-                file_get_contents(__DIR__ . '/app_behat_runtime.js'));
+        $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js'));
 
         // Wait until the site login field appears OR the main page.
         $situation = $this->spin(
@@ -373,8 +372,7 @@ class behat_app extends behat_base {
      */
     public function i_press_the_standard_button_in_the_app(string $button) {
         $this->spin(function($context, $args) use ($button) {
-            $result = $this->getSession()->evaluateScript('return window.behat.pressStandard("' .
-                    $button . '");');
+            $result = $this->evaluate_script("return window.behat.pressStandard('{$button}');");
             if ($result !== 'OK') {
                 throw new DriverException('Error pressing standard button - ' . $result);
             }
@@ -391,7 +389,7 @@ class behat_app extends behat_base {
      */
     public function i_close_the_popup_in_the_app() {
         $this->spin(function($context, $args)  {
-            $result = $this->getSession()->evaluateScript('return window.behat.closePopup();');
+            $result = $this->evaluate_script("return window.behat.closePopup();");
             if ($result !== 'OK') {
                 throw new DriverException('Error closing popup - ' . $result);
             }
@@ -449,7 +447,7 @@ class behat_app extends behat_base {
             } else {
                 $nearbit = '';
             }
-            $result = $context->getSession()->evaluateScript('return window.behat.press("' .
+            $result = $this->evaluate_script('return window.behat.press("' .
                     addslashes_js($text) . '"' . $nearbit .');');
             if ($result !== 'OK') {
                 throw new DriverException('Error pressing item - ' . $result);
@@ -472,7 +470,7 @@ class behat_app extends behat_base {
      */
     public function i_set_the_field_in_the_app(string $field, string $value) {
         $this->spin(function($context, $args) use ($field, $value) {
-            $result = $this->getSession()->evaluateScript('return window.behat.setField("' .
+            $result = $this->evaluate_script('return window.behat.setField("' .
                     addslashes_js($field) . '", "' . addslashes_js($value) . '");');
             if ($result !== 'OK') {
                 throw new DriverException('Error setting field - ' . $result);
@@ -494,7 +492,7 @@ class behat_app extends behat_base {
      */
     public function the_header_should_be_in_the_app(string $text) {
         $result = $this->spin(function($context, $args) {
-            $result = $this->getSession()->evaluateScript('return window.behat.getHeader();');
+            $result = $this->evaluate_script('return window.behat.getHeader();');
             if (substr($result, 0, 3) !== 'OK:') {
                 throw new DriverException('Error getting header - ' . $result);
             }
@@ -536,7 +534,7 @@ class behat_app extends behat_base {
         if (count($names) !== 2) {
             throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
         }
-        $this->getSession()->getDriver()->executeScript('window.close()');
+        $this->execute_script('window.close()');
         $this->getSession()->switchToWindow($names[0]);
     }
 
@@ -548,6 +546,6 @@ class behat_app extends behat_base {
      * @throws DriverException If the navigator.online mode is not available
      */
     public function i_switch_offline_mode(string $offline) {
-        $this->getSession()->evaluateScript('appProvider.setForceOffline(' . $offline . ');');
+        $this->execute_script('appProvider.setForceOffline(' . $offline . ');');
     }
 }
index d81686e..2532f94 100644 (file)
 require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
 require_once(__DIR__ . '/../../../lib/behat/behat_field_manager.php');
 
-use Behat\Gherkin\Node\TableNode as TableNode,
-    Behat\Gherkin\Node\PyStringNode as PyStringNode,
-    Behat\Mink\Exception\ExpectationException as ExpectationException,
-    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+use Behat\Gherkin\Node\{TableNode, PyStringNode};
+use Behat\Mink\Element\NodeElement;
+use Behat\Mink\Exception\{ElementNotFoundException, ExpectationException};
 
 /**
  * Forms-related steps definitions.
@@ -286,10 +285,7 @@ class behat_forms extends behat_base {
      * @return void
      */
     public function i_set_the_field_with_xpath_to($fieldxpath, $value) {
-        $fieldnode = $this->find('xpath', $fieldxpath);
-        $this->ensure_node_is_visible($fieldnode);
-        $field = behat_field_manager::get_form_field($fieldnode, $this->getSession());
-        $field->set_value($value);
+        $this->set_field_node_value($this->find('xpath', $fieldxpath), $value);
     }
 
     /**
@@ -627,13 +623,24 @@ class behat_forms extends behat_base {
      * @return void
      */
     protected function set_field_value($fieldlocator, $value) {
-
         // We delegate to behat_form_field class, it will
         // guess the type properly as it is a select tag.
         $field = behat_field_manager::get_form_field_from_label($fieldlocator, $this);
         $field->set_value($value);
     }
 
+    /**
+     * Generic field setter to be used by chainable steps.
+     *
+     * @param NodeElement $fieldnode
+     * @param string $value
+     */
+    public function set_field_node_value(NodeElement $fieldnode, string $value): void {
+        $this->ensure_node_is_visible($fieldnode);
+        $field = behat_field_manager::get_form_field($fieldnode, $this->getSession());
+        $field->set_value($value);
+    }
+
     /**
      * Generic field setter.
      *
@@ -646,12 +653,8 @@ class behat_forms extends behat_base {
      * @param string $containerelement Element we look in
      */
     protected function set_field_value_in_container($fieldlocator, $value, $containerselectortype, $containerelement) {
-
         $node = $this->get_node_in_container('field', $fieldlocator, $containerselectortype, $containerelement);
-        // We delegate to behat_form_field class, it will
-        // guess the type properly as it is a select tag.
-        $field = behat_field_manager::get_form_field($node, $this->getSession());
-        $field->set_value($value);
+        $this->set_field_node_value($node, $value);
     }
 
     /**
index ee6175a..49b2fdd 100644 (file)
@@ -74,7 +74,7 @@ class behat_general extends behat_base {
      * @Given /^I am on homepage$/
      */
     public function i_am_on_homepage() {
-        $this->getSession()->visit($this->locate_path('/'));
+        $this->execute('behat_general::i_visit', ['/']);
     }
 
     /**
@@ -83,7 +83,7 @@ class behat_general extends behat_base {
      * @Given /^I am on site homepage$/
      */
     public function i_am_on_site_homepage() {
-        $this->getSession()->visit($this->locate_path('/?redirect=0'));
+        $this->execute('behat_general::i_visit', ['/?redirect=0']);
     }
 
     /**
@@ -92,7 +92,7 @@ class behat_general extends behat_base {
      * @Given /^I am on course index$/
      */
     public function i_am_on_course_index() {
-        $this->getSession()->visit($this->locate_path('/course/index.php'));
+        $this->execute('behat_general::i_visit', ['/course/index.php']);
     }
 
     /**
@@ -228,8 +228,7 @@ class behat_general extends behat_base {
         // unnamed window (presumably the main window) to some other named
         // window, then we first set the main window name to a conventional
         // value that we can later use this name to switch back.
-        $this->getSession()->executeScript(
-                'if (window.name == "") window.name = "' . self::MAIN_WINDOW_NAME . '"');
+        $this->execute_script('if (window.name == "") window.name = "' . self::MAIN_WINDOW_NAME . '"');
 
         $this->getSession()->switchToWindow($windowname);
     }
@@ -258,7 +257,7 @@ class behat_general extends behat_base {
         $names = $this->getSession()->getWindowNames();
         for ($index = 1; $index < count($names); $index ++) {
             $this->getSession()->switchToWindow($names[$index]);
-            $this->getSession()->executeScript("window.open('', '_self').close();");
+            $this->execute_script("window.open('', '_self').close();");
         }
         $names = $this->getSession()->getWindowNames();
         if (count($names) !== 1) {
@@ -924,7 +923,7 @@ class behat_general extends behat_base {
     return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING;
 })()
 EOF;
-            $ok = $this->getSession()->getDriver()->evaluateScript($js);
+            $ok = $this->evaluate_script($js);
         } else {
 
             // Using following xpath axe to find it.
@@ -1058,7 +1057,7 @@ EOF;
      * @Given /^I trigger cron$/
      */
     public function i_trigger_cron() {
-        $this->getSession()->visit($this->locate_path('/admin/cron.php'));
+        $this->execute('behat_general::i_visit', ['/admin/cron.php']);
     }
 
     /**
@@ -1609,11 +1608,12 @@ EOF;
 
         $this->pageloaddetectionrunning = true;
 
-        $session->executeScript(
-                'var span = document.createElement("span");
-                span.setAttribute("data-rel", "' . self::PAGE_LOAD_DETECTION_STRING . '");
-                span.setAttribute("style", "display: none;");
-                document.body.appendChild(span);');
+        $this->execute_script(
+            'var span = document.createElement("span");
+            span.setAttribute("data-rel", "' . self::PAGE_LOAD_DETECTION_STRING . '");
+            span.setAttribute("style", "display: none;");
+            document.body.appendChild(span);'
+        );
     }
 
     /**
@@ -1811,7 +1811,7 @@ EOF;
         $xpath = addslashes_js($element->getXpath());
         $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
                 document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
-        $targetisfocused = $this->getSession()->evaluateScript($script);
+        $targetisfocused = $this->evaluate_script($script);
         if ($not == ' not') {
             if ($targetisfocused) {
                 throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
@@ -1843,7 +1843,7 @@ EOF;
         $xpath = addslashes_js($element->getXpath());
         $script = 'return (function() { return document.activeElement === document.evaluate("' . $xpath . '",
                 document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; })(); ';
-        $targetisfocused = $this->getSession()->evaluateScript($script);
+        $targetisfocused = $this->evaluate_script($script);
         if ($not == ' not') {
             if ($targetisfocused) {
                 throw new ExpectationException("$nodeelement $nodeselectortype is focused", $this->getSession());
@@ -1937,4 +1937,16 @@ EOF;
         $value = [\WebDriver\Key::ENTER];
         $this->getSession()->getDriver()->getWebDriverSession()->activeElement()->postValue(['value' => $value]);
     }
+
+    /**
+     * Visit a local URL relative to the behat root.
+     *
+     * @When I visit :localurl
+     *
+     * @param string|moodle_url $localurl The URL relative to the behat_wwwroot to visit.
+     */
+    public function i_visit($localurl): void {
+        $localurl = new moodle_url($localurl);
+        $this->getSession()->visit($this->locate_path($localurl->out_as_local_url(false)));
+    }
 }
index 1ffcb14..1e8e1e8 100644 (file)
@@ -86,6 +86,17 @@ class behat_hooks extends behat_base {
      */
     protected static $currentstepexception = null;
 
+    /**
+     * If an Exception is thrown in the BeforeScenario hook it will cause the Scenario to be skipped, and the exit code
+     * to be non-zero triggering a potential rerun.
+     *
+     * To combat this the exception is stored and re-thrown when looking for exceptions.
+     * This allows the test to instead be failed and re-run correctly.
+     *
+     * @var null|Exception
+     */
+    protected static $currentscenarioexception = null;
+
     /**
      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
      *
@@ -173,7 +184,7 @@ class behat_hooks extends behat_base {
             $message = <<<EOF
 Your behat test site is outdated, please run the following command from your Moodle dirroot to drop, and reinstall the Behat test site.
 
-    {$comandpath}
+    {$commandpath}
 
 EOF;
             self::log_and_stop($message);
@@ -362,8 +373,13 @@ EOF;
             // The `before_first_scenario_start_session` function will have started the session instead.
             return;
         }
+        self::$currentscenarioexception = null;
 
-        $this->restart_session();
+        try {
+            $this->restart_session();
+        } catch (Exception $e) {
+            self::$currentscenarioexception = $e;
+        }
     }
 
     /**
@@ -374,6 +390,12 @@ EOF;
      */
     public function before_scenario_hook(BeforeScenarioScope $scope) {
         global $DB;
+        if (self::$currentscenarioexception) {
+            // A BeforeScenario hook triggered an exception and marked this test as failed.
+            // Skip this hook as it will likely fail.
+            return;
+        }
+
         $suitename = $scope->getSuite()->getName();
 
         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
@@ -474,7 +496,7 @@ EOF;
             // Again, this would be better in the BeforeSuite hook, but that does not have access to the selectors in
             // order to perform the necessary searches.
             $session = $this->getSession();
-            $session->visit($this->locate_path('/'));
+            $this->execute('behat_general::i_visit', ['/']);
 
             // Checking that the root path is a Moodle test site.
             if (self::is_first_scenario()) {
@@ -526,6 +548,12 @@ EOF;
      * @BeforeStep
      */
     public function before_step_javascript(BeforeStepScope $scope) {
+        if (self::$currentscenarioexception) {
+            // A BeforeScenario hook triggered an exception and marked this test as failed.
+            // Skip this hook as it will likely fail.
+            return;
+        }
+
         self::$currentstepexception = null;
 
         // Only run if JS.
@@ -742,6 +770,11 @@ EOF;
      * @see Moodle\BehatExtension\EventDispatcher\Tester\ChainedStepTester
      */
     public function i_look_for_exceptions() {
+        // If the scenario already failed in a hook throw the exception.
+        if (!is_null(self::$currentscenarioexception)) {
+            throw self::$currentscenarioexception;
+        }
+
         // If the step already failed in a hook throw the exception.
         if (!is_null(self::$currentstepexception)) {
             throw self::$currentstepexception;
index 052fe30..7acc898 100644 (file)
@@ -391,7 +391,7 @@ class behat_navigation extends behat_base {
         )";
 
         // Adding an extra click we need to show the 'Log in' link.
-        if (!$this->getSession()->getDriver()->evaluateScript($navbuttonjs)) {
+        if (!$this->evaluate_script($navbuttonjs)) {
             return false;
         }
 
@@ -526,7 +526,7 @@ class behat_navigation extends behat_base {
 
                 }
             }
-            $this->getSession()->visit($this->locate_path($url->out_as_local_url()));
+            $this->execute('behat_general::i_visit', [$url]);
         }
 
         // Restore global user variable.
@@ -549,8 +549,7 @@ class behat_navigation extends behat_base {
      * @throws Exception if the specified page cannot be determined.
      */
     public function i_am_on_page(string $page) {
-        $this->getSession()->visit($this->locate_path(
-                $this->resolve_page_helper($page)->out_as_local_url()));
+        $this->execute('behat_general::i_visit', [$this->resolve_page_helper($page)]);
     }
 
     /**
@@ -629,8 +628,7 @@ class behat_navigation extends behat_base {
      * @throws Exception if the specified page cannot be determined.
      */
     public function i_am_on_page_instance(string $identifier, string $type) {
-        $this->getSession()->visit($this->locate_path(
-                $this->resolve_page_instance_helper($identifier, $type)->out_as_local_url()));
+        $this->execute('behat_general::i_visit', [$this->resolve_page_instance_helper($identifier, $type)]);
     }
 
     /**
@@ -753,11 +751,11 @@ class behat_navigation extends behat_base {
         global $DB;
         $course = $DB->get_record("course", array("fullname" => $coursefullname), 'id', MUST_EXIST);
         $url = new moodle_url('/course/view.php', ['id' => $course->id]);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 
     /**
-     * Opens the course homepage with editing mode on.
+     * Open the course homepage with editing mode enabled.
      *
      * @Given /^I am on "(?P<coursefullname_string>(?:[^"]|\\")*)" course homepage with editing mode on$/
      * @throws coding_exception
@@ -766,9 +764,22 @@ class behat_navigation extends behat_base {
      */
     public function i_am_on_course_homepage_with_editing_mode_on($coursefullname) {
         global $DB;
+
         $course = $DB->get_record("course", array("fullname" => $coursefullname), 'id', MUST_EXIST);
         $url = new moodle_url('/course/view.php', ['id' => $course->id]);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+
+        if ($this->running_javascript() && $sesskey = $this->get_sesskey()) {
+            // Javascript is running so it is possible to grab the session ket and jump straight to editing mode.
+            $url->param('edit', 1);
+            $url->param('sesskey', $sesskey);
+            $this->execute('behat_general::i_visit', [$url]);
+
+            return;
+        }
+
+        // Visit the course page.
+        $this->execute('behat_general::i_visit', [$url]);
+
         try {
             $this->execute("behat_forms::press_button", get_string('turneditingon'));
         } catch (Exception $e) {
@@ -990,6 +1001,6 @@ class behat_navigation extends behat_base {
         if (!preg_match($fixtureregex, $url)) {
             throw new coding_exception("URL {$url} is not a fixture URL");
         }
-        $this->getSession()->visit($this->locate_path($url));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
diff --git a/lib/tests/event/grade_item_deleted_test.php b/lib/tests/event/grade_item_deleted_test.php
new file mode 100644 (file)
index 0000000..baf7250
--- /dev/null
@@ -0,0 +1,76 @@
+<?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/>.
+
+/**
+ * Grade item deleted event tests.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Tom Dickman <tomdickman@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Test for grade item deleted event.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Tom Dickman <tomdickman@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core\event\grade_item_deleted
+ */
+class grade_item_deleted_testcase extends \advanced_testcase {
+
+    /**
+     * Test the grade item deleted event.
+     *
+     * @covers ::create_from_grade_item
+     */
+    public function test_grade_item_deleted() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $gradeitemrecord = $this->getDataGenerator()->create_grade_item(['courseid' => $course->id]);
+        $gradeitem = \grade_item::fetch(['id' => $gradeitemrecord->id, 'courseid' => $course->id]);
+
+        $countgradeitems = $DB->count_records('grade_items');
+
+        // Trigger and capture the event for deleting a grade item.
+        $sink = $this->redirectEvents();
+        $gradeitem->delete();
+        $events = $sink->get_events();
+        $sink->close();
+
+        // Event should only be triggered once.
+        $this->assertCount(1, $events);
+        $event = reset($events);
+
+        // Expect that the grade item was deleted and the event data is valid.
+        $this->assertEquals($countgradeitems - 1, $DB->count_records('grade_items'));
+        $this->assertInstanceOf('\core\event\grade_item_deleted', $event);
+        $eventdata = $event->get_data();
+        $this->assertEquals($gradeitem->id, $eventdata['objectid']);
+        $this->assertEquals($gradeitem->courseid, $eventdata['courseid']);
+        $this->assertEquals(\context_course::instance($gradeitem->courseid)->id, $eventdata['contextid']);
+        $this->assertEquals($gradeitem->itemname, $eventdata['other']['itemname']);
+        $this->assertEquals($gradeitem->itemtype, $eventdata['other']['itemtype']);
+        $this->assertEquals($gradeitem->itemmodule, $eventdata['other']['itemmodule']);
+    }
+}
index 76e3577..014bcbc 100644 (file)
@@ -31,6 +31,7 @@ information provided here is intended especially for developers.
 * The form element 'htmleditor', which was deprecated in 3.6, has been removed.
 * The `core_output_load_fontawesome_icon_map` web service has been deprecated and replaced by
   `core_output_load_fontawesome_icon_system_map` which takes the name of the theme to generate the icon system map for.
+* The class coursecat_sortable_records has been removed.
 
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
index 7f66560..0966e92 100644 (file)
@@ -198,7 +198,8 @@ if ($frm and isset($frm->username)) {                             // Login WITH
                 [
                     'username' => $frm->username,
                     'password' => $frm->password,
-                    'resendconfirmemail' => true
+                    'resendconfirmemail' => true,
+                    'logintoken' => \core\session\manager::get_login_token()
                 ]
             );
             echo $OUTPUT->single_button($resendconfirmurl, get_string('emailconfirmationresend'));
index 01a224c..c3c42e4 100644 (file)
Binary files a/message/amd/build/message_drawer_router.min.js and b/message/amd/build/message_drawer_router.min.js differ
index 47cc497..e2f106e 100644 (file)
Binary files a/message/amd/build/message_drawer_router.min.js.map and b/message/amd/build/message_drawer_router.min.js.map differ
index 6d35cae..9c3f4f9 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js and b/message/amd/build/message_drawer_view_conversation_renderer.min.js differ
index 6bd9db1..0248160 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js.map and b/message/amd/build/message_drawer_view_conversation_renderer.min.js.map differ
index f5db00f..119e081 100644 (file)
@@ -29,13 +29,15 @@ define(
     'jquery',
     'core/pubsub',
     'core/str',
-    'core_message/message_drawer_events'
+    'core_message/message_drawer_events',
+    'core/aria',
 ],
 function(
     $,
     PubSub,
     Str,
-    MessageDrawerEvents
+    MessageDrawerEvents,
+    Aria
 ) {
 
     /* @var {object} routes Message drawer route elements and callbacks. */
@@ -112,15 +114,15 @@ function(
                         element.attr('data-from-panel', true);
                     }
                     element.removeClass('hidden');
-                    element.attr('aria-hidden', false);
+                    Aria.unhide(element.get());
                 } else {
                     // For the message index page elements in the left panel should not be hidden.
                     if (!element.attr('data-in-panel')) {
                         element.addClass('hidden');
-                        element.attr('aria-hidden', true);
+                        Aria.hide(element.get());
                     } else if (newRoute == 'view-search' || newRoute == 'view-overview') {
                         element.addClass('hidden');
-                        element.attr('aria-hidden', true);
+                        Aria.hide(element.get());
                     }
                 }
             });
index 3f24f26..ed6d1b7 100644 (file)
@@ -33,7 +33,8 @@ define(
     'core/str',
     'core/templates',
     'core/user_date',
-    'core_message/message_drawer_view_conversation_constants'
+    'core_message/message_drawer_view_conversation_constants',
+    'core/aria',
 ],
 function(
     $,
@@ -41,7 +42,8 @@ function(
     Str,
     Templates,
     UserDate,
-    Constants
+    Constants,
+    Aria
 ) {
     var SELECTORS = Constants.SELECTORS;
     var TEMPLATES = Constants.TEMPLATES;
@@ -510,8 +512,7 @@ function(
     var showConfirmDialogueContainer = function(root) {
         var container = getConfirmDialogueContainer(root);
         var siblings = container.siblings(':not(.hidden)');
-        siblings.attr('aria-hidden', true);
-        siblings.attr('tabindex', -1);
+        Aria.hide(siblings.get());
         siblings.attr('data-confirm-dialogue-hidden', true);
 
         container.removeClass('hidden');
@@ -525,8 +526,7 @@ function(
     var hideConfirmDialogueContainer = function(root) {
         var container = getConfirmDialogueContainer(root);
         var siblings = container.siblings('[data-confirm-dialogue-hidden="true"]');
-        siblings.removeAttr('aria-hidden');
-        siblings.removeAttr('tabindex');
+        Aria.unhide(siblings.get());
         siblings.removeAttr('data-confirm-dialogue-hidden');
 
         container.addClass('hidden');
@@ -669,26 +669,29 @@ function(
                 var retry = element.find(SELECTORS.RETRY_SEND);
 
                 loading.addClass('hidden');
-                loading.attr('aria-hidden', 'true');
+                Aria.hide(loading.get());
+
                 time.addClass('hidden');
-                time.attr('aria-hidden', 'true');
+                Aria.hide(time.get());
+
                 retry.addClass('hidden');
-                retry.attr('aria-hidden', 'true');
+                Aria.hide(retry.get());
+
                 element.removeClass('border border-danger');
 
                 switch (after.sendState) {
                     case 'pending':
                         loading.removeClass('hidden');
-                        loading.attr('aria-hidden', 'false');
+                        Aria.unhide(loading.get());
                         break;
                     case 'error':
                         retry.removeClass('hidden');
-                        retry.attr('aria-hidden', 'false');
+                        Aria.unhide(retry.get());
                         element.addClass('border border-danger');
                         break;
                     case 'sent':
                         time.removeClass('hidden');
-                        time.attr('aria-hidden', 'false');
+                        Aria.unhide(time.get());
                         break;
                 }
             }
@@ -703,11 +706,11 @@ function(
 
                 if (after.errorMessage) {
                     messageContainer.removeClass('hidden');
-                    messageContainer.attr('aria-hidden', 'false');
+                    Aria.unhide(messageContainer.get());
                     message.text(after.errorMessage);
                 } else {
                     messageContainer.addClass('hidden');
-                    messageContainer.attr('aria-hidden', 'true');
+                    Aria.unhide(messageContainer.get());
                     message.text('');
                 }
             }
@@ -1003,11 +1006,11 @@ function(
 
         if (show) {
             container.removeClass('hidden');
-            container.attr('aria-hidden', false);
+            Aria.unhide(container.get());
             container.find(SELECTORS.EMOJI_PICKER_SEARCH_INPUT).focus();
         } else {
             container.addClass('hidden');
-            container.attr('aria-hidden', true);
+            Aria.hide(container.get());
         }
     };
 
@@ -1024,10 +1027,10 @@ function(
 
         if (show) {
             container.removeClass('hidden');
-            container.attr('aria-hidden', false);
+            Aria.unhide(container.get());
         } else {
             container.addClass('hidden');
-            container.attr('aria-hidden', true);
+            Aria.hide(container.get());
         }
     };
 
index 0055855..86708bc 100644 (file)
@@ -2629,14 +2629,15 @@ class api {
         $userto = \core_user::get_user($requesteduserid);
         $url = new \moodle_url('/message/index.php', ['view' => 'contactrequests']);
 
-        $subject = get_string('messagecontactrequestsubject', 'core_message', (object) [
+        $subject = get_string_manager()->get_string('messagecontactrequestsubject', 'core_message', (object) [
             'sitename' => format_string($SITE->fullname, true, ['context' => \context_system::instance()]),
             'user' => $userfromfullname,
-        ]);
-        $fullmessage = get_string('messagecontactrequest', 'core_message', (object) [
+        ], $userto->lang);
+
+        $fullmessage = get_string_manager()->get_string('messagecontactrequest', 'core_message', (object) [
             'url' => $url->out(),
             'user' => $userfromfullname,
-        ]);
+        ], $userto->lang);
 
         $message = new \core\message\message();
         $message->courseid = SITEID;
index d66df9f..6707bfe 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js and b/mod/assign/amd/build/grading_navigation.min.js differ
index 263adb4..798e324 100644 (file)
Binary files a/mod/assign/amd/build/grading_navigation.min.js.map and b/mod/assign/amd/build/grading_navigation.min.js.map differ
index 9f6a349..5c85fb9 100644 (file)
@@ -229,10 +229,13 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
         // There are 3 types of filter right now.
         var filterPanel = this._region.find('[data-region="configure-filters"]');
         var filters = filterPanel.find('select');
+        var preferenceNames = [];
 
         this._filters = [];
         filters.each(function(idx, ele) {
-            this._filters.push($(ele).val());
+            var element = $(ele);
+            this._filters.push(element.val());
+            preferenceNames.push('assign_' + element.prop('name'));
         }.bind(this));
 
         // Update the active filter string.
@@ -250,7 +253,6 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
 
         var select = this._region.find('[data-action=change-user]');
         var currentUserID = select.data('currentuserid');
-        var preferenceNames = ['assign_filter', 'assign_workflowfilter', 'assign_markerfilter'];
         this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).done(function() {
             // Reload the list of users to apply the new filters.
             if (!this._loadAllUsers()) {
index fb9f4b2..c6c69d6 100644 (file)
@@ -61,21 +61,21 @@ class behat_assignfeedback_editpdf extends behat_base {
     var event = { clientX: 100, clientY: 250, preventDefault: function() {} };
     instance.edit_start(event);
 }()); ';
-        $this->getSession()->executeScript($js);
+        $this->execute_script($js);
         sleep(1);
         $js = ' (function() {
     var instance = M.assignfeedback_editpdf.instance;
     var event = { clientX: 150, clientY: 275, preventDefault: function() {} };
     instance.edit_move(event);
 }()); ';
-        $this->getSession()->executeScript($js);
+        $this->execute_script($js);
         sleep(1);
         $js = ' (function() {
     var instance = M.assignfeedback_editpdf.instance;
     var event = { clientX: 200, clientY: 300, preventDefault: function() {} };
     instance.edit_end(event);
 }()); ';
-        $this->getSession()->executeScript($js);
+        $this->execute_script($js);
         sleep(1);
     }
 
index 07a7b48..0804647 100644 (file)
@@ -46,7 +46,7 @@ class mod_assign_grading_options_form extends moodleform {
 
         $mform->addElement('header', 'general', get_string('gradingoptions', 'assign'));
         // Visible elements.
-        $options = array(-1 => get_string('all'), 10 => '10', 20 => '20', 50 => '50', 100 => '100');
+        $options = array(10 => '10', 20 => '20', 50 => '50', 100 => '100', -1 => get_string('all'));
         $maxperpage = get_config('assign', 'maxperpage');
         if (isset($maxperpage) && $maxperpage != -1) {
             unset($options[-1]);
diff --git a/mod/assign/tests/behat/grading_app_filters.feature b/mod/assign/tests/behat/grading_app_filters.feature
new file mode 100644 (file)
index 0000000..92fd41f
--- /dev/null
@@ -0,0 +1,109 @@
+@mod @mod_assign
+Feature: In an assignment, teachers can change filters in the grading app
+  In order to manage submissions more easily
+  As a teacher
+  I need to preserve filter settings between the grader app and grading table.
+
+  @javascript
+  Scenario: Set filters in the grading table and see them in the grading app
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | marker1 | Marker | 1 | marker1@example.com |
+      | marker2 | Marker | 2 | marker2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | marker1 | C1 | teacher |
+      | marker2 | C1 | teacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Use marking workflow | Yes |
+      | Use marking allocation | Yes |
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "allocatedmarker" to "Marker 1"
+    And I set the field "workflowstate" to "In marking"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I press "OK"
+    And I click on "Edit settings" "link"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I set the field "filter" to "Not submitted"
+    And I set the field "markerfilter" to "Marker 1"
+    And I set the field "workflowfilter" to "In marking"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    Then the field "filter" matches value "Not submitted"
+    And the field "markerfilter" matches value "Marker 1"
+    And the field "workflowfilter" matches value "In marking"
+
+  @javascript
+  Scenario: Set filters in the grading app and see them in the grading table
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+      | marker1 | Marker | 1 | marker1@example.com |
+      | marker2 | Marker | 2 | marker2@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+      | marker1 | C1 | teacher |
+      | marker2 | C1 | teacher |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1 |
+      | assignsubmission_file_enabled | 0 |
+      | Use marking workflow | Yes |
+      | Use marking allocation | Yes |
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "allocatedmarker" to "Marker 1"
+    And I set the field "workflowstate" to "In marking"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I press "OK"
+    And I click on "Edit settings" "link"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I click on "[data-region=user-filters]" "css_element"
+    And I set the field "filter" to "Not submitted"
+    # The popup closes for some reason, so it needs to be reopened.
+    And I click on "[data-region=user-filters]" "css_element"
+    And I set the field "markerfilter" to "Marker 1"
+    And I set the field "workflowfilter" to "In marking"
+    And I click on "View all submissions" "link"
+    Then the field "filter" matches value "Not submitted"
+    And the field "markerfilter" matches value "Marker 1"
+    And the field "workflowfilter" matches value "In marking"
index f097099..aa89962 100644 (file)
@@ -41,7 +41,7 @@ class backup_choice_activity_structure_step extends backup_activity_structure_st
             'name', 'intro', 'introformat', 'publish',
             'showresults', 'display', 'allowupdate', 'allowmultiple', 'showunanswered',
             'limitanswers', 'timeopen', 'timeclose', 'timemodified',
-            'completionsubmit', 'showpreview', 'includeinactive'));
+            'completionsubmit', 'showpreview', 'includeinactive', 'showavailable'));
 
         $options = new backup_nested_element('options');
 
index cdcadd1..2fdb40d 100644 (file)
@@ -519,6 +519,7 @@ class mod_choice_external extends external_api {
                     $choicedetails['limitanswers']  = $choice->limitanswers;
                     $choicedetails['showunanswered']  = $choice->showunanswered;
                     $choicedetails['includeinactive']  = $choice->includeinactive;
+                    $choicedetails['showavailable']  = $choice->showavailable;
                 }
 
                 if (has_capability('moodle/course:manageactivities', $context)) {
@@ -571,6 +572,7 @@ class mod_choice_external extends external_api {
                             'showpreview' => new external_value(PARAM_BOOL, 'Show preview before timeopen', VALUE_OPTIONAL),
                             'timemodified' => new external_value(PARAM_INT, 'Time of last modification', VALUE_OPTIONAL),
                             'completionsubmit' => new external_value(PARAM_BOOL, 'Completion on user submission', VALUE_OPTIONAL),
+                            'showavailable' => new external_value(PARAM_BOOL, 'Show available spaces', VALUE_OPTIONAL),
                             'section' => new external_value(PARAM_INT, 'Course section id', VALUE_OPTIONAL),
                             'visible' => new external_value(PARAM_BOOL, 'Visible', VALUE_OPTIONAL),
                             'groupmode' => new external_value(PARAM_INT, 'Group mode', VALUE_OPTIONAL),
index 0e3cdcb..d6ee73f 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/choice/db" VERSION="20120122" COMMENT="XMLDB file for Moodle mod/choice"
+<XMLDB PATH="mod/choice/db" VERSION="20200616" COMMENT="XMLDB file for Moodle mod/choice"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -11,7 +11,6 @@
         <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="intro" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
-
         <FIELD NAME="publish" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="showresults" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="display" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
@@ -24,8 +23,8 @@
         <FIELD NAME="timeclose" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="showpreview" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
-
         <FIELD NAME="completionsubmit" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="If this field is set to 1, then the activity will be automatically marked as 'complete' once the user submits their choice."/>
+        <FIELD NAME="showavailable" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="If this field is set to 1, then the the number of available space on choice options will be shown, given limitanswers is set to 1."/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
@@ -34,7 +33,6 @@
         <INDEX NAME="course" UNIQUE="false" FIELDS="course"/>
       </INDEXES>
     </TABLE>
-
     <TABLE NAME="choice_options" COMMENT="available options to choice">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
@@ -44,7 +42,6 @@
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
-
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="choiceid" TYPE="foreign" FIELDS="choiceid" REFTABLE="choice" REFFIELDS="id"/>
       </KEYS>
@@ -54,7 +51,6 @@
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="choiceid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
-
         <FIELD NAME="optionid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
@@ -64,7 +60,6 @@
         <KEY NAME="optionid" TYPE="foreign" FIELDS="optionid" REFTABLE="choice_options" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
-
         <INDEX NAME="userid" UNIQUE="false" FIELDS="userid"/>
       </INDEXES>
     </TABLE>
index b3db19a..6f8abdc 100644 (file)
@@ -22,7 +22,9 @@
 defined('MOODLE_INTERNAL') || die();
 
 function xmldb_choice_upgrade($oldversion) {
-    global $CFG;
+    global $CFG, $DB;
+
+    $dbman = $DB->get_manager();
 
     // Automatically generated Moodle v3.5.0 release upgrade line.
     // Put any upgrade step following this.
@@ -39,5 +41,17 @@ function xmldb_choice_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020061600) {
+        // Define field showavailable to be added to choice.
+        $table = new xmldb_table('choice');
+        $field = new xmldb_field('showavailable', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'completionsubmit');
+
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Choice savepoint reached.
+        upgrade_mod_savepoint(true, 2020061600, 'choice');
+    }
     return true;
 }
index a56b121..8601148 100644 (file)
@@ -83,6 +83,7 @@ $string['indicator:socialbreadthdef'] = 'Choice social';
 $string['indicator:socialbreadthdef_help'] = 'The participant has reached this percentage of the social engagement offered by the Choice activities during this analysis interval (Levels = No participation, Participant alone, Participant with others)';
 $string['indicator:socialbreadthdef_link'] = 'Learning_analytics_indicators#Social_breadth';
 $string['limit'] = 'Limit';
+$string['limita'] = 'Limit: {$a}';
 $string['limitno'] = 'Limit {no}';
 $string['limitanswers'] = 'Limit the number of responses allowed';
 $string['modulename'] = 'Choice';
@@ -137,11 +138,14 @@ $string['removemychoice'] = 'Remove my choice';
 $string['removeresponses'] = 'Remove all responses';
 $string['responses'] = 'Responses';
 $string['responsesresultgraphheader'] = 'Graph display';
+$string['responsesa'] = 'Responses: {$a}';
 $string['responsesto'] = 'Responses to {$a}';
 $string['results'] = 'Results';
 $string['savemychoice'] = 'Save my choice';
 $string['search:activity'] = 'Choice - activity information';
 $string['selectalloption'] = 'Select all "{$a}"';
+$string['showavailable'] = 'Show available spaces';
+$string['showavailable_help'] = 'Allow students to see how many available spaces there are per option.';
 $string['showpreview'] = 'Show preview';
 $string['showpreview_help'] = 'Allow students to preview the available options before the choice is opened for submission.';
 $string['showunanswered'] = 'Show column for unanswered';
index eca6f38..4096c8b 100644 (file)
@@ -213,7 +213,9 @@ function choice_prepare_options($choice, $user, $coursemodule, $allresponses) {
 
     $cdisplay = array('options'=>array());
 
-    $cdisplay['limitanswers'] = true;
+    $cdisplay['limitanswers'] = $choice->limitanswers;
+    $cdisplay['showavailable'] = $choice->showavailable;
+
     $context = context_module::instance($coursemodule->id);
 
     foreach ($choice->option as $optionid => $text) {
index 9fc3741..47a400b 100644 (file)
@@ -44,6 +44,10 @@ class mod_choice_mod_form extends moodleform_mod {
         $mform->addElement('selectyesno', 'limitanswers', get_string('limitanswers', 'choice'));
         $mform->addHelpButton('limitanswers', 'limitanswers', 'choice');
 
+        $mform->addElement('selectyesno', 'showavailable', get_string('showavailable', 'choice'));
+        $mform->addHelpButton('showavailable', 'showavailable', 'choice');
+        $mform->hideIf('showavailable', 'limitanswers', 'eq', 0);
+
         $repeatarray = array();
         $repeatarray[] = $mform->createElement('text', 'option', get_string('optionno', 'choice'));
         $repeatarray[] = $mform->createElement('text', 'limit', get_string('limitno', 'choice'));
index cdaae58..4f954bb 100644 (file)
@@ -50,7 +50,7 @@ class mod_choice_renderer extends plugin_renderer_base {
         $choicecount = 0;
         foreach ($options['options'] as $option) {
             $choicecount++;
-            $html .= html_writer::start_tag('li', array('class'=>'option'));
+            $html .= html_writer::start_tag('li', array('class' => 'option mr-3'));
             if ($multiple) {
                 $option->attributes->name = 'answer[]';
                 $option->attributes->type = 'checkbox';
@@ -67,6 +67,13 @@ class mod_choice_renderer extends plugin_renderer_base {
                 $availableoption--;
             }
 
+            if (!empty($options['limitanswers']) && !empty($options['showavailable'])) {
+                $labeltext .= html_writer::empty_tag('br');
+                $labeltext .= get_string("responsesa", "choice", $option->countanswers);
+                $labeltext .= html_writer::empty_tag('br');
+                $labeltext .= get_string("limita", "choice", $option->maxanswers);
+            }
+
             $html .= html_writer::empty_tag('input', (array)$option->attributes + $disabled);
             $html .= html_writer::tag('label', $labeltext, array('for'=>$option->attributes->id));
             $html .= html_writer::end_tag('li');
@@ -182,6 +189,11 @@ class mod_choice_renderer extends plugin_renderer_base {
                 $headertitle = get_string('notanswered', 'choice');
             } else if ($optionid > 0) {
                 $headertitle = format_string($choices->options[$optionid]->text);
+                if (!empty($choices->options[$optionid]->user) && count($choices->options[$optionid]->user) > 0) {
+                    if ((count($choices->options[$optionid]->user)) == ($choices->options[$optionid]->maxanswer)) {
+                        $headertitle .= ' ' . get_string('full', 'choice');
+                    }
+                }
             }
             $celltext = $headertitle;
 
@@ -209,7 +221,10 @@ class mod_choice_renderer extends plugin_renderer_base {
             if (!empty($options->user) && count($options->user) > 0) {
                 $numberofuser = count($options->user);
             }
-
+            if (($choices->limitanswers) && ($choices->showavailable)) {
+                $numberofuser .= html_writer::empty_tag('br');
+                $numberofuser .= get_string("limita", "choice", $options->maxanswer);
+            }
             $celloption->text = html_writer::div($celltext, 'text-center');
             $optionsnames[$optionid] = $celltext;
             $cellusernumber->text = html_writer::div($numberofuser, 'text-center');
index fc6d4cc..4804e22 100644 (file)
@@ -10,6 +10,7 @@ Feature: Limit choice responses
       | teacher1 | Teacher | 1 | teacher1@example.com |
       | student1 | Student | 1 | student1@example.com |
       | student2 | Student | 2 | student2@example.com |
+      | student3 | Student | 3 | student3@example.com |
     And the following "courses" exist:
       | fullname | shortname | category |
       | Course 1 | C1 | 0 |
@@ -18,12 +19,14 @@ Feature: Limit choice responses
       | teacher1 | C1 | editingteacher |
       | student1 | C1 | student |
       | student2 | C1 | student |
+      | student3 | C1 | student |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add a "Choice" to section "1" and I fill the form with:
       | Choice name | Choice name |
       | Description | Choice Description |
       | Limit the number of responses allowed | 1 |
+      | Show available spaces  | 1 |
       | option[0] | Option 1 |
       | limit[0] | 1 |
       | option[1] | Option 2 |
@@ -38,4 +41,20 @@ Feature: Limit choice responses
     And I am on "Course 1" course homepage
     And I follow "Choice name"
     And I should see "Option 1 (Full)"
+    And I should see "Responses: 1"
+    And I should see "Limit: 1"
     And the "choice_1" "radio" should be disabled
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Choice name"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Limit the number of responses allowed | No |
+    And I press "Save and return to course"
+    And I log out
+    And I log in as "student3"
+    And I am on "Course 1" course homepage
+    And I follow "Choice name"
+    Then I should not see "Limit: 1"
+    And the "choice_1" "radio" should be enabled
index 0b6e15e..2efd124 100644 (file)
@@ -52,6 +52,7 @@ Feature: Multiple option choice response
       | Description | Choice Description |
       | Allow more than one choice to be selected | Yes |
       | Limit the number of responses allowed | 1 |
+      | Show available spaces  | 1 |
       | option[0] | Option 1 |
       | limit[0] | 1 |
       | option[1] | Option 2 |
@@ -74,3 +75,21 @@ Feature: Multiple option choice response
     And the "#choice_1" "css_element" should be disabled
     And the "#choice_2" "css_element" should be disabled
     And the "#choice_3" "css_element" should be enabled
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Choice name"
+    And I follow "View 1 responses"
+    Then I should see "Option 1 (Full)"
+    And I should see "Limit: 1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Choice name"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Limit the number of responses allowed | No |
+    And I press "Save and return to course"
+    And I am on "Course 1" course homepage
+    And I follow "Choice name"
+    And I follow "View 1 responses"
+    Then I should not see "Limit: 1"
+    And I log out
index 5a18e6b..5963fc8 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020061500;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2020061600;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2020060900;    // Requires this Moodle version
 $plugin->component = 'mod_choice';     // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 0;
index ca4c7df..af3f57e 100644 (file)
@@ -850,7 +850,7 @@ class subscriptions {
             // User does not have permission to unsubscribe from this discussion at all.
             $discussionsubscribe = true;
         } else {
-            if (isset($discussion) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, $discussionid, $cm)) {
+            if (isset($discussionid) && self::is_subscribed($USER->id, $forum, $discussionid, $cm)) {
                 // User is subscribed to the discussion - continue the subscription.
                 $discussionsubscribe = true;
             } else if (!isset($discussionid) && \mod_forum\subscriptions::is_subscribed($USER->id, $forum, null, $cm)) {
index e884477..e33b755 100644 (file)
@@ -328,9 +328,6 @@ $rendererfactory = mod_forum\local\container::get_renderer_factory();
 $discussionrenderer = $rendererfactory->get_discussion_renderer($forum, $discussion, $displaymode);
 $orderpostsby = $displaymode == FORUM_MODE_FLATNEWEST ? 'created DESC' : 'created ASC';
 $replies = $postvault->get_replies_to_post($USER, $post, $capabilitymanager->can_view_any_private_reply($USER), $orderpostsby);
-$postids = array_map(function($post) {
-    return $post->get_id();
-}, array_merge([$post], array_values($replies)));
 
 if ($move == -1 and confirm_sesskey()) {
     $forumname = format_string($forum->get_name(), true);
@@ -341,5 +338,12 @@ echo $discussionrenderer->render($USER, $post, $replies);
 echo $OUTPUT->footer();
 
 if ($istracked && !$CFG->forum_usermarksread) {
-    forum_tp_mark_posts_read($USER, $postids);
+    if ($displaymode == FORUM_MODE_THREADED) {
+        forum_tp_add_read_record($USER->id, $post->get_id());
+    } else {
+        $postids = array_map(function($post) {
+            return $post->get_id();
+        }, array_merge([$post], array_values($replies)));
+        forum_tp_mark_posts_read($USER, $postids);
+    }
 }
index 58937ce..454743e 100644 (file)
@@ -529,6 +529,6 @@ class behat_mod_forum extends behat_base {
         global $DB;
         $post = $DB->get_record("forum_posts", array("subject" => $postsubject), 'id', MUST_EXIST);
         $url = new moodle_url('/mod/forum/post.php', ['reply' => $post->id]);
-        $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
+        $this->execute('behat_general::i_visit', [$url]);
     }
 }
index 1931f82..3cf3224 100644 (file)
@@ -323,6 +323,7 @@ function label_generate_resized_image(stored_file $file, $maxwidth, $maxheight)
         $attrib['width'] = $maxwidth;
     }
 
+    $attrib['class'] = "img-fluid";
     $img = html_writer::empty_tag('img', $attrib);
     if ($link) {
         return html_writer::link($link, $img);
index b37cf4e..2b088a0 100644 (file)
@@ -46,39 +46,64 @@ class mod_lti_external_testcase extends externallib_advanced_testcase {
      * Set up for every test
      */
     public function setUp() {
-        global $DB;
         $this->resetAfterTest();
+    }
+
+    /**
+     * Sets up some basic test data including course, users, roles, and an lti instance, for use in some tests.
+     * @return array
+     */
+    protected function setup_test_data() {
+        global $DB;
         $this->setAdminUser();
 
         // Setup test data.
-        $this->course = $this->getDataGenerator()->create_course();
-        $this->lti = $this->getDataGenerator()->create_module('lti',
-            array('course' => $this->course->id, 'toolurl' => 'http://localhost/not/real/tool.php'));
-        $this->context = context_module::instance($this->lti->cmid);
-        $this->cm = get_coursemodule_from_instance('lti', $this->lti->id);
+        $course = $this->getDataGenerator()->create_course();
+        $lti = $this->getDataGenerator()->create_module(
+            'lti',
+            ['course' => $course->id, 'toolurl' => 'http://localhost/not/real/tool.php']
+        );
+        $context = context_module::instance($lti->cmid);
+        $cm = get_coursemodule_from_instance('lti', $lti->id);
 
         // Create users.
-        $this->student = self::getDataGenerator()->create_user();
-        $this->teacher = self::getDataGenerator()->create_user();
+        $student = self::getDataGenerator()->create_user();
+        $teacher = self::getDataGenerator()->create_user();
 
         // Users enrolments.
-        $this->studentrole = $DB->get_record('role', array('shortname' => 'student'));
-        $this->teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
-        $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
-        $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual');
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, $teacherrole->id, 'manual');
+
+        return [
+            'course' => $course,
+            'lti' => $lti,
+            'context' => $context,
+            'cm' => $cm,
+            'student' => $student,
+            'teacher' => $teacher,
+            'studentrole' => $studentrole,
+            'teacherrole' => $teacherrole
+        ];
     }
 
     /**
-     * Test view_lti
+     * Test get_tool_launch_data.
      */
     public function test_get_tool_launch_data() {
-        global $USER, $SITE;
+        global $USER;
 
-        $result = mod_lti_external::get_tool_launch_data($this->lti->id);
+        [
+            'course' => $course,
+            'lti' => $lti
+        ] = $this->setup_test_data();
+
+        $result = mod_lti_external::get_tool_launch_data($lti->id);
         $result = external_api::clean_returnvalue(mod_lti_external::get_tool_launch_data_returns(), $result);
&nb