Merge branch 'MDL-35971-master' of git://github.com/junpataleta/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 14 Jan 2020 00:23:56 +0000 (08:23 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 14 Jan 2020 00:23:56 +0000 (08:23 +0800)
115 files changed:
Gruntfile.js
GruntfileComponents.js [new file with mode: 0644]
admin/roles/classes/define_role_table_advanced.php
admin/settings/server.php
admin/templates/setting_configpasswordunmask.mustache
admin/tool/customlang/locallib.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_request.php
admin/tool/dataprivacy/classes/task/process_data_request_task.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/db/install.xml
admin/tool/dataprivacy/db/upgrade.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/settings.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/behat/datadelete.feature
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/dataprivacy/version.php
auth/cas/CAS/CAS.php
auth/cas/CAS/CAS/Client.php
auth/cas/CAS/README.md
auth/cas/CAS/moodle_readme.txt
babel-plugin-add-module-to-define.js
backup/externallib.php
backup/restore.php
calendar/lib.php
calendar/tests/lib_test.php
competency/tests/privacy_test.php
enrol/meta/lib.php
grade/amd/build/edittree_index.min.js
grade/amd/build/edittree_index.min.js.map
grade/amd/src/edittree_index.js
grade/edit/tree/lib.php
grade/templates/edit_tree.mustache
lang/en/moodle.php
lang/en/role.php
lib/adminlib.php
lib/amd/build/loglevel.min.js
lib/amd/build/loglevel.min.js.map
lib/amd/build/mustache.min.js
lib/amd/build/mustache.min.js.map
lib/amd/src/loglevel.js
lib/amd/src/mustache.js
lib/classes/task/adhoc_task.php
lib/classes/task/manager.php
lib/cronlib.php
lib/datalib.php
lib/db/access.php
lib/db/install.php
lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-debug.js
lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-min.js
lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button.js
lib/editor/atto/plugins/accessibilitychecker/yui/src/button/js/button.js
lib/editor/tests/fixtures/editor_form.php
lib/filelib.php
lib/filestorage/file_system.php
lib/form/duration.php
lib/form/filetypes.php
lib/form/templatable_form_element.php
lib/mustache/readme_moodle.txt
lib/mustache/src/Mustache/Parser.php
lib/scssphp/Compiler.php
lib/scssphp/moodle_readme.txt
lib/setup.php
lib/setuplib.php
lib/tests/adhoc_task_test.php
lib/tests/fixtures/repeated_events.ics [new file with mode: 0644]
lib/tests/session_manager_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js
lib/yui/src/notification/js/dialogue.js
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_overview_section.min.js.map
message/amd/src/message_drawer_view_overview_section.js
mod/assign/locallib.php
mod/feedback/classes/responses_table.php
mod/forum/amd/build/local/layout/fullscreen.min.js
mod/forum/amd/build/local/layout/fullscreen.min.js.map
mod/forum/amd/src/local/layout/fullscreen.js
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/exporters/discussion_summary.php
mod/forum/classes/local/factories/url.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/post_form.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/templates/forum_action_menu.mustache
mod/forum/templates/forum_discussion_favourite_toggle.mustache
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/inpage_reply.mustache
mod/forum/templates/inpage_reply_v2.mustache
mod/forum/templates/local/grades/local/grader/grading.mustache
mod/forum/templates/local/grades/local/grader/navigation.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/exporters_author_test.php
mod/forum/tests/externallib_test.php
mod/glossary/import_form.php
mod/wiki/comments_form.php
mod/wiki/parser/parser.php
question/engine/questionusage.php
report/log/classes/table_log.php
report/loglive/classes/renderable.php
repository/dropbox/classes/dropbox.php
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/loader.js
theme/boost/templates/navbar-secure.mustache
theme/boost/templates/navbar.mustache
theme/classic/templates/navbar-secure.mustache
theme/classic/templates/navbar.mustache
version.php

index f05bb19..f45c261 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+/* eslint-env node */
+
 /**
- * Grunt configuration
+ * Calculate the cwd, taking into consideration the `root` option (for Windows).
+ *
+ * @param {Object} grunt
+ * @returns {String} The current directory as best we can determine
  */
+const getCwd = grunt => {
+    const fs = require('fs');
+    const path = require('path');
 
-/* eslint-env node */
-module.exports = function(grunt) {
-    var path = require('path'),
-        tasks = {},
-        cwd = process.env.PWD || process.cwd(),
-        async = require('async'),
-        DOMParser = require('xmldom').DOMParser,
-        xpath = require('xpath'),
-        semver = require('semver'),
-        watchman = require('fb-watchman'),
-        watchmanClient = new watchman.Client(),
-        gruntFilePath = process.cwd();
-
-    // Verify the node version is new enough.
-    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
-    var actual = semver.valid(process.version);
-    if (!semver.satisfies(actual, expected)) {
-        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
-    }
+    let cwd = fs.realpathSync(process.env.PWD || process.cwd());
 
     // Windows users can't run grunt in a subdirectory, so allow them to set
     // the root by passing --root=path/to/dir.
     if (grunt.option('root')) {
-        var root = grunt.option('root');
+        const root = grunt.option('root');
         if (grunt.file.exists(__dirname, root)) {
-            cwd = path.join(__dirname, root);
+            cwd = fs.realpathSync(path.join(__dirname, root));
             grunt.log.ok('Setting root to ' + cwd);
         } else {
             grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
         }
     }
 
+    return cwd;
+};
+
+/**
+ * Register any stylelint tasks.
+ *
+ * @param {Object} grunt
+ * @param {Array} files
+ * @param {String} fullRunDir
+ */
+const registerStyleLintTasks = (grunt, files, fullRunDir) => {
+    const getCssConfigForFiles = files => {
+        return {
+            stylelint: {
+                css: {
+                    // Use a fully-qualified path.
+                    src: files,
+                    options: {
+                        configOverrides: {
+                            rules: {
+                                // These rules have to be disabled in .stylelintrc for scss compat.
+                                "at-rule-no-unknown": true,
+                            }
+                        }
+                    }
+                },
+            },
+        };
+    };
+
+    const getScssConfigForFiles = files => {
+        return {
+            stylelint: {
+                scss: {
+                    options: {syntax: 'scss'},
+                    src: files,
+                },
+            },
+        };
+    };
+
+    let hasCss = true;
+    let hasScss = true;
+
+    if (files) {
+        // Specific files were passed. Just set them up.
+        grunt.config.merge(getCssConfigForFiles(files));
+        grunt.config.merge(getScssConfigForFiles(files));
+    } else {
+        // The stylelint system does not handle the case where there was no file to lint.
+        // Check whether there are any files to lint in the current directory.
+        const glob = require('glob');
+
+        const scssSrc = [];
+        glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path));
+
+        if (scssSrc.length) {
+            grunt.config.merge(getScssConfigForFiles(scssSrc));
+        } else {
+            hasScss = false;
+        }
+
+        const cssSrc = [];
+        glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path));
+
+        if (cssSrc.length) {
+            grunt.config.merge(getCssConfigForFiles(cssSrc));
+        } else {
+            hasCss = false;
+        }
+    }
+
+    const scssTasks = ['sass'];
+    if (hasScss) {
+        scssTasks.unshift('stylelint:scss');
+    }
+    grunt.registerTask('scss', scssTasks);
+
+    const cssTasks = [];
+    if (hasCss) {
+        cssTasks.push('stylelint:css');
+    }
+    grunt.registerTask('rawcss', cssTasks);
+
+    grunt.registerTask('css', ['scss', 'rawcss']);
+};
+
+/**
+ * Grunt configuration.
+ *
+ * @param {Object} grunt
+ */
+module.exports = function(grunt) {
+    const path = require('path');
+    const tasks = {};
+    const async = require('async');
+    const DOMParser = require('xmldom').DOMParser;
+    const xpath = require('xpath');
+    const semver = require('semver');
+    const watchman = require('fb-watchman');
+    const watchmanClient = new watchman.Client();
+    const fs = require('fs');
+    const ComponentList = require(path.resolve('GruntfileComponents.js'));
+
+    // Verify the node version is new enough.
+    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
+    var actual = semver.valid(process.version);
+    if (!semver.satisfies(actual, expected)) {
+        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
+    }
+
+    // Detect directories:
+    // * gruntFilePath          The real path on disk to this Gruntfile.js
+    // * cwd                    The current working directory, which can be overridden by the `root` option
+    // * relativeCwd            The cwd, relative to the Gruntfile.js
+    // * componentDirectory     The root directory of the component if the cwd is in a valid component
+    // * inComponent            Whether the cwd is in a valid component
+    // * runDir                 The componentDirectory or cwd if not in a component, relative to Gruntfile.js
+    // * fullRunDir             The full path to the runDir
+    const gruntFilePath = fs.realpathSync(process.cwd());
+    const cwd = getCwd(grunt);
+    const relativeCwd = cwd.replace(new RegExp(`${gruntFilePath}/?`), '');
+    const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd);
+    const inComponent = !!componentDirectory;
+    const runDir = inComponent ? componentDirectory : relativeCwd;
+    const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
+    grunt.log.debug(`The cwd was detected as ${cwd} with a fullRunDir of ${fullRunDir}`);
+
+    if (inComponent) {
+        grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
+    }
+
     var files = null;
     if (grunt.option('files')) {
         // Accept a comma separated list of files to process.
         files = grunt.option('files').split(',');
     }
 
-    var inAMD = path.basename(cwd) == 'amd';
+    const inAMD = path.basename(cwd) == 'amd';
 
     // Globbing pattern for matching all AMD JS source files.
-    var amdSrc = [];
-    if (inAMD) {
-        amdSrc.push(cwd + "/src/*.js");
-        amdSrc.push(cwd + "/src/**/*.js");
+    let amdSrc = [];
+    if (inComponent) {
+        amdSrc.push(componentDirectory + "/amd/src/*.js");
+        amdSrc.push(componentDirectory + "/amd/src/**/*.js");
     } else {
-        amdSrc.push("**/amd/src/*.js");
-        amdSrc.push("**/amd/src/**/*.js");
+        amdSrc = ComponentList.getAmdSrcGlobList();
+    }
+
+    let yuiSrc = [];
+    if (inComponent) {
+        yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
+    } else {
+        yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
     }
 
     /**
@@ -97,28 +225,29 @@ module.exports = function(grunt) {
      * @return {array} The list of thirdparty paths.
      */
     var getThirdPartyPathsFromXML = function() {
-        var thirdpartyfiles = grunt.file.expand('*/**/thirdpartylibs.xml');
-        var libs = ['node_modules/', 'vendor/'];
+        const thirdpartyfiles = ComponentList.getThirdPartyLibsList(gruntFilePath + '/');
+        const libs = ['node_modules/', 'vendor/'];
 
         thirdpartyfiles.forEach(function(file) {
-          var dirname = path.dirname(file);
+            const dirname = path.dirname(file);
 
-          var doc = new DOMParser().parseFromString(grunt.file.read(file));
-          var nodes = xpath.select("/libraries/library/location/text()", doc);
+            const doc = new DOMParser().parseFromString(grunt.file.read(file));
+            const nodes = xpath.select("/libraries/library/location/text()", doc);
 
-          nodes.forEach(function(node) {
-            var lib = path.join(dirname, node.toString());
-            if (grunt.file.isDir(lib)) {
-                // Ensure trailing slash on dirs.
-                lib = lib.replace(/\/?$/, '/');
-            }
+            nodes.forEach(function(node) {
+                let lib = path.join(dirname, node.toString());
+                if (grunt.file.isDir(lib)) {
+                    // Ensure trailing slash on dirs.
+                    lib = lib.replace(/\/?$/, '/');
+                }
 
-            // Look for duplicate paths before adding to array.
-            if (libs.indexOf(lib) === -1) {
-                libs.push(lib);
-            }
-          });
+                // Look for duplicate paths before adding to array.
+                if (libs.indexOf(lib) === -1) {
+                    libs.push(lib);
+                }
+            });
         });
+
         return libs;
     };
 
@@ -130,7 +259,7 @@ module.exports = function(grunt) {
             options: {quiet: !grunt.option('show-lint-warnings')},
             amd: {src: files ? files : amdSrc},
             // Check YUI module source files.
-            yui: {src: files ? files : ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js']}
+            yui: {src: files ? files : yuiSrc},
         },
         babel: {
             options: {
@@ -198,30 +327,41 @@ module.exports = function(grunt) {
                 nospawn: true // We need not to spawn so config can be changed dynamically.
             },
             amd: {
-                files: ['**/amd/src/**/*.js'],
+                files: inComponent
+                    ? ['amd/src/*.js', 'amd/src/**/*.js']
+                    : ['**/amd/src/**/*.js'],
                 tasks: ['amd']
             },
             boost: {
-                files: ['**/theme/boost/scss/**/*.scss'],
+                files: [inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'],
                 tasks: ['scss']
             },
             rawcss: {
-                files: ['**/*.css', '**/theme/**/!(moodle.css|editor.css)'],
+                files: [
+                    '**/*.css',
+                ],
+                excludes: [
+                    '**/moodle.css',
+                    '**/editor.css',
+                ],
                 tasks: ['rawcss']
             },
             yui: {
-                files: ['**/yui/src/**/*.js'],
+                files: inComponent
+                    ? ['yui/src/*.json', 'yui/src/**/*.js']
+                    : ['**/yui/src/**/*.js'],
                 tasks: ['yui']
             },
             gherkinlint: {
-                files: ['**/tests/behat/*.feature'],
+                files: [inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
                 tasks: ['gherkinlint']
             }
         },
         shifter: {
             options: {
                 recursive: true,
-                paths: files ? files : [cwd]
+                // Shifter takes a relative path.
+                paths: files ? files : [runDir]
             }
         },
         gherkinlint: {
@@ -229,42 +369,30 @@ module.exports = function(grunt) {
                 files: files ? files : ['**/tests/behat/*.feature'],
             }
         },
-        stylelint: {
-            scss: {
-                options: {syntax: 'scss'},
-                src: files ? files : ['*/**/*.scss']
-            },
-            css: {
-                src: files ? files : ['*/**/*.css'],
-                options: {
-                    configOverrides: {
-                        rules: {
-                            // These rules have to be disabled in .stylelintrc for scss compat.
-                            "at-rule-no-unknown": true,
-                        }
-                    }
-                }
-            }
-        }
     });
 
     /**
      * Generate ignore files (utilising thirdpartylibs.xml data)
      */
     tasks.ignorefiles = function() {
-      // An array of paths to third party directories.
-      var thirdPartyPaths = getThirdPartyPathsFromXML();
-      // Generate .eslintignore.
-      var eslintIgnores = ['# Generated by "grunt ignorefiles"', '*/**/yui/src/*/meta/', '*/**/build/'].concat(thirdPartyPaths);
-      grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
-      // Generate .stylelintignore.
-      var stylelintIgnores = [
-          '# Generated by "grunt ignorefiles"',
-          '**/yui/build/*',
-          'theme/boost/style/moodle.css',
-          'theme/classic/style/moodle.css',
-      ].concat(thirdPartyPaths);
-      grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+        // An array of paths to third party directories.
+        const thirdPartyPaths = getThirdPartyPathsFromXML();
+        // Generate .eslintignore.
+        const eslintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '*/**/yui/src/*/meta/',
+            '*/**/build/',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+
+        // Generate .stylelintignore.
+        const stylelintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '**/yui/build/*',
+            'theme/boost/style/moodle.css',
+            'theme/classic/style/moodle.css',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
     };
 
     /**
@@ -428,7 +556,7 @@ module.exports = function(grunt) {
                             grunt: true,
                             // Run from current working dir and inherit stdio from process.
                             opts: {
-                                cwd: cwd,
+                                cwd: fullRunDir,
                                 stdio: 'inherit'
                             },
                             args: [task, filesOption]
@@ -454,18 +582,26 @@ module.exports = function(grunt) {
             );
         };
 
-        var watchConfig = grunt.config.get(['watch']);
-        watchConfig = Object.keys(watchConfig).reduce(function(carry, key) {
+        const originalWatchConfig = grunt.config.get(['watch']);
+        const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
             if (key == 'options') {
                 return carry;
             }
 
-            var value = watchConfig[key];
-            var fileGlobs = value.files;
-            var taskNames = value.tasks;
+            const value = originalWatchConfig[key];
+
+            const taskNames = value.tasks;
+            const files = value.files;
+            let excludes = [];
+            if (value.excludes) {
+                excludes = value.excludes;
+            }
 
             taskNames.forEach(function(taskName) {
-                carry[taskName] = fileGlobs;
+                carry[taskName] = {
+                    files,
+                    excludes,
+                };
             });
 
             return carry;
@@ -499,12 +635,14 @@ module.exports = function(grunt) {
             resp.files.forEach(function(file) {
                 grunt.log.ok('File changed: ' + file.name);
 
-                var fullPath = cwd + '/' + file.name;
+                var fullPath = fullRunDir + '/' + file.name;
                 Object.keys(watchConfig).forEach(function(task) {
-                    var fileGlobs = watchConfig[task];
-                    var match = fileGlobs.every(function(fileGlob) {
-                        return grunt.file.isMatch(fileGlob, fullPath);
+
+                    const fileGlobs = watchConfig[task].files;
+                    var match = fileGlobs.some(function(fileGlob) {
+                        return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
                     });
+
                     if (match) {
                         // If we are watching a subdirectory then the file.name will be relative
                         // to that directory. However the grunt tasks  expect the file paths to be
@@ -536,7 +674,7 @@ module.exports = function(grunt) {
         });
 
         // Initiate the watch on the current directory.
-        watchmanClient.command(['watch-project', cwd], function(watchError, watchResponse) {
+        watchmanClient.command(['watch-project', fullRunDir], function(watchError, watchResponse) {
             if (watchError) {
                 grunt.log.error('Error initiating watch:', watchError);
                 watchTaskDone(1);
@@ -558,18 +696,40 @@ module.exports = function(grunt) {
                     return;
                 }
 
-                // Use the matching patterns specified in the watch config.
+                // Generate the expression query used by watchman.
+                // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
+                // We generate an expression to match any value in the files list of all of our tasks, but excluding
+                // all value in the  excludes list of that task.
+                //
+                // [anyof, [
+                //      [allof, [
+                //          [anyof, [
+                //              ['match', validPath, 'wholename'],
+                //              ['match', validPath, 'wholename'],
+                //          ],
+                //          [not,
+                //              [anyof, [
+                //                  ['match', invalidPath, 'wholename'],
+                //                  ['match', invalidPath, 'wholename'],
+                //              ],
+                //          ],
+                //      ],
+                var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
                 var matches = Object.keys(watchConfig).map(function(task) {
-                    var fileGlobs = watchConfig[task];
-                    var fileGlobMatches = fileGlobs.map(function(fileGlob) {
-                        return ['match', fileGlob, 'wholename'];
-                    });
+                    const matchAll = [];
+                    matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
 
-                    return ['allof'].concat(fileGlobMatches);
+                    if (watchConfig[task].excludes.length) {
+                        matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
+                    }
+
+                    return ['allof'].concat(matchAll);
                 });
 
+                matches = ['anyof'].concat(matches);
+
                 var sub = {
-                    expression: ["anyof"].concat(matches),
+                    expression: matches,
                     // Which fields we're interested in.
                     fields: ["name", "size", "type"],
                     // Add our time constraint.
@@ -589,7 +749,7 @@ module.exports = function(grunt) {
                         return;
                     }
 
-                    grunt.log.ok('Listening for changes to files in ' + cwd);
+                    grunt.log.ok('Listening for changes to files in ' + fullRunDir);
                 });
             });
         });
@@ -634,10 +794,8 @@ module.exports = function(grunt) {
     grunt.registerTask('amd', ['eslint:amd', 'babel']);
     grunt.registerTask('js', ['amd', 'yui']);
 
-    // Register CSS taks.
-    grunt.registerTask('css', ['stylelint:scss', 'sass', 'stylelint:css']);
-    grunt.registerTask('scss', ['stylelint:scss', 'sass']);
-    grunt.registerTask('rawcss', ['stylelint:css']);
+    // Register CSS tasks.
+    registerStyleLintTasks(grunt, files, fullRunDir);
 
     // Register the startup task.
     grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup);
diff --git a/GruntfileComponents.js b/GruntfileComponents.js
new file mode 100644 (file)
index 0000000..06ed999
--- /dev/null
@@ -0,0 +1,185 @@
+// 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/>.
+
+/**
+ * Helper functions for working with Moodle component names, directories, and sources.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+"use strict";
+/* eslint-env node */
+
+/** @var {Object} A list of subsystems in Moodle */
+const componentData = {};
+
+/**
+ * Load details of all moodle modules.
+ *
+ * @returns {object}
+ */
+const fetchComponentData = () => {
+    const fs = require('fs');
+    const path = require('path');
+    const glob = require('glob');
+    const gruntFilePath = process.cwd();
+
+    if (!Object.entries(componentData).length) {
+        componentData.subsystems = {};
+        componentData.pathList = [];
+
+        // Fetch the component definiitions from the distributed JSON file.
+        const components = JSON.parse(fs.readFileSync(`${gruntFilePath}/lib/components.json`));
+
+        // Build the list of moodle subsystems.
+        componentData.subsystems.lib = 'core';
+        componentData.pathList.push(process.cwd() + path.sep + 'lib');
+        for (const [component, thisPath] of Object.entries(components.subsystems)) {
+            if (thisPath) {
+                // Prefix "core_" to the front of the subsystems.
+                componentData.subsystems[thisPath] = `core_${component}`;
+                componentData.pathList.push(process.cwd() + path.sep + thisPath);
+            }
+        }
+
+        // The list of components incldues the list of subsystems.
+        componentData.components = componentData.subsystems;
+
+        // Go through each of the plugintypes.
+        Object.entries(components.plugintypes).forEach(([pluginType, pluginTypePath]) => {
+            // We don't allow any code in this place..?
+            glob.sync(`${pluginTypePath}/*/version.php`).forEach(versionPath => {
+                const componentPath = fs.realpathSync(path.dirname(versionPath));
+                const componentName = path.basename(componentPath);
+                const frankenstyleName = `${pluginType}_${componentName}`;
+                componentData.components[`${pluginTypePath}/${componentName}`] = frankenstyleName;
+                componentData.pathList.push(componentPath);
+
+                // Look for any subplugins.
+                const subPluginConfigurationFile = `${componentPath}/db/subplugins.json`;
+                if (fs.existsSync(subPluginConfigurationFile)) {
+                    const subpluginList = JSON.parse(fs.readFileSync(fs.realpathSync(subPluginConfigurationFile)));
+
+                    Object.entries(subpluginList.plugintypes).forEach(([subpluginType, subpluginTypePath]) => {
+                        glob.sync(`${subpluginTypePath}/*/version.php`).forEach(versionPath => {
+                            const componentPath = fs.realpathSync(path.dirname(versionPath));
+                            const componentName = path.basename(componentPath);
+                            const frankenstyleName = `${subpluginType}_${componentName}`;
+
+                            componentData.components[`${subpluginTypePath}/${componentName}`] = frankenstyleName;
+                            componentData.pathList.push(componentPath);
+                        });
+                    });
+                }
+            });
+        });
+
+    }
+
+    return componentData;
+};
+
+/**
+ * Get the list of paths to build AMD sources.
+ *
+ * @returns {Array}
+ */
+const getAmdSrcGlobList = () => {
+    const globList = [];
+    fetchComponentData().pathList.forEach(componentPath => {
+        globList.push(`${componentPath}/amd/src/*.js`);
+        globList.push(`${componentPath}/amd/src/**/*.js`);
+    });
+
+    return globList;
+};
+
+/**
+ * Get the list of paths to build YUI sources.
+ *
+ * @param {String} relativeTo
+ * @returns {Array}
+ */
+const getYuiSrcGlobList = relativeTo => {
+    const globList = [];
+    fetchComponentData().pathList.forEach(componentPath => {
+        const relativeComponentPath = componentPath.replace(relativeTo, '');
+        globList.push(`${relativeComponentPath}/yui/src/**/*.js`);
+    });
+
+    return globList;
+};
+
+/**
+ * Get the list of paths to thirdpartylibs.xml.
+ *
+ * @param {String} relativeTo
+ * @returns {Array}
+ */
+const getThirdPartyLibsList = relativeTo => {
+    const fs = require('fs');
+
+    return fetchComponentData().pathList
+        .map(componentPath => componentPath.replace(relativeTo, '') + '/thirdpartylibs.xml')
+        .filter(path => fs.existsSync(path))
+        .sort();
+};
+
+/**
+ * Find the name of the component matching the specified path.
+ *
+ * @param {String} path
+ * @returns {String|null} Name of matching component.
+ */
+const getComponentFromPath = path => {
+    const componentList = fetchComponentData().components;
+
+    if (componentList.hasOwnProperty(path)) {
+        return componentList[path];
+    }
+
+    return null;
+};
+
+/**
+ * Check whether the supplied path, relative to the Gruntfile.js, is in a known component.
+ *
+ * @param {String} checkPath The path to check
+ * @returns {String|null}
+ */
+const getOwningComponentDirectory = checkPath => {
+    const path = require('path');
+
+    const pathList = fetchComponentData().components;
+    for (const componentPath of Object.keys(pathList)) {
+        if (checkPath === componentPath) {
+            return componentPath;
+        }
+        if (checkPath.startsWith(componentPath + path.sep)) {
+            return componentPath;
+        }
+    }
+
+    return null;
+};
+
+module.exports = {
+    getAmdSrcGlobList,
+    getComponentFromPath,
+    getOwningComponentDirectory,
+    getYuiSrcGlobList,
+    getThirdPartyLibsList,
+};
index c0e3d8d..6fb58c6 100644 (file)
@@ -158,19 +158,19 @@ class core_role_define_role_table_advanced extends core_role_capability_table_wi
         // Allowed roles.
         $allow = optional_param_array('allowassign', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowassign = $allow;
+            $this->allowassign = array_filter($allow);
         }
         $allow = optional_param_array('allowoverride', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowoverride = $allow;
+            $this->allowoverride = array_filter($allow);
         }
         $allow = optional_param_array('allowswitch', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowswitch = $allow;
+            $this->allowswitch = array_filter($allow);
         }
         $allow = optional_param_array('allowview', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowview = $allow;
+            $this->allowview = array_filter($allow);
         }
 
         // Now read the permissions for each capability.
@@ -619,7 +619,9 @@ class core_role_define_role_table_advanced extends core_role_capability_table_wi
         if ($this->roleid == 0) {
             $options[-1] = get_string('thisnewrole', 'core_role');
         }
-        return html_writer::select($options, 'allow'.$type.'[]', $selected, false, array('multiple' => 'multiple',
+        return
+            html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'allow'.$type.'[]', 'value' => "")) .
+            html_writer::select($options, 'allow'.$type.'[]', $selected, false, array('multiple' => 'multiple',
             'size' => 10, 'class' => 'form-control'));
     }
 
index 4b0f427..0d55217 100644 (file)
@@ -44,15 +44,10 @@ $temp = new admin_settingpage('sessionhandling', new lang_string('sessionhandlin
 if (empty($CFG->session_handler_class) and $DB->session_lock_supported()) {
     $temp->add(new admin_setting_configcheckbox('dbsessions', new lang_string('dbsessions', 'admin'), new lang_string('configdbsessions', 'admin'), 0));
 }
-$temp->add(new admin_setting_configselect('sessiontimeout', new lang_string('sessiontimeout', 'admin'), new lang_string('configsessiontimeout', 'admin'), 7200, array(14400 => new lang_string('numhours', '', 4),
-                                                                                                                                                      10800 => new lang_string('numhours', '', 3),
-                                                                                                                                                      7200 => new lang_string('numhours', '', 2),
-                                                                                                                                                      5400 => new lang_string('numhours', '', '1.5'),
-                                                                                                                                                      3600 => new lang_string('numminutes', '', 60),
-                                                                                                                                                      2700 => new lang_string('numminutes', '', 45),
-                                                                                                                                                      1800 => new lang_string('numminutes', '', 30),
-                                                                                                                                                      900 => new lang_string('numminutes', '', 15),
-                                                                                                                                                      300 => new lang_string('numminutes', '', 5))));
+
+$temp->add(new admin_setting_configduration('sessiontimeout', new lang_string('sessiontimeout', 'admin'),
+    new lang_string('configsessiontimeout', 'admin'), 8 * 60 * 60));
+
 $temp->add(new admin_setting_configtext('sessioncookie', new lang_string('sessioncookie', 'admin'), new lang_string('configsessioncookie', 'admin'), '', PARAM_ALPHANUM));
 $temp->add(new admin_setting_configtext('sessioncookiepath', new lang_string('sessioncookiepath', 'admin'), new lang_string('configsessioncookiepath', 'admin'), '', PARAM_RAW));
 $temp->add(new admin_setting_configtext('sessioncookiedomain', new lang_string('sessioncookiedomain', 'admin'), new lang_string('configsessioncookiedomain', 'admin'), '', PARAM_RAW, 50));
index 8cb2d66..7092e82 100644 (file)
     * size - form element size
     * value - form element value
     * id - element id
+    * forced - has value been defined in config.php
 
     Example context (json):
     {
         "name": "test",
         "id": "test0",
         "size": "8",
-        "value": "secret"
+        "value": "secret",
+        "forced": false
     }
 }}
+{{#forced}}
+    <div class="form-password">
+        <input type="text"
+               name = "{{ name }}"
+               id="{{ id }}"
+               value="********"
+               size="{{ size }}"
+               class="form-control"
+               disabled
+        >
+    </div>
+{{/forced}}
+{{^forced}}
 <div class="form-password">
     <span data-passwordunmask="wrapper" data-passwordunmaskid="{{ id }}">
         <span data-passwordunmask="editor">
@@ -61,3 +76,4 @@ require(['core_form/passwordunmask'], function(PasswordUnmask) {
     new PasswordUnmask("{{ id }}");
 });
 {{/js}}
+{{/forced}}
index 83e879a..2f02b45 100644 (file)
@@ -254,20 +254,19 @@ class tool_customlang_utils {
      *
      * @param string $component the name of the component
      * @param array $strings
+     * @return void
      */
     protected static function dump_strings($lang, $component, $strings) {
         global $CFG;
 
         if ($lang !== clean_param($lang, PARAM_LANG)) {
-            debugging('Unable to dump local strings for non-installed language pack .'.s($lang));
-            return false;
+            throw new moodle_exception('Unable to dump local strings for non-installed language pack .'.s($lang));
         }
         if ($component !== clean_param($component, PARAM_COMPONENT)) {
             throw new coding_exception('Incorrect component name');
         }
         if (!$filename = self::get_component_filename($component)) {
-            debugging('Unable to find the filename for the component '.s($component));
-            return false;
+            throw new moodle_exception('Unable to find the filename for the component '.s($component));
         }
         if ($filename !== clean_param($filename, PARAM_FILE)) {
             throw new coding_exception('Incorrect file name '.s($filename));
@@ -284,8 +283,7 @@ class tool_customlang_utils {
         }
 
         if (!$f = fopen($filepath, 'w')) {
-            debugging('Unable to write '.s($filepath));
-            return false;
+            throw new moodle_exception('Unable to write '.s($filepath));
         }
         fwrite($f, <<<EOF
 <?php
index 9ae8647..906dcb5 100644 (file)
@@ -258,7 +258,20 @@ class api {
         // The user making the request.
         $datarequest->set('requestedby', $requestinguser);
         // Set status.
-        $datarequest->set('status', self::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $status = self::DATAREQUEST_STATUS_AWAITING_APPROVAL;
+        if (self::is_automatic_request_approval_on($type)) {
+            // Set status to approved if automatic data request approval is enabled.
+            $status = self::DATAREQUEST_STATUS_APPROVED;
+            // Set the privacy officer field if the one making the data request is a privacy officer.
+            if (self::is_site_dpo($requestinguser)) {
+                $datarequest->set('dpo', $requestinguser);
+            }
+            // Mark this request as system approved.
+            $datarequest->set('systemapproved', true);
+            // No need to notify privacy officer(s) about automatically approved data requests.
+            $notify = false;
+        }
+        $datarequest->set('status', $status);
         // Set request type.
         $datarequest->set('type', $type);
         // Set request comments.
@@ -269,13 +282,22 @@ class api {
         // Store subject access request.
         $datarequest->create();
 
+        // Queue the ad-hoc task for automatically approved data requests.
+        if ($status == self::DATAREQUEST_STATUS_APPROVED) {
+            $userid = null;
+            if ($type == self::DATAREQUEST_TYPE_EXPORT) {
+                $userid = $foruser;
+            }
+            self::queue_data_request_task($datarequest->get('id'), $userid);
+        }
+
         if ($notify) {
             // Get the list of the site Data Protection Officers.
-            $dpos = api::get_site_dpos();
+            $dpos = self::get_site_dpos();
 
             // Email the data request to the Data Protection Officer(s)/Admin(s).
             foreach ($dpos as $dpo) {
-                api::notify_dpo($dpo, $datarequest);
+                self::notify_dpo($dpo, $datarequest);
             }
         }
 
@@ -624,12 +646,11 @@ class api {
         $result = self::update_request_status($requestid, self::DATAREQUEST_STATUS_APPROVED, $USER->id);
 
         // Fire an ad hoc task to initiate the data request process.
-        $task = new process_data_request_task();
-        $task->set_custom_data(['requestid' => $requestid]);
+        $userid = null;
         if ($request->get('type') == self::DATAREQUEST_TYPE_EXPORT) {
-            $task->set_userid($request->get('userid'));
+            $userid = $request->get('userid');
         }
-        manager::queue_adhoc_task($task, true);
+        self::queue_data_request_task($requestid, $userid);
 
         return $result;
     }
@@ -1277,4 +1298,35 @@ class api {
 
         return $formattedtime;
     }
+
+    /**
+     * Whether automatic data request approval is turned on or not for the given request type.
+     *
+     * @param int $type The request type.
+     * @return bool
+     */
+    public static function is_automatic_request_approval_on(int $type): bool {
+        switch ($type) {
+            case self::DATAREQUEST_TYPE_EXPORT:
+                return !empty(get_config('tool_dataprivacy', 'automaticdataexportapproval'));
+            case self::DATAREQUEST_TYPE_DELETE:
+                return !empty(get_config('tool_dataprivacy', 'automaticdatadeletionapproval'));
+        }
+        return false;
+    }
+
+    /**
+     * Creates an ad-hoc task for the data request.
+     *
+     * @param int $requestid The data request ID.
+     * @param int $userid Optional. The user ID to run the task as, if necessary.
+     */
+    public static function queue_data_request_task(int $requestid, int $userid = null): void {
+        $task = new process_data_request_task();
+        $task->set_custom_data(['requestid' => $requestid]);
+        if ($userid) {
+            $task->set_userid($userid);
+        }
+        manager::queue_adhoc_task($task, true);
+    }
 }
index 3901563..6a94fad 100644 (file)
@@ -118,6 +118,10 @@ class data_request extends persistent {
                 'type' => PARAM_INT,
                 'default' => FORMAT_PLAIN
             ],
+            'systemapproved' => [
+                'default' => false,
+                'type' => PARAM_BOOL,
+            ],
             'creationmethod' => [
                 'default' => self::DATAREQUEST_CREATION_MANUAL,
                 'choices' => [
index 032014c..bb29c6c 100644 (file)
@@ -146,14 +146,20 @@ class process_data_request_task extends adhoc_task {
         mtrace('The processing of the user data request has been completed...');
 
         // Create message to notify the user regarding the processing results.
-        $dpo = core_user::get_user($request->dpo);
         $message = new message();
         $message->courseid = $SITE->id;
         $message->component = 'tool_dataprivacy';
         $message->name = 'datarequestprocessingresults';
-        $message->userfrom = $dpo;
-        $message->replyto = $dpo->email;
-        $message->replytoname = fullname($dpo->email);
+        if (empty($request->dpo)) {
+            // Use the no-reply user as the sender if the privacy officer is not set. This is the case for automatically
+            // approved requests.
+            $fromuser = core_user::get_noreply_user();
+        } else {
+            $fromuser = core_user::get_user($request->dpo);
+            $message->replyto = $fromuser->email;
+            $message->replytoname = fullname($fromuser);
+        }
+        $message->userfrom = $fromuser;
 
         $typetext = null;
         // Prepare the context data for the email message body.
@@ -219,7 +225,7 @@ class process_data_request_task extends adhoc_task {
             if ($emailonly) {
                 // Do not sent an email if the user has been deleted. The user email has been previously deleted.
                 if (!$foruser->deleted) {
-                    $messagesent = email_to_user($foruser, $dpo, $subject, $message->fullmessage, $messagehtml);
+                    $messagesent = email_to_user($foruser, $fromuser, $subject, $message->fullmessage, $messagehtml);
                 }
             } else {
                 $messagesent = message_send($message);
@@ -265,7 +271,7 @@ class process_data_request_task extends adhoc_task {
 
                 // Send message.
                 if ($emailonly) {
-                    email_to_user($requestedby, $dpo, $subject, $message->fullmessage, $messagehtml);
+                    email_to_user($requestedby, $fromuser, $subject, $message->fullmessage, $messagehtml);
                 } else {
                     message_send($message);
                 }
index b32dc15..2124400 100644 (file)
@@ -94,7 +94,11 @@ if ($data = $mform->get_data()) {
     if ($manage) {
         $foruser = core_user::get_user($data->userid);
         $redirectmessage = get_string('datarequestcreatedforuser', 'tool_dataprivacy', fullname($foruser));
+    } else if (\tool_dataprivacy\api::is_automatic_request_approval_on($data->type)) {
+        // Let the user know that the request has been submitted and will be processed soon.
+        $redirectmessage = get_string('approvedrequestsubmitted', 'tool_dataprivacy');
     } else {
+        // Let the user know that the request has been submitted to the privacy officer.
         $redirectmessage = get_string('requestsubmitted', 'tool_dataprivacy');
     }
     redirect($returnurl, $redirectmessage);
index fc5e96b..a7b564c 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="admin/tool/dataprivacy/db" VERSION="20181107" COMMENT="XMLDB file for Moodle tool/dataprivacy"
+<XMLDB PATH="admin/tool/dataprivacy/db" VERSION="20191217" COMMENT="XMLDB file for Moodle tool/dataprivacy"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
@@ -16,6 +16,7 @@
         <FIELD NAME="dpo" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="The user ID of the Data Protection Officer who is reviewing th request"/>
         <FIELD NAME="dpocomment" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="DPO's comments (e.g. reason for rejecting the request, etc.)"/>
         <FIELD NAME="dpocommentformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="systemapproved" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The user who created/modified this request object"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The time this data request was created"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The last time this data request was updated"/>
index 7b2a9d8..e90e82a 100644 (file)
@@ -322,5 +322,20 @@ function xmldb_tool_dataprivacy_upgrade($oldversion) {
     // Automatically generated Moodle v3.8.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019121700) {
+
+        // Define field systemapproved to be added to tool_dataprivacy_request.
+        $table = new xmldb_table('tool_dataprivacy_request');
+        $field = new xmldb_field('systemapproved', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '0', 'dpocommentformat');
+
+        // Conditionally launch add field systemapproved.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Dataprivacy savepoint reached.
+        upgrade_plugin_savepoint(true, 2019121700, 'tool', 'dataprivacy');
+    }
+
     return true;
 }
index 5a0efd4..9a31758 100644 (file)
@@ -31,7 +31,12 @@ $string['addcategory'] = 'Add category';
 $string['addnewdefaults'] = 'Add a new module default';
 $string['addpurpose'] = 'Add purpose';
 $string['approve'] = 'Approve';
+$string['approvedrequestsubmitted'] = 'Your request has been submitted and will be processed soon';
 $string['approverequest'] = 'Approve request';
+$string['automaticdatadeletionapproval'] = 'Automatic data deletion request approval';
+$string['automaticdatadeletionapproval_desc'] = 'If enabled, data deletion requests are automatically approved.<br/>Note that the automatic approval will only apply to new data deletion requests with this setting enabled. Existing data deletion requests pending approval will still have to be manually approved by the privacy officer.';
+$string['automaticdataexportapproval'] = 'Automatic data export request approval';
+$string['automaticdataexportapproval_desc'] = 'If enabled, data export requests are automatically approved.<br/>Note that the automatic approval will only apply to new data export requests with this setting enabled. Existing data export requests pending approval will still have to be manually approved by the privacy officer.';
 $string['automaticdeletionrequests'] = 'Create automatic data deletion requests';
 $string['automaticdeletionrequests_desc'] = 'If enabled, a data deletion request will be created automatically for any user accounts deleted manually.';
 $string['bulkapproverequests'] = 'Approve requests';
index de3d715..0a335e3 100644 (file)
@@ -34,6 +34,16 @@ if ($hassiteconfig) {
                 new lang_string('contactdataprotectionofficer_desc', 'tool_dataprivacy'), 0)
         );
 
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/automaticdataexportapproval',
+                new lang_string('automaticdataexportapproval', 'tool_dataprivacy'),
+                new lang_string('automaticdataexportapproval_desc', 'tool_dataprivacy'), 0)
+        );
+
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/automaticdatadeletionapproval',
+                new lang_string('automaticdatadeletionapproval', 'tool_dataprivacy'),
+                new lang_string('automaticdatadeletionapproval_desc', 'tool_dataprivacy'), 0)
+        );
+
         // Automatically create delete data request for users upon user deletion.
         // Automatically create delete data request for pre-existing deleted users.
         // Enabled by default.
index a913f3e..7bf94cc 100644 (file)
@@ -301,6 +301,11 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $datarequest = api::create_data_request($student->id, api::DATAREQUEST_TYPE_EXPORT);
 
         $requestid = $datarequest->get('id');
+
+        // Login as a user without DPO role.
+        $this->setUser($teacher);
+        $this->expectException(required_capability_exception::class);
+        api::approve_data_request($requestid);
     }
 
     /**
@@ -550,32 +555,65 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     }
 
     /**
-     * Test for api::create_data_request()
+     * Data provider for data request creation tests.
+     *
+     * @return array
      */
-    public function test_create_data_request() {
-        $this->resetAfterTest();
-
-        $generator = new testing_data_generator();
-        $user = $generator->create_user();
-        $comment = 'sample comment';
-
-        // Login as user.
-        $this->setUser($user->id);
-
-        // Test data request creation.
-        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
-        $this->assertEquals($user->id, $datarequest->get('userid'));
-        $this->assertEquals($user->id, $datarequest->get('requestedby'));
-        $this->assertEquals(0, $datarequest->get('dpo'));
-        $this->assertEquals(api::DATAREQUEST_TYPE_EXPORT, $datarequest->get('type'));
-        $this->assertEquals(api::DATAREQUEST_STATUS_AWAITING_APPROVAL, $datarequest->get('status'));
-        $this->assertEquals($comment, $datarequest->get('comments'));
+    public function data_request_creation_provider() {
+        return [
+            'Export request by user, automatic approval off' => [
+                false, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Export request by user, automatic approval on' => [
+                false, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', true, 0,
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+            'Export request by PO, automatic approval off' => [
+                true, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Export request by PO, automatic approval on' => [
+                true, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', true, 'dpo',
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+            'Delete request by user, automatic approval off' => [
+                false, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Delete request by user, automatic approval on' => [
+                false, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', true, 0,
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+            'Delete request by PO, automatic approval off' => [
+                true, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Delete request by PO, automatic approval on' => [
+                true, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', true, 'dpo',
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+        ];
     }
 
     /**
-     * Test for api::create_data_request() made by DPO.
+     * Test for api::create_data_request()
+     *
+     * @dataProvider data_request_creation_provider
+     * @param bool $asprivacyofficer Whether the request is made as the Privacy Officer or the user itself.
+     * @param string $type The data request type.
+     * @param string $setting The automatic approval setting.
+     * @param bool $automaticapproval Whether automatic data request approval is turned on or not.
+     * @param int|string $expecteddpoval The expected value for the 'dpo' field. 'dpo' means we'd the expected value would be the
+     *                                   user ID of the privacy officer which happens in the case where a PO requests on behalf of
+     *                                   someone else and automatic data request approval is turned on.
+     * @param int $expectedstatus The expected status of the data request.
+     * @param int $expectedtaskcount The number of expected queued data requests tasks.
+     * @throws coding_exception
+     * @throws invalid_persistent_exception
      */
-    public function test_create_data_request_by_dpo() {
+    public function test_create_data_request($asprivacyofficer, $type, $setting, $automaticapproval, $expecteddpoval,
+                                             $expectedstatus, $expectedtaskcount) {
         global $USER;
 
         $this->resetAfterTest();
@@ -584,16 +622,34 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $user = $generator->create_user();
         $comment = 'sample comment';
 
-        // Login as DPO (Admin is DPO by default).
-        $this->setAdminUser();
+        // Login.
+        if ($asprivacyofficer) {
+            $this->setAdminUser();
+        } else {
+            $this->setUser($user->id);
+        }
+
+        // Set the automatic data request approval setting value.
+        set_config($setting, $automaticapproval, 'tool_dataprivacy');
+
+        // If set to 'dpo' use the currently logged-in user's ID (which should be the admin user's ID).
+        if ($expecteddpoval === 'dpo') {
+            $expecteddpoval = $USER->id;
+        }
 
         // Test data request creation.
-        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+        $datarequest = api::create_data_request($user->id, $type, $comment);
         $this->assertEquals($user->id, $datarequest->get('userid'));
         $this->assertEquals($USER->id, $datarequest->get('requestedby'));
-        $this->assertEquals(api::DATAREQUEST_TYPE_EXPORT, $datarequest->get('type'));
-        $this->assertEquals(api::DATAREQUEST_STATUS_AWAITING_APPROVAL, $datarequest->get('status'));
+        $this->assertEquals($expecteddpoval, $datarequest->get('dpo'));
+        $this->assertEquals($type, $datarequest->get('type'));
+        $this->assertEquals($expectedstatus, $datarequest->get('status'));
         $this->assertEquals($comment, $datarequest->get('comments'));
+        $this->assertEquals($automaticapproval, $datarequest->get('systemapproved'));
+
+        // Test number of queued data request tasks.
+        $datarequesttasks = manager::get_adhoc_tasks(process_data_request_task::class);
+        $this->assertCount($expectedtaskcount, $datarequesttasks);
     }
 
     /**
@@ -2250,4 +2306,88 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         set_config('siteadmins', $child->id);
         $this->assertFalse(api::can_create_data_deletion_request_for_children($child->id));
     }
+
+    /**
+     * Data provider function for testing \tool_dataprivacy\api::queue_data_request_task().
+     *
+     * @return array
+     */
+    public function queue_data_request_task_provider() {
+        return [
+            'With user ID provided' => [true],
+            'Without user ID provided' => [false],
+        ];
+    }
+
+    /**
+     * Test for \tool_dataprivacy\api::queue_data_request_task().
+     *
+     * @dataProvider queue_data_request_task_provider
+     * @param bool $withuserid
+     */
+    public function test_queue_data_request_task(bool $withuserid) {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        if ($withuserid) {
+            $user = $this->getDataGenerator()->create_user();
+            api::queue_data_request_task(1, $user->id);
+            $expecteduserid = $user->id;
+        } else {
+            api::queue_data_request_task(1);
+            $expecteduserid = null;
+        }
+
+        // Test number of queued data request tasks.
+        $datarequesttasks = manager::get_adhoc_tasks(process_data_request_task::class);
+        $this->assertCount(1, $datarequesttasks);
+        $requesttask = reset($datarequesttasks);
+        $this->assertEquals($expecteduserid, $requesttask->get_userid());
+    }
+
+    /**
+     * Data provider for test_is_automatic_request_approval_on().
+     */
+    public function automatic_request_approval_setting_provider() {
+        return [
+            'Data export, not set' => [
+                'automaticdataexportapproval', api::DATAREQUEST_TYPE_EXPORT, null, false
+            ],
+            'Data export, turned on' => [
+                'automaticdataexportapproval', api::DATAREQUEST_TYPE_EXPORT, true, true
+            ],
+            'Data export, turned off' => [
+                'automaticdataexportapproval', api::DATAREQUEST_TYPE_EXPORT, false, false
+            ],
+            'Data deletion, not set' => [
+                'automaticdatadeletionapproval', api::DATAREQUEST_TYPE_DELETE, null, false
+            ],
+            'Data deletion, turned on' => [
+                'automaticdatadeletionapproval', api::DATAREQUEST_TYPE_DELETE, true, true
+            ],
+            'Data deletion, turned off' => [
+                'automaticdatadeletionapproval', api::DATAREQUEST_TYPE_DELETE, false, false
+            ],
+        ];
+    }
+
+    /**
+     * Test for \tool_dataprivacy\api::is_automatic_request_approval_on().
+     *
+     * @dataProvider automatic_request_approval_setting_provider
+     * @param string $setting The automatic approval setting.
+     * @param int $type The data request type.
+     * @param bool $value The setting's value.
+     * @param bool $expected The expected result.
+     */
+    public function test_is_automatic_request_approval_on($setting, $type, $value, $expected) {
+        $this->resetAfterTest();
+
+        if ($value !== null) {
+            set_config($setting, $value, 'tool_dataprivacy');
+        }
+
+        $this->assertEquals($expected, api::is_automatic_request_approval_on($type));
+    }
 }
index 0a4da1b..4add17f 100644 (file)
@@ -240,3 +240,13 @@ Feature: Data delete from the privacy API
     And I reload the page
     And I open the action menu in "region-main" "region"
     Then I should not see "Resubmit as new request"
+
+  Scenario: Request data deletion as student with automatic approval turned on
+    Given the following config values are set as admin:
+      | automaticdatadeletionapproval | 1  | tool_dataprivacy |
+    And I log in as "victim"
+    And I follow "Profile" in the user menu
+    And I follow "Delete my account"
+    When I press "Save changes"
+    Then I should see "Your request has been submitted and will be processed soon"
+    And I should see "Approved" in the "Delete all of my personal data" "table_row"
index b651201..65fec34 100644 (file)
@@ -151,3 +151,13 @@ Feature: Data export from the privacy API
     When I reload the page
     And I set the field "Search" to "University2"
     Then I should see "Victim User 2"
+
+  Scenario: Request data export as student with automatic approval turned on
+    Given the following config values are set as admin:
+      | automaticdataexportapproval | 1  | tool_dataprivacy |
+    And I log in as "victim"
+    And I follow "Profile" in the user menu
+    And I follow "Export all of my personal data"
+    When I press "Save changes"
+    Then I should see "Your request has been submitted and will be processed soon"
+    And I should see "Approved" in the "Export all of my personal data" "table_row"
index 636f744..5242cdf 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2019111800;
+$plugin->version   = 2019121700;
 $plugin->requires  = 2019111200;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index 5d6f881..343a3eb 100644 (file)
@@ -61,7 +61,7 @@ if (!defined('E_USER_DEPRECATED')) {
 /**
  * phpCAS version. accessible for the user by phpCAS::getVersion().
  */
-define('PHPCAS_VERSION', '1.3.7+');
+define('PHPCAS_VERSION', '1.3.8');
 
 /**
  * @addtogroup public
index 338bd50..f06c154 100644 (file)
@@ -997,7 +997,18 @@ class CAS_Client
 
         // set to callback mode if PgtIou and PgtId CGI GET parameters are provided
         if ( $this->isProxy() ) {
-            $this->_setCallbackMode(!empty($_GET['pgtIou'])&&!empty($_GET['pgtId']));
+            if(!empty($_GET['pgtIou'])&&!empty($_GET['pgtId'])) {
+                $this->_setCallbackMode(true);
+                $this->_setCallbackModeUsingPost(false);
+            } elseif (!empty($_POST['pgtIou'])&&!empty($_POST['pgtId'])) {
+                $this->_setCallbackMode(true);
+                $this->_setCallbackModeUsingPost(true);
+            } else {
+                $this->_setCallbackMode(false);
+                $this->_setCallbackModeUsingPost(false);
+            }
+
+            
         }
 
         if ( $this->_isCallbackMode() ) {
@@ -2329,6 +2340,36 @@ class CAS_Client
         return $this->_callback_mode;
     }
 
+    /**
+     * @var bool a boolean to know if the CAS client is using POST parameters when in callback mode.
+     * Written by CAS_Client::_setCallbackModeUsingPost(), read by CAS_Client::_isCallbackModeUsingPost().
+     *
+     * @hideinitializer
+     */
+    private $_callback_mode_using_post = false;
+
+    /**
+     * This method sets/unsets usage of POST parameters in callback mode (default/false is GET parameters)
+     *
+     * @param bool $callback_mode_using_post true to use POST, false to use GET (default).
+     *
+     * @return void
+     */
+    private function _setCallbackModeUsingPost($callback_mode_using_post)
+    {
+        $this->_callback_mode_using_post = $callback_mode_using_post;
+    }
+
+    /**
+     * This method returns true when the callback mode is using POST, false otherwise.
+     *
+     * @return bool A boolean.
+     */
+    private function _isCallbackModeUsingPost()
+    {
+        return $this->_callback_mode_using_post;
+    }
+
     /**
      * the URL that should be used for the PGT callback (in fact the URL of the
      * current request without any CGI parameter). Written and read by
@@ -2387,23 +2428,39 @@ class CAS_Client
     private function _callback()
     {
         phpCAS::traceBegin();
-        if (preg_match('/^PGTIOU-[\.\-\w]+$/', $_GET['pgtIou'])) {
-            if (preg_match('/^[PT]GT-[\.\-\w]+$/', $_GET['pgtId'])) {
-                $this->printHTMLHeader('phpCAS callback');
-                $pgt_iou = $_GET['pgtIou'];
-                $pgt = $_GET['pgtId'];
-                phpCAS::trace('Storing PGT `'.$pgt.'\' (id=`'.$pgt_iou.'\')');
-                echo '<p>Storing PGT `'.$pgt.'\' (id=`'.$pgt_iou.'\').</p>';
-                $this->_storePGT($pgt, $pgt_iou);
-                $this->printHTMLFooter();
+        if ($this->_isCallbackModeUsingPost()) {
+            $pgtId = $_POST['pgtId'];
+            $pgtIou = $_POST['pgtIou'];
+        } else {
+            $pgtId = $_GET['pgtId'];
+            $pgtIou = $_GET['pgtIou'];
+        }
+        if (preg_match('/^PGTIOU-[\.\-\w]+$/', $pgtIou)) {
+            if (preg_match('/^[PT]GT-[\.\-\w]+$/', $pgtId)) {
+                phpCAS::trace('Storing PGT `'.$pgtId.'\' (id=`'.$pgtIou.'\')');
+                $this->_storePGT($pgtId, $pgtIou);
+                if (array_key_exists('HTTP_ACCEPT', $_SERVER) &&
+                    (   $_SERVER['HTTP_ACCEPT'] == 'application/xml' ||
+                        $_SERVER['HTTP_ACCEPT'] == 'text/xml'
+                    )
+                ) {
+                    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\r\n";
+                    echo '<proxySuccess xmlns="http://www.yale.edu/tp/cas" />';
+                    phpCAS::traceExit("XML response sent");
+                } else {
+                    $this->printHTMLHeader('phpCAS callback');
+                    echo '<p>Storing PGT `'.$pgtId.'\' (id=`'.$pgtIou.'\').</p>';
+                    $this->printHTMLFooter();
+                    phpCAS::traceExit("HTML response sent");
+                }
                 phpCAS::traceExit("Successfull Callback");
             } else {
-                phpCAS::error('PGT format invalid' . $_GET['pgtId']);
-                phpCAS::traceExit('PGT format invalid' . $_GET['pgtId']);
+                phpCAS::error('PGT format invalid' . $pgtId);
+                phpCAS::traceExit('PGT format invalid' . $pgtId);
             }
         } else {
-            phpCAS::error('PGTiou format invalid' . $_GET['pgtIou']);
-            phpCAS::traceExit('PGTiou format invalid' . $_GET['pgtIou']);
+            phpCAS::error('PGTiou format invalid' . $pgtIou);
+            phpCAS::traceExit('PGTiou format invalid' . $pgtIou);
         }
 
         // Flush the buffer to prevent from sending anything other then a 200
index 583c1dc..f425edc 100644 (file)
@@ -4,11 +4,16 @@ phpCAS
 phpCAS is an authentication library that allows PHP applications to easily authenticate
 users via a Central Authentication Service (CAS) server.
 
-Please see the phpCAS website for more information:
+Please see the wiki website for more information:
 
 https://wiki.jasig.org/display/CASC/phpCAS
 
-[![Build Status](https://travis-ci.org/Jasig/phpCAS.png)](https://travis-ci.org/Jasig/phpCAS)
+Api documentation can be found here:
+
+https://apereo.github.io/phpCAS/
+
+
+[![Build Status](https://travis-ci.org/apereo/phpCAS.png)](https://travis-ci.org/apereo/phpCAS)
 
 
 LICENSE
index 7894d1c..11cf506 100644 (file)
@@ -1,5 +1,3 @@
-Description of phpCAS 1.3.7 library import
+Description of phpCAS 1.3.8 library import
 
-* downloaded from http://downloads.jasig.org/cas-clients/php/current/
-* applied patch https://github.com/apereo/phpCAS/pull/247 for PHP 7.2 compatibility (MDL-60280)
-* applied patch https://github.com/apereo/phpCAS/pull/278 for PHP 7.3 compatibility (MDL-63422)
+* downloaded from http://downloads.jasig.org/cas-clients/php/current/
\ No newline at end of file
index cdbd8a7..dfe68c6 100644 (file)
 module.exports = ({template, types}) => {
     const fs = require('fs');
     const path = require('path');
-    const glob = require('glob');
     const cwd = process.cwd();
-
-    // Static variable to hold the modules.
-    let moodleSubsystems = null;
-    let moodlePlugins = null;
-
-    /**
-     * Parse Moodle's JSON files containing the lists of components.
-     *
-     * The values are stored in the static variables because we
-     * only need to load them once per transpiling run.
-     */
-    function loadMoodleModules() {
-        moodleSubsystems = {'lib': 'core'};
-        moodlePlugins = {};
-        let components = fs.readFileSync('lib/components.json');
-        components = JSON.parse(components);
-
-        for (const [component, path] of Object.entries(components.subsystems)) {
-            if (path) {
-                // Prefix "core_" to the front of the subsystems.
-                moodleSubsystems[path] = `core_${component}`;
-            }
-        }
-
-        for (const [component, path] of Object.entries(components.plugintypes)) {
-            if (path) {
-                moodlePlugins[path] = component;
-            }
-        }
-
-        for (const file of glob.sync('**/db/subplugins.json')) {
-            var rawContents = fs.readFileSync(file);
-            var subplugins = JSON.parse(rawContents);
-
-            for (const [component, path] of Object.entries(subplugins.plugintypes)) {
-                if (path) {
-                    moodlePlugins[path] = component;
-                }
-            }
-        }
-    }
+    const ComponentList = require(path.resolve('GruntfileComponents.js'));
 
     /**
      * Search the list of components that match the given file name
@@ -99,26 +58,14 @@ module.exports = ({template, types}) => {
         const fileName = file.replace('.js', '');
 
         // Check subsystems first which require an exact match.
-        if (moodleSubsystems.hasOwnProperty(componentPath)) {
-            return `${moodleSubsystems[componentPath]}/${fileName}`;
-        }
-
-        // It's not a subsystem so it must be a plugin. Moodle defines root folders
-        // where plugins can be installed so our path with be <plugin_root>/<plugin_name>.
-        // Let's separate the two.
-        let pathParts = componentPath.split('/');
-        const pluginName = pathParts.pop();
-        const pluginPath = pathParts.join('/');
-
-        // The plugin path mutch match exactly because some plugins are subplugins of
-        // other plugins which means their paths would partially match.
-        if (moodlePlugins.hasOwnProperty(pluginPath)) {
-            return `${moodlePlugins[pluginPath]}_${pluginName}/${fileName}`;
+        const componentName = ComponentList.getComponentFromPath(componentPath);
+        if (componentName) {
+            return `${componentName}/${fileName}`;
         }
 
         // This matches the previous PHP behaviour that would throw an exception
         // if it couldn't parse an AMD file.
-        throw new Error('Unable to find module name for ' + searchFileName);
+        throw new Error(`Unable to find module name for ${searchFileName} (${componentPath}::${file}}`);
     }
 
     /**
@@ -149,10 +96,6 @@ module.exports = ({template, types}) => {
         pre() {
             this.seenDefine = false;
             this.addedReturnForDefaultExport = false;
-
-            if (moodleSubsystems === null) {
-                loadMoodleModules();
-            }
         },
         visitor: {
             // Plugin ordering is only respected if we visit the "Program" node.
index cb40f59..dd70910 100644 (file)
@@ -91,7 +91,6 @@ class core_backup_external extends external_api {
             require_capability('moodle/backup:backupactivity', $context);
         } else {
             require_capability('moodle/backup:backupcourse', $context);
-            $instanceid = $course->id;
         }
 
         $results = array();
index 4ee134e..750a398 100644 (file)
@@ -55,9 +55,11 @@ require_capability('moodle/restore:restorecourse', $context);
 if (is_null($course)) {
     $coursefullname = $SITE->fullname;
     $courseshortname = $SITE->shortname;
+    $courseurl = new moodle_url('/');
 } else {
     $coursefullname = $course->fullname;
     $courseshortname = $course->shortname;
+    $courseurl = course_get_url($course->id);
 }
 
 // Show page header.
@@ -173,7 +175,6 @@ if ($restore->get_stage() != restore_ui::STAGE_PROCESS) {
     \core\task\manager::queue_adhoc_task($asynctask);
 
     // Add ajax progress bar and initiate ajax via a template.
-    $courseurl = new moodle_url('/course/view.php', array('id' => $course->id));
     $restoreurl = new moodle_url('/backup/restorefile.php', array('contextid' => $contextid));
     $progresssetup = array(
             'backupid' => $restoreid,
@@ -182,7 +183,6 @@ if ($restore->get_stage() != restore_ui::STAGE_PROCESS) {
             'restoreurl' => $restoreurl->out()
     );
     echo $renderer->render_from_template('core/async_backup_status', $progresssetup);
-
 }
 
 $restore->destroy();
index 43e9de8..f378c10 100644 (file)
@@ -3016,7 +3016,7 @@ function calendar_get_icalendar($url) {
  * Import events from an iCalendar object into a course calendar.
  *
  * @param iCalendar $ical The iCalendar object.
- * @param int $courseid The course ID for the calendar.
+ * @param int $unused Deprecated
  * @param int $subscriptionid The subscription ID.
  * @return string A log of the import progress, including errors.
  */
@@ -3066,7 +3066,8 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid
         }
     }
 
-    if (!empty($subscriptionid)) {
+    $existing = $DB->get_field('event_subscriptions', 'lastupdated', ['id' => $subscriptionid]);
+    if (!empty($existing)) {
         $eventsuuids = $DB->get_records_menu('event', ['subscriptionid' => $subscriptionid], '', 'id, uuid');
 
         $icaleventscount = count($icaluuids);
index 96af607..94a3cb4 100644 (file)
@@ -227,6 +227,24 @@ class core_calendar_lib_testcase extends advanced_testcase {
         calendar_import_icalendar_events($ical, null, $sub->id);
         $count = $DB->count_records('event', array('subscriptionid' => $sub->id));
         $this->assertEquals($count, 1);
+
+        // Test for ICS file with repeated events.
+        $subscription = new stdClass();
+        $subscription->name = 'Repeated events';
+        $subscription->importfrom = CALENDAR_IMPORT_FROM_FILE;
+        $subscription->eventtype = 'site';
+        $id = calendar_add_subscription($subscription);
+        $calendar = file_get_contents($CFG->dirroot . '/lib/tests/fixtures/repeated_events.ics');
+        $ical = new iCalendar();
+        $ical->unserialize($calendar);
+        $this->assertEquals($ical->parser_errors, []);
+
+        $sub = calendar_get_subscription($id);
+        $output = calendar_import_icalendar_events($ical, null, $sub->id);
+        $this->assertStringNotContainsString('Events deleted: 17', $output);
+        $this->assertStringContainsString('Events imported: 1', $output);
+        $this->assertStringContainsString('Events skipped: 0', $output);
+        $this->assertStringContainsString('Events updated: 0', $output);
     }
 
     /**
index 77c5a5a..7105753 100644 (file)
@@ -2302,7 +2302,7 @@ class core_competency_privacy_testcase extends provider_testcase {
         $this->assertEquals('-', $comp['rating']['rating']);
         $comp = $data->competencies[2];
         $this->assertEquals($comp4->get('shortname'), $comp['name']);
-        $this->assertNull($comp['rating']['rating']);
+        $this->assertNull($comp['rating']);
         $data = writer::with_context($u1ctx)->get_data(array_merge($path, ["{$p1a->get('name')} ({$p1a->get('id')})",
             get_string('commentsubcontext', 'core_comment')]));
         $this->assert_exported_comments(['Hello.', 'It\'s me.', 'After all these years...'], $data->comments);
@@ -2320,7 +2320,7 @@ class core_competency_privacy_testcase extends provider_testcase {
         $this->assertEquals('C', $comp['rating']['rating']);
         $comp = $data->competencies[2];
         $this->assertEquals($comp4->get('shortname'), $comp['name']);
-        $this->assertNull($comp['rating']['rating']);
+        $this->assertNull($comp['rating']);
 
         // This plan is complete.
         $data = writer::with_context($u1ctx)->get_data(array_merge($path, ["{$p1c->get('name')} ({$p1c->get('id')})"]));
index b64817b..632860a 100644 (file)
@@ -120,10 +120,12 @@ class enrol_meta_plugin extends enrol_plugin {
         require_once("$CFG->dirroot/enrol/meta/locallib.php");
 
         // Support creating multiple at once.
-        if (is_array($fields['customint1'])) {
+        if (isset($fields['customint1']) && is_array($fields['customint1'])) {
             $courses = array_unique($fields['customint1']);
-        } else {
+        } else if (isset($fields['customint1'])) {
             $courses = array($fields['customint1']);
+        } else {
+            $courses = array(null); // Strange? Yes, but that's how it's working or instance is not created ever.
         }
         foreach ($courses as $courseid) {
             if (!empty($fields['customint2']) && $fields['customint2'] == ENROL_META_CREATE_GROUP) {
index cc73e30..4692f4a 100644 (file)
Binary files a/grade/amd/build/edittree_index.min.js and b/grade/amd/build/edittree_index.min.js differ
index e3569f3..26c6df9 100644 (file)
Binary files a/grade/amd/build/edittree_index.min.js.map and b/grade/amd/build/edittree_index.min.js.map differ
index 9316f84..bb9aa79 100644 (file)
@@ -30,12 +30,6 @@ define([
      * @method edittree
      */
     var edittree = function() {
-        // Watch items and toggle the move menu accordingly.
-        $('body').on('change', '.itemselect.ignoredirty', edittree.checkMoveMenuState);
-
-        // Watch for the 'All' and 'None' links.
-        $('body').on('click', '[data-action="grade_edittree-index-bulkselect"]', edittree.toggleAllSelectItems);
-
         // Watch for the weight override checkboxes.
         $('body').on('change', '.weightoverride', edittree.toggleWeightInput);
 
@@ -47,9 +41,6 @@ define([
             bulkmove.val(1);
             form.submit();
         });
-
-        // CHeck the initial state of the move menu.
-        edittree.checkMoveMenuState();
     };
 
     /**
@@ -67,60 +58,6 @@ define([
         $('input[name="weight_' + row.data('itemid') + '"]').prop('disabled', !node.prop('checked'));
     };
 
-    /**
-     * Toggle all select boxes on or off.
-     *
-     * @method toggleAllSelectItems
-     * @param {EventFacade} e
-     * @private
-     */
-    edittree.toggleAllSelectItems = function(e) {
-        e.preventDefault();
-
-        var node = $(this),
-            row = node.closest('tr');
-        $('.' + row.data('category') + ' .itemselect').prop('checked', node.data('checked'));
-
-        edittree.checkMoveMenuState();
-    };
-
-    /**
-     * Get the move menu.
-     *
-     * @method getMoveMenu
-     * @private
-     * @return {jQuery}
-     */
-    edittree.getMoveMenu = function() {
-        return $('#menumoveafter');
-    };
-
-    /**
-     * Check whether any checkboxes are ticked.
-     *
-     * @method checkMoveMenuState
-     * @private
-     * @return {Boolean}
-     */
-    edittree.checkMoveMenuState = function() {
-        var menu = edittree.getMoveMenu();
-        if (!menu.length) {
-            return false;
-        }
-
-        var selected;
-        $('.itemselect').each(function() {
-            selected = $(this).prop('checked');
-
-            // Return early if any are checked.
-            return !selected;
-        });
-
-        menu.prop('disabled', !selected);
-
-        return selected;
-    };
-
     return /** @alias module:core_grades/edittree_index */ {
         enhance: edittree
     };
index 86cf33e..8eac5fe 100644 (file)
@@ -840,20 +840,39 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
     }
 
     public function get_category_cell($category, $levelclass, $params) {
+        global $OUTPUT;
+
         if (empty($params['eid'])) {
             throw new Exception('Array key (eid) missing from 3rd param of grade_edit_tree_column_select::get_category_cell($category, $levelclass, $params)');
         }
-        $selectall = html_writer::link('#', get_string('all'), [
-            'data-action' => 'grade_edittree-index-bulkselect',
-            'data-checked' => true,
-        ]);
-        $selectnone = html_writer::link('#', get_string('none'), [
-            'data-action' => 'grade_edittree-index-bulkselect',
-            'data-checked' => false,
+
+        // Get toggle group for this master checkbox.
+        $togglegroup = $this->get_checkbox_togglegroup($category);
+        // Set label for this master checkbox.
+        $masterlabel = get_string('all');
+        // Use category name if available.
+        if ($category->fullname !== '?') {
+            $masterlabel = format_string($category->fullname);
+            // Limit the displayed category name to prevent the Select column from getting too wide.
+            if (core_text::strlen($masterlabel) > 20) {
+                $masterlabel = get_string('textellipsis', 'core', core_text::substr($masterlabel, 0, 12));
+            }
+        }
+        // Build the master checkbox.
+        $mastercheckbox = new \core\output\checkbox_toggleall($togglegroup, true, [
+            'id' => $togglegroup,
+            'name' => $togglegroup,
+            'value' => 1,
+            'classes' => 'itemselect ignoredirty',
+            'label' => $masterlabel,
+            // Consistent label to prevent the select column from resizing.
+            'selectall' => $masterlabel,
+            'deselectall' => $masterlabel,
+            'labelclasses' => 'm-0',
         ]);
 
         $categorycell = parent::get_category_cell($category, $levelclass, $params);
-        $categorycell->text = $selectall . ' / ' . $selectnone;
+        $categorycell->text = $OUTPUT->render($mastercheckbox);
         return $categorycell;
     }
 
@@ -864,12 +883,43 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
         $itemcell = parent::get_item_cell($item, $params);
 
         if ($params['itemtype'] != 'course' && $params['itemtype'] != 'category') {
-            $itemcell->text = '<label class="accesshide" for="select_'.$params['eid'].'">'.
-                get_string('select', 'grades', $item->itemname).'</label>
-                <input class="itemselect ignoredirty" type="checkbox" name="select_'.$params['eid'].'" id="select_'.$params['eid'].
-                '"/>';
+            global $OUTPUT;
+
+            // Fetch the grade item's category.
+            $category = grade_category::fetch(['id' => $item->categoryid]);
+            $togglegroup = $this->get_checkbox_togglegroup($category);
+
+            $checkboxid = 'select_' . $params['eid'];
+            $checkbox = new \core\output\checkbox_toggleall($togglegroup, false, [
+                'id' => $checkboxid,
+                'name' => $checkboxid,
+                'label' => get_string('select', 'grades', $item->itemname),
+                'labelclasses' => 'accesshide',
+                'classes' => 'itemselect ignoredirty',
+            ]);
+            $itemcell->text = $OUTPUT->render($checkbox);
         }
         return $itemcell;
     }
+
+    /**
+     * Generates a toggle group name for a bulk-action checkbox based on the given grade category.
+     *
+     * @param grade_category $category The grade category.
+     * @return string
+     */
+    protected function get_checkbox_togglegroup(grade_category $category): string {
+        $levels = [];
+        $categories = explode('/', $category->path);
+        foreach ($categories as $categoryid) {
+            $level = 'category' . $categoryid;
+            if (!in_array($level, $levels)) {
+                $levels[] = 'category' . $categoryid;
+            }
+        }
+        $togglegroup = implode(' ', $levels);
+
+        return $togglegroup;
+    }
 }
 
index 5c4c539..5767d76 100644 (file)
 {{!
     @template core_grades/edit_tree
 
-    Edit tree.
+    Edit tree template.
+
+    Context variables required for this template:
+    * actionurl - string - Form action URL.
+    * sesskey - string - The session key.
+    * notification - object - Context data for the notification.
+    * showsave - boolean - Whether to show the save changes button.
+    * showbulkmove - boolean - Whether to show the bulk move select menu.
+    * table - string - HTML content of the grade items table.
+    * bulkmoveoptions - array - Key-value pair array for the options of the the bulk move select menu element.
 
     Example context (json):
     {
-        "actionurl": "https://domain.example/grade/edit/tree/index.php?id=4",
+        "actionurl": "#",
         "sesskey": "fakesesskey",
         "notification": "",
         "table": "<table class='generaltable simple setup-grades' id='grade_edit_tree_table'><thead> <tr><th>Name</th><th>Weights</th><th>Max grade</th><th>Actions</th> </tr></thead><tbody></tbody></table>",
@@ -48,7 +57,8 @@
                 <div class="form-inline mt-3">
                     <input type="hidden" name="bulkmove" value="0" id="bulkmoveinput">
                     <label for="menumoveafter">{{#str}}moveselectedto, grades{{/str}}</label>
-                    <select name="moveafter" id="menumoveafter" class="ignoredirty singleselect custom-select form-control">
+                    <select name="moveafter" id="menumoveafter" class="ignoredirty singleselect custom-select form-control"
+                            data-action="toggle" data-toggle="action" data-togglegroup="category" disabled>
                         {{#bulkmoveoptions}}
                             <option value="{{value}}">{{name}}</option>
                         {{/bulkmoveoptions}}
index 0b950d1..6c17f58 100644 (file)
@@ -1842,6 +1842,7 @@ $string['shortnameuser'] = 'User short name';
 $string['shortsitename'] = 'Short name for site (eg single word)';
 $string['show'] = 'Show';
 $string['showactions'] = 'Show actions';
+$string['showadvancededitor'] = 'Advanced';
 $string['showadvancedsettings'] = 'Show advanced settings';
 $string['showall'] = 'Show all {$a}';
 $string['showallcourses'] = 'Show all courses';
@@ -2002,6 +2003,7 @@ If \'plain text area\' is selected, a format for text input areas such as HTML o
 The list of available text editors is determined by the site administrator.';
 $string['texteditor'] = 'Use standard web forms';
 $string['textformat'] = 'Plain text format';
+$string['textellipsis'] = '{$a}...';
 $string['timesplitting:deciles'] = 'Last tenth';
 $string['timesplitting:deciles_help'] = 'This analysis interval divides the course into tenths (10 equal parts), with each prediction being based on the data of only the most recent previous tenth.';
 $string['timesplitting:decilesaccum'] = 'All previous tenths';
index 72edb50..96d071a 100644 (file)
@@ -423,6 +423,7 @@ $string['site:restore'] = 'Restore courses';
 $string['site:sendmessage'] = 'Send messages to any user';
 $string['site:trustcontent'] = 'Trust submitted content';
 $string['site:uploadusers'] = 'Upload new users from file';
+$string['site:viewanonymousevents'] = 'View anonymous events in reports';
 $string['site:viewfullnames'] = 'Always see full names of users';
 $string['site:viewparticipants'] = 'View participants';
 $string['site:viewreports'] = 'View reports';
index 047a574..da19957 100644 (file)
@@ -2641,13 +2641,25 @@ class admin_setting_configpasswordunmask extends admin_setting_configtext {
      * @return  string              Rendered HTML
      */
     public function output_html($data, $query='') {
-        global $OUTPUT;
+        global $OUTPUT, $CFG;
+        $forced = false;
+        if (empty($this->plugin)) {
+            if (array_key_exists($this->name, $CFG->config_php_settings)) {
+                $forced = true;
+            }
+        } else {
+            if (array_key_exists($this->plugin, $CFG->forced_plugin_settings)
+                and array_key_exists($this->name, $CFG->forced_plugin_settings[$this->plugin])) {
+                $forced = true;
+            }
+        }
         $context = (object) [
             'id' => $this->get_id(),
             'name' => $this->get_full_name(),
             'size' => $this->size,
-            'value' => $data,
+            'value' => $forced ? null : $data,
             'forceltr' => $this->get_force_ltr(),
+            'forced' => $forced
         ];
         $element = $OUTPUT->render_from_template('core_admin/setting_configpasswordunmask', $context);
         return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', null, $query);
index 0888103..456f345 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js and b/lib/amd/build/loglevel.min.js differ
index aa558f6..3fb6ea7 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js.map and b/lib/amd/build/loglevel.min.js.map differ
index a9e781f..ebe3aa2 100644 (file)
Binary files a/lib/amd/build/mustache.min.js and b/lib/amd/build/mustache.min.js differ
index 589186e..62d7036 100644 (file)
Binary files a/lib/amd/build/mustache.min.js.map and b/lib/amd/build/mustache.min.js.map differ
index 0c31946..e3d9fd7 100644 (file)
@@ -1,39 +1,32 @@
-// The MIT License
-//
 // Copyright (c) 2013 Tim Perry
 //
-// Permission is hereby granted, free of charge, to any person obtaining
-// a copy of this software and associated documentation files (the
-// "Software"), to deal in the Software without restriction, including
-// without limitation the rights to use, copy, modify, merge, publish,
-// distribute, sublicense, and/or sell copies of the Software, and to
-// permit persons to whom the Software is furnished to do so, subject to
-// the following conditions:
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
 //
 // The above copyright notice and this permission notice shall be
 // included in all copies or substantial portions of the Software.
 //
 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-//
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
 
 // Description of import into Moodle:
-// Download from https://github.com/pimterry/loglevel/dist
+// Download from https://github.com/pimterry/loglevel/tree/master/dist
 // Copy loglevel.js into lib/amd/src/ in Moodle folder.
 // Add the license as a comment to the file and these instructions.
 
-/*
- * loglevel - https://github.com/pimterry/loglevel
- *
- * Copyright (c) 2013 Tim Perry
- * Licensed under the MIT license.
- */
-/*! loglevel - v1.6.2 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
+/*! loglevel - v1.6.6 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
 (function (root, definition) {
     "use strict";
     if (typeof define === 'function' && define.amd) {
@@ -49,6 +42,9 @@
     // Slightly dubious tricks to cut down minimized file size
     var noop = function() {};
     var undefinedType = "undefined";
+    var isIE = (typeof window !== undefinedType) && (
+        /Trident\/|MSIE /.test(window.navigator.userAgent)
+    );
 
     var logMethods = [
         "trace",
         }
     }
 
+    // Trace() doesn't print the message in IE, so for that case we need to wrap it
+    function traceForIE() {
+        if (console.log) {
+            if (console.log.apply) {
+                console.log.apply(console, arguments);
+            } else {
+                // In old IE, native console methods themselves don't have apply().
+                Function.prototype.apply.apply(console.log, [console, arguments]);
+            }
+        }
+        if (console.trace) console.trace();
+    }
+
     // Build the best logging method possible for this env
     // Wherever possible we want to bind, not wrap, to preserve stack traces
     function realMethod(methodName) {
@@ -84,6 +93,8 @@
 
         if (typeof console === undefinedType) {
             return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives
+        } else if (methodName === 'trace' && isIE) {
+            return traceForIE;
         } else if (console[methodName] !== undefined) {
             return bindMethod(console, methodName);
         } else if (console.log !== undefined) {
     function defaultMethodFactory(methodName, level, loggerName) {
         /*jshint validthis:true */
         return realMethod(methodName) ||
-            enableLoggingWhenConsoleArrives.apply(this, arguments);
+               enableLoggingWhenConsoleArrives.apply(this, arguments);
     }
 
     function Logger(name, defaultLevel, factory) {
-        var self = this;
-        var currentLevel;
-        var storageKey = "loglevel";
-        if (name) {
-            storageKey += ":" + name;
-        }
-
-        function persistLevelIfPossible(levelNum) {
-            var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
-
-            if (typeof window === undefinedType) return;
-
-            // Use localStorage if available
-            try {
-                window.localStorage[storageKey] = levelName;
-                return;
-            } catch (ignore) {}
-
-            // Use session cookie as fallback
-            try {
-                window.document.cookie =
-                    encodeURIComponent(storageKey) + "=" + levelName + ";";
-            } catch (ignore) {}
-        }
-
-        function getPersistedLevel() {
-            var storedLevel;
-
-            if (typeof window === undefinedType) return;
-
-            try {
-                storedLevel = window.localStorage[storageKey];
-            } catch (ignore) {}
-
-            // Fallback to cookies if local storage gives us nothing
-            if (typeof storedLevel === undefinedType) {
-                try {
-                    var cookie = window.document.cookie;
-                    var location = cookie.indexOf(
-                        encodeURIComponent(storageKey) + "=");
-                    if (location !== -1) {
-                        storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
-                    }
-                } catch (ignore) {}
-            }
-
-            // If the stored level is not valid, treat it as if nothing was stored.
-            if (self.levels[storedLevel] === undefined) {
-                storedLevel = undefined;
-            }
-
-            return storedLevel;
-        }
-
-        /*
-         *
-         * Public logger API - see https://github.com/pimterry/loglevel for details
-         *
-         */
-
-        self.name = name;
-
-        self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
-            "ERROR": 4, "SILENT": 5};
-
-        self.methodFactory = factory || defaultMethodFactory;
-
-        self.getLevel = function () {
-            return currentLevel;
-        };
-
-        self.setLevel = function (level, persist) {
-            if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
-                level = self.levels[level.toUpperCase()];
-            }
-            if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
-                currentLevel = level;
-                if (persist !== false) {  // defaults to true
-                    persistLevelIfPossible(level);
-                }
-                replaceLoggingMethods.call(self, level, name);
-                if (typeof console === undefinedType && level < self.levels.SILENT) {
-                    return "No console available for logging";
-                }
-            } else {
-                throw "log.setLevel() called with invalid level: " + level;
-            }
-        };
-
-        self.setDefaultLevel = function (level) {
-            if (!getPersistedLevel()) {
-                self.setLevel(level, false);
-            }
-        };
-
-        self.enableAll = function(persist) {
-            self.setLevel(self.levels.TRACE, persist);
-        };
-
-        self.disableAll = function(persist) {
-            self.setLevel(self.levels.SILENT, persist);
-        };
-
-        // Initialize with the right level
-        var initialLevel = getPersistedLevel();
-        if (initialLevel == null) {
-            initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
-        }
-        self.setLevel(initialLevel, false);
+      var self = this;
+      var currentLevel;
+      var storageKey = "loglevel";
+      if (name) {
+        storageKey += ":" + name;
+      }
+
+      function persistLevelIfPossible(levelNum) {
+          var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
+
+          if (typeof window === undefinedType) return;
+
+          // Use localStorage if available
+          try {
+              window.localStorage[storageKey] = levelName;
+              return;
+          } catch (ignore) {}
+
+          // Use session cookie as fallback
+          try {
+              window.document.cookie =
+                encodeURIComponent(storageKey) + "=" + levelName + ";";
+          } catch (ignore) {}
+      }
+
+      function getPersistedLevel() {
+          var storedLevel;
+
+          if (typeof window === undefinedType) return;
+
+          try {
+              storedLevel = window.localStorage[storageKey];
+          } catch (ignore) {}
+
+          // Fallback to cookies if local storage gives us nothing
+          if (typeof storedLevel === undefinedType) {
+              try {
+                  var cookie = window.document.cookie;
+                  var location = cookie.indexOf(
+                      encodeURIComponent(storageKey) + "=");
+                  if (location !== -1) {
+                      storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
+                  }
+              } catch (ignore) {}
+          }
+
+          // If the stored level is not valid, treat it as if nothing was stored.
+          if (self.levels[storedLevel] === undefined) {
+              storedLevel = undefined;
+          }
+
+          return storedLevel;
+      }
+
+      /*
+       *
+       * Public logger API - see https://github.com/pimterry/loglevel for details
+       *
+       */
+
+      self.name = name;
+
+      self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
+          "ERROR": 4, "SILENT": 5};
+
+      self.methodFactory = factory || defaultMethodFactory;
+
+      self.getLevel = function () {
+          return currentLevel;
+      };
+
+      self.setLevel = function (level, persist) {
+          if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
+              level = self.levels[level.toUpperCase()];
+          }
+          if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
+              currentLevel = level;
+              if (persist !== false) {  // defaults to true
+                  persistLevelIfPossible(level);
+              }
+              replaceLoggingMethods.call(self, level, name);
+              if (typeof console === undefinedType && level < self.levels.SILENT) {
+                  return "No console available for logging";
+              }
+          } else {
+              throw "log.setLevel() called with invalid level: " + level;
+          }
+      };
+
+      self.setDefaultLevel = function (level) {
+          if (!getPersistedLevel()) {
+              self.setLevel(level, false);
+          }
+      };
+
+      self.enableAll = function(persist) {
+          self.setLevel(self.levels.TRACE, persist);
+      };
+
+      self.disableAll = function(persist) {
+          self.setLevel(self.levels.SILENT, persist);
+      };
+
+      // Initialize with the right level
+      var initialLevel = getPersistedLevel();
+      if (initialLevel == null) {
+          initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
+      }
+      self.setLevel(initialLevel, false);
     }
 
     /*
     var _loggersByName = {};
     defaultLogger.getLogger = function getLogger(name) {
         if (typeof name !== "string" || name === "") {
-            throw new TypeError("You must supply a name when creating a logger.");
+          throw new TypeError("You must supply a name when creating a logger.");
         }
 
         var logger = _loggersByName[name];
         if (!logger) {
-            logger = _loggersByName[name] = new Logger(
-                name, defaultLogger.getLevel(), defaultLogger.methodFactory);
+          logger = _loggersByName[name] = new Logger(
+            name, defaultLogger.getLevel(), defaultLogger.methodFactory);
         }
         return logger;
     };
     var _log = (typeof window !== undefinedType) ? window.log : undefined;
     defaultLogger.noConflict = function() {
         if (typeof window !== undefinedType &&
-            window.log === defaultLogger) {
+               window.log === defaultLogger) {
             window.log = _log;
         }
 
index 573ddca..360d7d1 100644 (file)
    * Tokens that are the root node of a subtree contain two more elements: 1) an
    * array of tokens in the subtree and 2) the index in the original template at
    * which the closing tag for that section begins.
+   *
+   * Tokens for partials also contain two more elements: 1) a string value of
+   * indendation prior to that tag and 2) the index of that tag on that line -
+   * eg a value of 2 indicates the partial is the third tag on this line.
    */
   function parseTemplate (template, tags) {
     if (!template)
       return [];
-
+    var lineHasNonSpace = false;
     var sections = [];     // Stack to hold section tokens
     var tokens = [];       // Buffer to hold the tokens
     var spaces = [];       // Indices of whitespace tokens on the current line
     var hasTag = false;    // Is there a {{tag}} on the current line?
     var nonSpace = false;  // Is there a non-space char on the current line?
+    var indentation = '';  // Tracks indentation for tags that use it
+    var tagIndex = 0;      // Stores a count of number of tags encountered on a line
 
     // Strips all whitespace tokens array for the current line
     // if there was a {{#tag}} on it and otherwise only space.
 
           if (isWhitespace(chr)) {
             spaces.push(tokens.length);
+            indentation += chr;
           } else {
             nonSpace = true;
+            lineHasNonSpace = true;
+            indentation += ' ';
           }
 
           tokens.push([ 'text', chr, start, start + 1 ]);
           start += 1;
 
           // Check for whitespace on the current line.
-          if (chr === '\n')
+          if (chr === '\n') {
             stripSpace();
+            indentation = '';
+            tagIndex = 0;
+            lineHasNonSpace = false;
+          }
         }
       }
 
       if (!scanner.scan(closingTagRe))
         throw new Error('Unclosed tag at ' + scanner.pos);
 
-      token = [ type, value, start, scanner.pos ];
+      if (type == '>') {
+        token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ];
+      } else {
+        token = [ type, value, start, scanner.pos ];
+      }
+      tagIndex++;
       tokens.push(token);
 
       if (type === '#' || type === '^' || type === '$' || type === '<') {
       }
     }
 
+    stripSpace();
+
     // Make sure there are no open sections when we're done.
     openSection = sections.pop();
 
       return this.renderTokens(token[4], context, partials, originalTemplate);
   };
 
+  Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) {
+    var filteredIndentation = indentation.replace(/[^ \t]/g, '');
+    var partialByNl = partial.split('\n');
+    for (var i = 0; i < partialByNl.length; i++) {
+      if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) {
+        partialByNl[i] = filteredIndentation + partialByNl[i];
+      }
+    }
+    return partialByNl.join('\n');
+  };
+
   Writer.prototype.renderPartial = function renderPartial (token, context, partials, tags) {
     if (!partials) return;
 
     var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
-    if (value != null)
-      return this.renderTokens(this.parse(value, tags), context, partials, value);
+    if (value != null) {
+      var lineHasNonSpace = token[6];
+      var tagIndex = token[5];
+      var indentation = token[4];
+      var indentedValue = value;
+      if (tagIndex == 0 && indentation) {
+        indentedValue = this.indentPartial(value, indentation, lineHasNonSpace);
+      }
+      return this.renderTokens(this.parse(indentedValue, tags), context, partials, indentedValue);
+    }
   };
 
   Writer.prototype.renderBlock = function renderBlock (token, context, partials, originalTemplate) {
 
     var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
     if (value != null)
-      // Ignore any wrongly set block vars before we started.
-      context.clearBlockVars();
-      // We are only rendering to record the default block variables.
-      this.renderTokens(token[4], context, partials, originalTemplate);
-      // Now we render and return the result.
-      var result = this.renderTokens(this.parse(value), context, partials, value);
-      // Don't leak the block variables outside this include.
+    // Ignore any wrongly set block vars before we started.
       context.clearBlockVars();
-      return result;
+    // We are only rendering to record the default block variables.
+    this.renderTokens(token[4], context, partials, originalTemplate);
+    // Now we render and return the result.
+    var result = this.renderTokens(this.parse(value), context, partials, value);
+    // Don't leak the block variables outside this include.
+    context.clearBlockVars();
+    return result;
   };
 
   Writer.prototype.renderBlockVariable = function renderBlockVariable (token, context, partials, originalTemplate) {
   };
 
   mustache.name = 'mustache.js';
-  mustache.version = '3.0.1';
+  mustache.version = '3.1.0';
   mustache.tags = [ '{{', '}}' ];
 
   // All high-level mustache.* functions use this writer.
index 2e1e042..bd3e8af 100644 (file)
@@ -43,6 +43,9 @@ abstract class adhoc_task extends task_base {
     /** @var integer|null $userid - Adhoc tasks may choose to run as a specific user. */
     private $userid = null;
 
+    /** @var \core\lock\lock The concurrency task lock for this task. */
+    private $concurrencylock = null;
+
     /**
      * Setter for $id.
      * @param int|null $id
@@ -107,4 +110,51 @@ abstract class adhoc_task extends task_base {
         $this->userid = $userid;
     }
 
+    /**
+     * Returns default concurrency limit for this task.
+     *
+     * @return int default concurrency limit
+     */
+    protected function get_default_concurrency_limit(): int {
+        global $CFG;
+
+        if (isset($CFG->task_concurrency_limit_default)) {
+            return (int) $CFG->task_concurrency_limit_default;
+        }
+        return 0;
+    }
+
+    /**
+     * Returns effective concurrency limit for this task.
+     *
+     * @return int effective concurrency limit for this task
+     */
+    final public function get_concurrency_limit(): int {
+        global $CFG;
+
+        $classname = get_class($this);
+
+        if (isset($CFG->task_concurrency_limit[$classname])) {
+            return (int) $CFG->task_concurrency_limit[$classname];
+        }
+        return $this->get_default_concurrency_limit();
+    }
+
+    /**
+     * Sets concurrency task lock.
+     *
+     * @param   \core\lock\lock $lock concurrency lock to be set
+     */
+    final public function set_concurrency_lock(\core\lock\lock $lock): void {
+        $this->concurrencylock = $lock;
+    }
+
+    /**
+     * Release the concurrency lock for this task type.
+     */
+    final public function release_concurrency_lock(): void {
+        if ($this->concurrencylock) {
+            $this->concurrencylock->release();
+        }
+    }
 }
index 091974b..ebe2abe 100644 (file)
@@ -552,9 +552,11 @@ class manager {
      * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
      *
      * @param int $timestart
+     * @param bool $checklimits Should we check limits?
      * @return \core\task\adhoc_task or null if not found
+     * @throws \moodle_exception
      */
-    public static function get_next_adhoc_task($timestart) {
+    public static function get_next_adhoc_task($timestart, $checklimits = true) {
         global $DB;
 
         $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
@@ -568,10 +570,16 @@ class manager {
             throw new \moodle_exception('locktimeout');
         }
 
+        $skipclasses = array();
+
         foreach ($records as $record) {
 
+            if (in_array($record->classname, $skipclasses)) {
+                // Skip the task if it can't be started due to per-task concurrency limit.
+                continue;
+            }
+
             if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
-                $classname = '\\' . $record->classname;
 
                 // Safety check, see if the task has been already processed by another cron run.
                 $record = $DB->get_record('task_adhoc', array('id' => $record->id));
@@ -587,6 +595,19 @@ class manager {
                     continue;
                 }
 
+                $tasklimit = $task->get_concurrency_limit();
+                if ($checklimits && $tasklimit > 0) {
+                    if ($concurrencylock = self::get_concurrent_task_lock($task)) {
+                        $task->set_concurrency_lock($concurrencylock);
+                    } else {
+                        // Unable to obtain a concurrency lock.
+                        mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
+                        $skipclasses[] = $record->classname;
+                        $lock->release();
+                        continue;
+                    }
+                }
+
                 $task->set_lock($lock);
                 if (!$task->is_blocking()) {
                     $cronlock->release();
@@ -609,6 +630,7 @@ class manager {
      *
      * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
      * @return \core\task\scheduled_task or null
+     * @throws \moodle_exception
      */
     public static function get_next_scheduled_task($timestart) {
         global $DB;
@@ -691,13 +713,13 @@ class manager {
             $delay = 86400;
         }
 
-        $classname = self::get_canonical_class_name($task);
-
+        // Reschedule and then release the locks.
         $task->set_next_run_time(time() + $delay);
         $task->set_fail_delay($delay);
         $record = self::record_from_adhoc_task($task);
         $DB->update_record('task_adhoc', $record);
 
+        $task->release_concurrency_lock();
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
@@ -721,7 +743,8 @@ class manager {
         // Delete the adhoc task record - it is finished.
         $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
 
-        // Reschedule and then release the locks.
+        // Release the locks.
+        $task->release_concurrency_lock();
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
@@ -858,4 +881,24 @@ class manager {
         }
         return $classname;
     }
+
+    /**
+     * Gets the concurrent lock required to run an adhoc task.
+     *
+     * @param   adhoc_task $task The task to obtain the lock for
+     * @return  \core\lock\lock The lock if one was obtained successfully
+     * @throws  \coding_exception
+     */
+    protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
+        $adhoclock = null;
+        $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
+
+        for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
+            if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
+                return $adhoclock;
+            }
+        }
+
+        return null;
+    }
 }
index db0193f..0adbcc9 100644 (file)
@@ -87,6 +87,7 @@ function cron_run() {
  * Execute all queued scheduled tasks, applying necessary concurrency limits and time limits.
  *
  * @param   int     $timenow The time this process started.
+ * @throws \moodle_exception
  */
 function cron_run_scheduled_tasks(int $timenow) {
     // Allow a restriction on the number of scheduled task runners at once.
@@ -96,7 +97,10 @@ function cron_run_scheduled_tasks(int $timenow) {
 
     $scheduledlock = null;
     for ($run = 0; $run < $maxruns; $run++) {
-        if ($scheduledlock = $cronlockfactory->get_lock("scheduled_task_runner_{$run}", 1)) {
+        // If we can't get a lock instantly it means runner N is already running
+        // so fail as fast as possible and try N+1 so we don't limit the speed at
+        // which we bring new runners into the pool.
+        if ($scheduledlock = $cronlockfactory->get_lock("scheduled_task_runner_{$run}", 0)) {
             break;
         }
     }
@@ -109,19 +113,21 @@ function cron_run_scheduled_tasks(int $timenow) {
     $starttime = time();
 
     // Run all scheduled tasks.
-    while (!\core\task\manager::static_caches_cleared_since($timenow) &&
-            $task = \core\task\manager::get_next_scheduled_task($timenow)) {
-        cron_run_inner_scheduled_task($task);
-        unset($task);
+    try {
+        while (!\core\task\manager::static_caches_cleared_since($timenow) &&
+                $task = \core\task\manager::get_next_scheduled_task($timenow)) {
+            cron_run_inner_scheduled_task($task);
+            unset($task);
 
-        if ((time() - $starttime) > $maxruntime) {
-            mtrace("Stopping processing of scheduled tasks as time limit has been reached.");
-            break;
+            if ((time() - $starttime) > $maxruntime) {
+                mtrace("Stopping processing of scheduled tasks as time limit has been reached.");
+                break;
+            }
         }
+    } finally {
+        // Release the scheduled task runner lock.
+        $scheduledlock->release();
     }
-
-    // Release the scheduled task runner lock.
-    $scheduledlock->release();
 }
 
 /**
@@ -130,6 +136,7 @@ function cron_run_scheduled_tasks(int $timenow) {
  * @param   int     $timenow The time this process started.
  * @param   int     $keepalive Keep this function alive for N seconds and poll for new adhoc tasks.
  * @param   bool    $checklimits Should we check limits?
+ * @throws \moodle_exception
  */
 function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true) {
     // Allow a restriction on the number of adhoc task runners at once.
@@ -140,7 +147,10 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
     if ($checklimits) {
         $adhoclock = null;
         for ($run = 0; $run < $maxruns; $run++) {
-            if ($adhoclock = $cronlockfactory->get_lock("adhoc_task_runner_{$run}", 1)) {
+            // If we can't get a lock instantly it means runner N is already running
+            // so fail as fast as possible and try N+1 so we don't limit the speed at
+            // which we bring new runners into the pool.
+            if ($adhoclock = $cronlockfactory->get_lock("adhoc_task_runner_{$run}", 0)) {
                 break;
             }
         }
@@ -168,7 +178,15 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
             break;
         }
 
-        $task = \core\task\manager::get_next_adhoc_task(time());
+        try {
+            $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits);
+        } catch (Exception $e) {
+            if ($adhoclock) {
+                // Release the adhoc task runner lock.
+                $adhoclock->release();
+            }
+            throw $e;
+        }
 
         if ($task) {
             if ($waiting) {
index 52946fa..2ca8e54 100644 (file)
@@ -1639,6 +1639,10 @@ function print_object($object) {
     if (CLI_SCRIPT) {
         fwrite(STDERR, print_r($object, true));
         fwrite(STDERR, PHP_EOL);
+    } else if (AJAX_SCRIPT) {
+        foreach (explode("\n", print_r($object, true)) as $line) {
+            error_log($line);
+        }
     } else {
         echo html_writer::tag('pre', s(print_r($object, true)), array('class' => 'notifytiny'));
     }
index dacaf5b..a970632 100644 (file)
@@ -391,6 +391,17 @@ $capabilities = array(
         )
     ),
 
+    'moodle/site:viewanonymousevents' => array(
+
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => array(
+            'teacher' => CAP_PROHIBIT,
+            'editingteacher' => CAP_PROHIBIT,
+            'manager' => CAP_ALLOW
+        )
+    ),
+
     'moodle/site:viewfullnames' => array(
 
         'captype' => 'read',
index be9692d..0722a2a 100644 (file)
@@ -125,8 +125,8 @@ function xmldb_main_install() {
         'backup_version'        => 2008111700,
         'backup_release'        => '2.0 dev',
         'mnet_dispatcher_mode'  => 'off',
-        'sessiontimeout'        => 7200, // must be present during roles installation
-        'stringfilters'         => '', // These two are managed in a strange way by the filters
+        'sessiontimeout'        => 8 * 60 * 60, // Must be present during roles installation.
+        'stringfilters'         => '', // These two are managed in a strange way by the filters.
         'filterall'             => 0, // setting page, so have to be initialised here.
         'texteditors'           => 'atto,tinymce,textarea',
         'antiviruses'           => '',
index cdf67fa..c8d7bb6 100644 (file)
Binary files a/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-debug.js and b/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-debug.js differ
index 2940890..e4a9c3b 100644 (file)
Binary files a/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-min.js and b/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-min.js differ
index 7c14998..89d365c 100644 (file)
Binary files a/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button.js and b/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button.js differ
index 1634ac7..ce6a4e2 100644 (file)
@@ -127,8 +127,11 @@ Y.namespace('M.atto_accessibilitychecker').Button = Y.Base.create('button', Y.M.
 
             // Check for non-empty text.
             if (Y.Lang.trim(node.get('text')) !== '') {
-                foreground = node.getComputedStyle('color');
-                background = node.getComputedStyle('backgroundColor');
+                foreground = Y.Color.fromArray(
+                    this._getComputedBackgroundColor(node, node.getComputedStyle('color')),
+                    Y.Color.TYPES.RGBA
+                );
+                background = Y.Color.fromArray(this._getComputedBackgroundColor(node), Y.Color.TYPES.RGBA);
 
                 lum1 = this._getLuminanceFromCssColor(foreground);
                 lum2 = this._getLuminanceFromCssColor(background);
@@ -237,7 +240,7 @@ Y.namespace('M.atto_accessibilitychecker').Button = Y.Base.create('button', Y.M.
      * Generate the HTML that lists the found warnings.
      *
      * @method _addWarnings
-     * @param {Node} A Node to append the html to.
+     * @param {Node} list Node to append the html to.
      * @param {String} description Description of this failure.
      * @param {array} nodes An array of failing nodes.
      * @param {boolean} imagewarnings true if the warnings are related to images, false if text.
@@ -307,5 +310,43 @@ Y.namespace('M.atto_accessibilitychecker').Button = Y.Base.create('button', Y.M.
             b1 = part1(color[2]);
 
         return 0.2126 * r1 + 0.7152 * g1 + 0.0722 * b1;
+    },
+
+    /**
+     * Get the computed RGB converted to full alpha value, considering the node hierarchy.
+     *
+     * @method _getComputedBackgroundColor
+     * @param {Node} node
+     * @param {String} color The initial colour. If not specified, fetches the backgroundColor from the node.
+     * @return {Array} Colour in Array form (RGBA)
+     * @private
+     */
+    _getComputedBackgroundColor: function(node, color) {
+        color = color || node.getComputedStyle('backgroundColor');
+
+        if (color.toLowerCase() === 'transparent') {
+            // Y.Color doesn't handle 'transparent' properly.
+            color = 'rgba(1, 1, 1, 0)';
+        }
+
+        // Convert the colour to its constituent parts in RGBA format, then fetch the alpha.
+        var colorParts = Y.Color.toArray(color);
+        var alpha = colorParts[3];
+
+        if (alpha === 1) {
+            // If the alpha of the background is already 1, then the parent background colour does not change anything.
+            return colorParts;
+        }
+
+        // Fetch the computed background colour of the parent and use it to calculate the RGB of this item.
+        var parentColor = this._getComputedBackgroundColor(node.get('parentNode'));
+        return [
+            // RGB = (alpha * R|G|B) + (1 - alpha * solid parent colour).
+            (1 - alpha) * parentColor[0] + alpha * colorParts[0],
+            (1 - alpha) * parentColor[1] + alpha * colorParts[1],
+            (1 - alpha) * parentColor[2] + alpha * colorParts[2],
+            // We always return a colour with full alpha.
+            1
+        ];
     }
 });
index 645728a..cc81222 100644 (file)
@@ -44,7 +44,7 @@ class editor_form extends moodleform {
      */
     protected function definition() {
         $mform = $this->_form;
-        $editoroptions = $this->_customdata['editoroptions'];
+        $editoroptions = $this->_customdata['editoroptions'] ?? null;
 
         // Add header.
         $mform->addElement('header', 'myheader', 'Editor in Moodle form');
index 3dcebbf..c8e98f5 100644 (file)
@@ -2219,20 +2219,20 @@ function readfile_accel($file, $mimetype, $accelerate) {
             if ($ranges) {
                 if (is_object($file)) {
                     $handle = $file->get_content_file_handle();
+                    if ($handle === false) {
+                        throw new file_exception('storedfilecannotreadfile', $file->get_filename());
+                    }
                 } else {
                     $handle = fopen($file, 'rb');
+                    if ($handle === false) {
+                        throw new file_exception('cannotopenfile', $file);
+                    }
                 }
                 byteserving_send_file($handle, $mimetype, $ranges, $filesize);
             }
         }
     }
 
-    header('Content-Length: '.$filesize);
-
-    if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
-        exit;
-    }
-
     if ($filesize > 10000000) {
         // for large files try to flush and close all buffers to conserve memory
         while(@ob_get_level()) {
@@ -2242,11 +2242,21 @@ function readfile_accel($file, $mimetype, $accelerate) {
         }
     }
 
+    // Send this header after we have flushed the buffers so that if we fail
+    // later can remove this because it wasn't sent.
+    header('Content-Length: ' . $filesize);
+
+    if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
+        exit;
+    }
+
     // send the whole file content
     if (is_object($file)) {
         $file->readfile();
     } else {
-        readfile_allow_large($file, $filesize);
+        if (readfile_allow_large($file, $filesize) === false) {
+            throw new file_exception('cannotopenfile', $file);
+        }
     }
 }
 
index ae1d362..5015696 100644 (file)
@@ -63,7 +63,9 @@ abstract class file_system {
         } else {
             $path = $this->get_remote_path_from_storedfile($file);
         }
-        readfile_allow_large($path, $file->get_filesize());
+        if (readfile_allow_large($path, $file->get_filesize()) === false) {
+            throw new file_exception('storedfilecannotreadfile', $file->get_filename());
+        }
     }
 
     /**
@@ -416,11 +418,16 @@ abstract class file_system {
     protected function get_imageinfo_from_path($path) {
         $imageinfo = getimagesize($path);
 
+        if (!is_array($imageinfo)) {
+            return false; // Nothing to process, the file was not recognised as image by GD.
+        }
+
         $image = array(
                 'width'     => $imageinfo[0],
                 'height'    => $imageinfo[1],
                 'mimetype'  => image_type_to_mime_type($imageinfo[2]),
             );
+
         if (empty($image['width']) or empty($image['height']) or empty($image['mimetype'])) {
             // GD can not parse it, sorry.
             return false;
index d1c8eee..91ed768 100644 (file)
@@ -200,7 +200,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 break;
 
             case 'createElement':
-                if ($arg[2]['optional']) {
+                if (!empty($arg[2]['optional'])) {
                     $caller->disabledIf($arg[0], $arg[0] . '[enabled]');
                 }
                 $caller->setType($arg[0] . '[number]', PARAM_FLOAT);
index 9e4e3b0..9c70b5a 100644 (file)
@@ -221,6 +221,8 @@ class MoodleQuickForm_filetypes extends MoodleQuickForm_group {
      */
     public function validateSubmitValue($value) {
 
+        $value = $value ?? ['filetypes' => null]; // A null $value can arrive here. Coalesce, creating the default array.
+
         if (!$this->allowall) {
             // Assert that there is an actual list provided.
             $normalized = $this->util->normalize_file_types($value['filetypes']);
index 447a822..870897d 100644 (file)
@@ -82,7 +82,8 @@ trait templatable_form_element {
         $context['type'] = $this->getType();
         $context['attributes'] = implode(' ', $otherattributes);
         $context['emptylabel'] = ($this->getLabel() === '');
-        $context['iderror'] = preg_replace('/^id_/', 'id_error_', $context['id']);
+        $context['iderror'] = preg_replace('/_id_/', '_id_error_', $context['id']);
+        $context['iderror'] = preg_replace('/^id_/', 'id_error_', $context['iderror']);
 
         // Elements with multiple values need array syntax.
         if ($this->getAttribute('multiple')) {
index 6bc4acf..586ce76 100644 (file)
@@ -11,3 +11,12 @@ unzip v2.12.0.zip
 cd mustache.php-2.12.0/
 mv src /path/to/moodle/lib/mustache/
 mv LICENSE /path/to/moodle/lib/mustache/
+
+Local changes:
+
+Note: All this changes need to be reviewed on every upgrade and, if they have
+been already applied upstream for the release being used, can be removed
+from the list. If still not available upstream, they will need to be re-applied.
+
+- MDL-67114: PHP 7.4 compatibility. Array operations on scalar value.
+  This corresponds to upstream https://github.com/bobthecow/mustache.php/pull/352
index c36a84a..0ec4192 100644 (file)
@@ -149,7 +149,7 @@ class Mustache_Parser
                 case Mustache_Tokenizer::T_BLOCK_VAR:
                     if ($this->pragmaBlocks) {
                         // BLOCKS pragma is enabled, let's do this!
-                        if ($parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
+                        if (isset($parent) && $parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
                             $token[Mustache_Tokenizer::TYPE] = Mustache_Tokenizer::T_BLOCK_ARG;
                         }
                         $this->clearStandaloneLines($nodes, $tokens);
@@ -275,7 +275,7 @@ class Mustache_Parser
      */
     private function checkIfTokenIsAllowedInParent($parent, array $token)
     {
-        if ($parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
+        if (isset($parent) && $parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
             throw new Mustache_Exception_SyntaxException('Illegal content in < parent tag', $token);
         }
     }
index 8f26740..75c5d67 100644 (file)
@@ -2664,11 +2664,15 @@ class Compiler
      * @param array   $value
      * @param boolean $inExp
      *
-     * @return array|\ScssPhp\ScssPhp\Node\Number
+     * @return null|array|\ScssPhp\ScssPhp\Node\Number
      */
     protected function reduce($value, $inExp = false)
     {
 
+        if (is_null($value)) {
+            return null;
+        }
+
         switch ($value[0]) {
             case Type::T_EXPRESSION:
                 list(, $op, $left, $right, $inParens) = $value;
index 592a066..f0b5311 100644 (file)
@@ -7,5 +7,14 @@ Import procedure:
 
 - Copy all the files from the folder 'src' this directory.
 - Copy the license file from the project root.
+- Review the local changes defined below, if any. Reapply
+  them if needed. If already available upstream, please remove
+  them from the list.
 
 Licensed under MIT, Copyright (c) 2015 Leaf Corcoran.
+
+Currenly using 1.0.2 plus these local changes:
+
+- MDL-67114 : Added basic compatibility with php 7.4. This corresponds to
+      upstream commit https://github.com/scssphp/scssphp/commit/66675c1553b7e9d7c480d8aaedbf7c72374647cf
+      that is available in scssphp >= 1.0.4
index b734e7c..e37a08e 100644 (file)
@@ -793,7 +793,7 @@ if (CLI_SCRIPT) {
 
 // Start session and prepare global $SESSION, $USER.
 if (empty($CFG->sessiontimeout)) {
-    $CFG->sessiontimeout = 7200;
+    $CFG->sessiontimeout = 8 * 60 * 60;
 }
 \core\session\manager::start();
 
index f291a6d..4aa3b9a 100644 (file)
@@ -362,6 +362,10 @@ function default_exception_handler($ex) {
 
     $info = get_exception_info($ex);
 
+    // If we already tried to send the header remove it, the content length
+    // should be either empty or the length of the error page.
+    @header_remove('Content-Length');
+
     if (is_early_init($info->backtrace)) {
         echo bootstrap_renderer::early_error($info->message, $info->moreinfourl, $info->link, $info->backtrace, $info->debuginfo, $info->errorcode);
     } else {
index c5d7961..f90f238 100644 (file)
@@ -362,4 +362,38 @@ class core_adhoc_task_testcase extends advanced_testcase {
 
         $this->assertEquals($user->id, $task->get_userid());
     }
+
+    /**
+     * Test get_concurrency_limit() method to return 0 by default.
+     */
+    public function test_get_concurrency_limit() {
+        $this->resetAfterTest(true);
+        $task = new \core\task\adhoc_test_task();
+        $concurrencylimit = $task->get_concurrency_limit();
+        $this->assertEquals(0, $concurrencylimit);
+    }
+
+    /**
+     * Test get_concurrency_limit() method to return a default value set in config.
+     */
+    public function test_get_concurrency_limit_default() {
+        $this->resetAfterTest(true);
+        set_config('task_concurrency_limit_default', 10);
+        $task = new \core\task\adhoc_test_task();
+        $concurrencylimit = $task->get_concurrency_limit();
+        $this->assertEquals(10, $concurrencylimit);
+    }
+
+    /**
+     * Test get_concurrency_limit() method to return a value for specific task class.
+     */
+    public function test_get_concurrency_limit_for_task() {
+        global $CFG;
+        $this->resetAfterTest(true);
+        set_config('task_concurrency_limit_default', 10);
+        $CFG->task_concurrency_limit = array('core\task\adhoc_test_task' => 5);
+        $task = new \core\task\adhoc_test_task();
+        $concurrencylimit = $task->get_concurrency_limit();
+        $this->assertEquals(5, $concurrencylimit);
+    }
 }
diff --git a/lib/tests/fixtures/repeated_events.ics b/lib/tests/fixtures/repeated_events.ics
new file mode 100644 (file)
index 0000000..7157007
--- /dev/null
@@ -0,0 +1,34 @@
+BEGIN:VCALENDAR\r
+PRODID:QIS-LSF HIS eG\r
+VERSION:2.0\r
+BEGIN:VTIMEZONE\r
+TZID:Europe/Berlin\r
+X-LIC-LOCATION:Europe/Berlin\r
+BEGIN:DAYLIGHT\r
+TZOFFSETFROM:+0100\r
+TZOFFSETTO:+0200\r
+TZNAME:CEST\r
+DTSTART:19700329T020000\r
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r
+END:DAYLIGHT\r
+BEGIN:STANDARD\r
+TZOFFSETFROM:+0200\r
+TZOFFSETTO:+0100\r
+TZNAME:CET\r
+DTSTART:19701025T030000\r
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r
+END:STANDARD\r
+END:VTIMEZONE\r
+METHOD:PUBLISH\r
+BEGIN:VEVENT\r
+DTSTART;TZID=Europe/Berlin:20191015T160000\r
+DTEND;TZID=Europe/Berlin:20191015T180000\r
+RRULE:FREQ=WEEKLY;UNTIL=20200211T235900Z;INTERVAL=1;BYDAY=TU\r
+LOCATION:O27 - 122\r
+DTSTAMP:20191002T151421Z\r
+UID:115808212972\r
+DESCRIPTION:\r
+SUMMARY:CS6307.000 - Introduction to Computer Science (for Non-Computer Scientists)\r
+CATEGORIES:Vorlesung/ Übung\r
+END:VEVENT\r
+END:VCALENDAR\r
index 0c71552..bbafd02 100644 (file)
@@ -835,7 +835,7 @@ class core_session_manager_testcase extends advanced_testcase {
         $SESSION->recentsessionlocks = $this->sessionlock_history();
 
         $page = \core\session\manager::get_locked_page_at($time);
-        $this->assertEquals($url, $page['url']);
+        $this->assertEquals($url, is_array($page) ? $page['url'] : null);
     }
 
     /**
index 42d5545..5630e29 100644 (file)
     <location>amd/src/loglevel.js</location>
     <name>loglevel.js</name>
     <license>MIT</license>
-    <version>1.6.2</version>
+    <version>1.6.6</version>
   </library>
   <library>
     <location>mustache</location>
     <location>amd/src/mustache.js</location>
     <name>Mustache.js</name>
     <license>MIT</license>
-    <version>3.0.1</version>
+    <version>3.1.0</version>
   </library>
   <library>
     <location>graphlib.php</location>
index 65cac74..dcb5e82 100644 (file)
@@ -11,6 +11,7 @@ information provided here is intended especially for developers.
   which means auto-detecting number of decimal points.
 * plagiarism_save_form_elements() has been deprecated. Please use {plugin name}_coursemodule_edit_post_actions() instead.
 * plagiarism_get_form_elements_module() has been deprecated. Please use {plugin name}_coursemodule_standard_elements() instead.
+* Changed default sessiontimeout to 8 hours to cover most normal working days
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index 0ed3f4c..4f6ac6f 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js differ
index 135ea41..a631e8e 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js differ
index 4851528..78a4fae 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js differ
index efe8cd0..9c2e8c3 100644 (file)
@@ -33,7 +33,7 @@ DIALOGUE = function(config) {
     // during widget instantiation, however, because we're creating it on the fly (and 'config.srcNode' isn't set yet), care must
     // be taken to add it to the DOM and to properly set the value of 'config.srcNode' before calling the parent constructor.
     // Note: additional classes can be added to this content node by setting the 'additionalBaseClass' config property (a string).
-    var id = 'moodle-dialogue-' + Y.stamp(this); // Can't use this.get('id') as it's not set at this stage.
+    var id = 'moodle-dialogue-' + Y.stamp(this) + '-wrap'; // Can't use this.get('id') as it's not set at this stage.
     config.notificationBase =
         Y.Node.create('<div class="' + CSS.BASE + '">')
               .append(Y.Node.create('<div id="' + id + '" role="dialog" ' +
index 5271dc1..c6daf6f 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js and b/message/amd/build/message_drawer_view_overview_section.min.js differ
index 32de90f..1331bfd 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js.map and b/message/amd/build/message_drawer_view_overview_section.min.js.map differ
index 8fa7398..fed38be 100644 (file)
@@ -223,7 +223,10 @@ function(
                 // If that's not possible, we'll report it under the catch-all 'other media'.
                 var messagePreview = $(lastMessage.text).text();
                 if (messagePreview) {
-                    return messagePreview;
+                    // The text value of the message must have no html/script tags.
+                    if (messagePreview.indexOf('<') == -1) {
+                        return messagePreview;
+                    }
                 }
             }
 
index 0ba0a6c..f667b43 100644 (file)
@@ -2539,7 +2539,7 @@ class assign {
         // Only ever send a max of one days worth of updates.
         $yesterday = time() - (24 * 3600);
         $timenow   = time();
-        $lastcron = $DB->get_field('modules', 'lastcron', array('name' => 'assign'));
+        $lastruntime = $DB->get_field('task_scheduled', 'lastruntime', array('component' => 'mod_assign'));
 
         // Collect all submissions that require mailing.
         // Submissions are included if all are true:
@@ -2711,10 +2711,10 @@ class assign {
         $sql = 'SELECT id
                     FROM {assign}
                     WHERE
-                        allowsubmissionsfromdate >= :lastcron AND
+                        allowsubmissionsfromdate >= :lastruntime AND
                         allowsubmissionsfromdate <= :timenow AND
                         alwaysshowdescription = 0';
-        $params = array('lastcron' => $lastcron, 'timenow' => $timenow);
+        $params = array('lastruntime' => $lastruntime, 'timenow' => $timenow);
         $newlyavailable = $DB->get_records_sql($sql, $params);
         foreach ($newlyavailable as $record) {
             $cm = get_coursemodule_from_instance('assign', $record->id, 0, false, MUST_EXIST);
index 4327cc0..af1ae2c 100644 (file)
@@ -295,6 +295,7 @@ class mod_feedback_responses_table extends table_sql {
         $columnscount = 0;
         $this->hasmorecolumns = max(0, count($items) - self::TABLEJOINLIMIT);
 
+        $headernamepostfix = !$this->is_downloading();
         // Add feedback response values.
         foreach ($items as $nr => $item) {
             if ($columnscount++ < self::TABLEJOINLIMIT) {
@@ -308,7 +309,7 @@ class mod_feedback_responses_table extends table_sql {
 
             $tablecolumns[] = "val{$nr}";
             $itemobj = feedback_get_item_class($item->typ);
-            $tableheaders[] = $itemobj->get_display_name($item);
+            $tableheaders[] = $itemobj->get_display_name($item, $headernamepostfix);
         }
 
         // Add 'Delete entry' column.
index d06fe79..d0887f0 100644 (file)
Binary files a/mod/forum/amd/build/local/layout/fullscreen.min.js and b/mod/forum/amd/build/local/layout/fullscreen.min.js differ
index 3c9cd52..9be0684 100644 (file)
Binary files a/mod/forum/amd/build/local/layout/fullscreen.min.js.map and b/mod/forum/amd/build/local/layout/fullscreen.min.js.map differ
index 038a42a..724629f 100644 (file)
@@ -38,7 +38,7 @@ const getComposedLayout = ({
     document.body.append(container);
     container.classList.add('layout');
     container.classList.add('fullscreen');
-    container.setAttribute('aria-role', 'application');
+    container.setAttribute('role', 'application');
     addToastRegion(container);
 
     // Lock scrolling on the document body.
index 93f995b..31352c0 100644 (file)
@@ -147,6 +147,7 @@ class author extends exporter {
         $authorcontextid = $this->authorcontextid;
         $urlfactory = $this->related['urlfactory'];
         $context = $this->related['context'];
+        $forum = $this->related['forum'];
 
         if ($this->canview) {
             if ($author->is_deleted()) {
@@ -156,7 +157,7 @@ class author extends exporter {
                     'isdeleted' => true,
                     'groups' => [],
                     'urls' => [
-                        'profile' => ($urlfactory->get_author_profile_url($author))->out(false),
+                        'profile' => ($urlfactory->get_author_profile_url($author, $forum->get_course_id()))->out(false),
                         'profileimage' => ($urlfactory->get_author_profile_image_url($author, $authorcontextid))->out(false)
                     ]
                 ];
@@ -192,7 +193,7 @@ class author extends exporter {
                     'isdeleted' => false,
                     'groups' => $groups,
                     'urls' => [
-                        'profile' => ($urlfactory->get_author_profile_url($author))->out(false),
+                        'profile' => ($urlfactory->get_author_profile_url($author, $forum->get_course_id()))->out(false),
                         'profileimage' => ($urlfactory->get_author_profile_image_url($author, $authorcontextid))->out(false)
                     ]
                 ];
@@ -220,7 +221,8 @@ class author extends exporter {
     protected static function define_related() {
         return [
             'urlfactory' => 'mod_forum\local\factories\url',
-            'context' => 'context'
+            'context' => 'context',
+            'forum' => 'mod_forum\local\entities\forum',
         ];
     }
 }
index 4637cb9..13a075f 100644 (file)
@@ -140,6 +140,7 @@ class discussion_summary extends exporter {
         $related = [
             'urlfactory' => $this->related['urlfactory'],
             'context' => $this->related['forum']->get_context(),
+            'forum' => $forum,
         ];
 
         $firstpostauthor = new author(
index cfb6b4b..caae3d7 100644 (file)
@@ -394,11 +394,13 @@ class url {
      * Get the url to view an author's profile.
      *
      * @param author_entity $author The author
+     * @param int $courseid The course id
      * @return moodle_url
      */
-    public function get_author_profile_url(author_entity $author) : moodle_url {
+    public function get_author_profile_url(author_entity $author, int $courseid) : moodle_url {
         return new moodle_url('/user/view.php', [
-            'id' => $author->get_id()
+            'id' => $author->get_id(),
+            'course' => $courseid
         ]);
     }
 
index d117ff6..82ac800 100644 (file)
@@ -340,6 +340,9 @@ class discussion {
                 $select = new url_select($forummenu, '',
                         ['/mod/forum/discuss.php?d=' . $discussion->get_id() => get_string("movethisdiscussionto", "forum")],
                         'forummenu', $movebutton);
+                $select->set_label(get_string('movethisdiscussionlabel', 'mod_forum'), [
+                    'class' => 'sr-only',
+                ]);
                 $html .= $this->renderer->render($select);
                 $html .= "</div>";
                 return $html;
index a2b20e1..9484681 100644 (file)
@@ -291,7 +291,7 @@ class mod_forum_post_form extends moodleform {
                 // Additional attribs to handle collapsible div.
                 ['data-toggle' => 'collapse', 'data-target' => "#collapseAddForm"]);
             $buttonarray[] = &$mform->createElement('submit', 'advancedadddiscussion',
-                get_string('advanced'), null, null, ['customclassoverride' => 'btn-link']);
+                get_string('showadvancededitor'), null, null, ['customclassoverride' => 'btn-link']);
 
             $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false);
             $mform->closeHeaderBefore('buttonar');
index 6d80dab..23788fd 100644 (file)
@@ -1319,7 +1319,7 @@ class mod_forum_external extends external_api {
             $preferredformat = editors_get_preferred_format();
             // If the post is not HTML and the preferred format is HTML, convert to it.
             if ($params['messageformat'] != FORMAT_HTML and $preferredformat == FORMAT_HTML) {
-                $params['message'] = format_text($params['message'], $params['messageformat'], ['context' => $context]);
+                $params['message'] = format_text($params['message'], $params['messageformat'], ['filter' => false]);
             }
             $params['messageformat'] = $preferredformat;
         }
index 422b100..df053be 100644 (file)
@@ -431,6 +431,7 @@ $string['modulename_link'] = 'mod/forum/view';
 $string['modulenameplural'] = 'Forums';
 $string['more'] = 'more';
 $string['movedmarker'] = '(Moved)';
+$string['movethisdiscussionlabel'] = 'Move the current discussion to the specified forum';
 $string['movethisdiscussionto'] = 'Move this discussion to ...';
 $string['mustprovidediscussionorpost'] = 'You must provide either a discussion id or post id to export';
 $string['myprofileownpost'] = 'My forum posts';
@@ -691,6 +692,7 @@ $string['timedhidden'] = 'Timed status: Hidden from students';
 $string['timedposts'] = 'Timed posts';
 $string['timedvisible'] = 'Timed status: Visible to all users';
 $string['timestartenderror'] = 'Display end date cannot be earlier than the start date';
+$string['togglediscussionmenu'] = 'Toggle the discussion menu';
 $string['togglefullscreen'] = 'Toggle full screen';
 $string['togglesettingsdrawer'] = 'Toggle settings drawer';
 $string['trackforum'] = 'Track unread posts';
index 23750ca..03aa1d9 100644 (file)
@@ -56,6 +56,7 @@
         aria-haspopup="true"
         tabindex="0"
         aria-controls="forum-action-menu-{{id}}-menu"
+        aria-label="{{#str}}togglediscussionmenu, mod_forum{{/str}}"
         aria-expanded="false">
         {{#settings.togglemoreicon}}
             {{#pix}} i/menu, core{{/pix}}
index 7b0eaa0..79fef67 100644 (file)
@@ -31,7 +31,7 @@
 }}
 {{< mod_forum/discussion_favourite_toggle }}
     {{$classes}}btn btn-link{{/classes}}
-    {{$role}}{{/role}}
+    {{$role}}button{{/role}}
     {{$favouritecontent}}
         {{#userstate.favourited}}
             {{#pix}}t/star, mod_forum, {{#str}}removefromfavourites, mod_forum{{/str}}{{/pix}}
index ff599a9..32b44d0 100644 (file)
@@ -36,7 +36,7 @@
     data-region="post"
     data-target="{{id}}-target"
     tabindex="0"
-    aria-labelledby="post-header-{{id}}"
+    aria-labelledby="post-header-{{id}}-{{uniqid}}"
     aria-describedby="post-content-{{id}}"
 >
     {{! The firstpost and starter classes below aren't used for anything other than to identify the first post in behat. }}
@@ -74,7 +74,7 @@
         </div>
 
         <div class="forum-post-core d-flex flex-column w-100"  data-region-content="forum-post-core">
-            <header id="post-header-{{uniqid}}">
+            <header id="post-header-{{id}}-{{uniqid}}">
                 {{^isdeleted}}
                     <div class="d-flex flex-wrap align-items-center mb-1">
                         <address class="mb-0 mr-2" tabindex="-1">
                                         <button
                                             class="btn btn-icon text-muted icon-no-margin icon-size-3"
                                             type="button"
-                                            id="post-actions-menu-{{uniqid}}"
+                                            id="post-actions-menu-{{id}}-{{uniqid}}"
                                             data-toggle="dropdown"
                                             aria-haspopup="true"
                                             aria-expanded="false"
                                             {{#pix}} i/moremenu {{/pix}}
                                         </button>
                                         <!-- inline style to fix RTL placement bug -->
-                                        <div class="dropdown-menu dropdown-menu-right rounded-lg shadow border-0" aria-labelledby="post-actions-menu-{{uniqid}}" style="right: auto">
+                                        <div class="dropdown-menu dropdown-menu-right rounded-lg shadow border-0" aria-labelledby="post-actions-menu-{{id}}-{{uniqid}}" style="right: auto" role="menubar">
                                             {{#capabilities}}
                                                 {{#view}}
                                                     <a
index 1854aac..91cdbfd 100644 (file)
@@ -36,7 +36,7 @@
     data-region="post"
     data-target="{{id}}-target"
     tabindex="0"
-    aria-labelledby="post-header-{{id}}"
+    aria-labelledby="post-header-{{id}}-{{uniqid}}"
     aria-describedby="post-content-{{id}}"
 >
     {{! The firstpost and starter classes below aren't used for anything other than to identify the first post in behat. }}
@@ -48,7 +48,7 @@
         {{#isfirstunread}}<a id="unread" aria-hidden="true"></a>{{/isfirstunread}}
 
         <div class="d-flex flex-column w-100"  data-region-content="forum-post-core">
-            <header  class="mb-2 header row d-flex">
+            <header id="post-header-{{id}}-{{uniqid}}" class="mb-2 header row d-flex">
                 {{^isdeleted}}
                     {{#author}}
                         <div class="mr-2" style="width: 45px;">
                                                     class="btn btn-link"
                                                     title="{{#str}} permanentlinktopost, mod_forum {{/str}}"
                                                     aria-label="{{#str}} permanentlinktopost, mod_forum {{/str}}"
+                                                    role="menuitem"
                                                 >
                                                     {{#str}} permalink, mod_forum {{/str}}
                                                 </a>
                                                         data-region="post-action"
                                                         href="{{{urls.markasread}}}"
                                                         class="btn btn-link"
+                                                        role="menuitem"
                                                     >
                                                         {{#str}} markread, mod_forum {{/str}}
                                                     </a>
                                                         data-region="post-action"
                                                         href="{{{urls.markasunread}}}"
                                                         class="btn btn-link"
+                                                        role="menuitem"
                                                     >
                                                         {{#str}} markunread, mod_forum {{/str}}
                                                     </a>
                                                     class="btn btn-link"
                                                     title="{{#str}} permanentlinktoparentpost, mod_forum {{/str}}"
                                                     aria-label="{{#str}} permanentlinktoparentpost, mod_forum {{/str}}"
+                                                    role="menuitem"
                                                 >
                                                     {{#str}} parent, mod_forum {{/str}}
                                                 </a>
                                                     data-region="post-action"
                                                     href="{{{urls.edit}}}"
                                                     class="btn btn-link"
+                                                    role="menuitem"
                                                 >
                                                     {{#str}} edit, mod_forum {{/str}}
                                                 </a>
                                                     data-region="post-action"
                                                     href="{{{urls.split}}}"
                                                     class="btn btn-link"
+                                                    role="menuitem"
                                                 >
                                                     {{#str}} prune, mod_forum {{/str}}
                                                 </a>
                                                     data-region="post-action"
                                                     href="{{{urls.delete}}}"
                                                     class="btn btn-link"
+                                                    role="menuitem"
                                                 >
                                                     {{#str}} delete, mod_forum {{/str}}
                                                 </a>
                                                         data-action="collapsible-link"
                                                         data-can-reply-privately="{{canreplyprivately}}"
                                                         title="{{#str}} reply, mod_forum {{/str}}"
+                                                        role="menuitem"
                                                     >
                                                         {{#str}} reply, mod_forum {{/str}}
                                                     </a>
                                                 {{#selfenrol}}
                                                     {{$replyoutput}}
                                                         <a
-                                                                href="{{{urls.reply}}}"
-                                                                class="btn btn-link"
-                                                                data-post-id="{{id}}"
-                                                                data-can-reply-privately="{{canreplyprivately}}"
-                                                                title="{{#str}} reply, mod_forum {{/str}}"
+                                                            href="{{{urls.reply}}}"
+                                                            class="btn btn-link"
+                                                            data-post-id="{{id}}"
+                                                            data-can-reply-privately="{{canreplyprivately}}"
+                                                            title="{{#str}} reply, mod_forum {{/str}}"
+                                                            role="menuitem"
                                                         >
                                                             {{#str}} reply, mod_forum {{/str}}
                                                         </a>
                                                     data-region="post-action"
                                                     href="{{{urls.export}}}"
                                                     class="btn btn-link"
+                                                    role="menuitem"
                                                 >
                                                     {{#str}} addtoportfolio, core_portfolio {{/str}}
                                                 </a>
index de8970c..ddcb978 100644 (file)
@@ -55,8 +55,8 @@
                 </button>
                 {{#canreplyprivately}}
                 <div class="form-check form-check-inline">
-                    <input type="checkbox" class="form-check-input" id="private-reply" title="{{#str}} privatereply, forum {{/str}}" name="privatereply"/>
-                    <label class="form-check-label" for="private-reply">{{#str}} privatereply, forum {{/str}}</label>
+                    <input type="checkbox" class="form-check-input" id="private-reply-checkbox-{{uniqid}}" title="{{#str}} privatereply, forum {{/str}}" name="privatereply"/>
+                    <label class="form-check-label" for="private-reply-checkbox-{{uniqid}}">{{#str}} privatereply, forum {{/str}}</label>
                 </div>
                 {{/canreplyprivately}}
                 <button title="{{#str}} advanced, core {{/str}}" data-action="forum-advanced-reply" class="btn btn-link float-right" type="submit">
@@ -65,4 +65,4 @@
             </div>
         </form>
     </div>
-</div>
\ No newline at end of file
+</div>
index 336a3be..b94c83d 100644 (file)
@@ -69,7 +69,7 @@
                     data-max-rows="10"
                 ></textarea>
                 <input type="hidden" name="postformat" value="{{postformat}}"/>
-                <input type="hidden" name="subject" value="{{#str}} inpagereplysubject, forum, {{parentsubject}} {{/str}}"/>
+                <input type="hidden" name="subject" value="{{parentsubject}}"/>
                 <input type="hidden" name="reply" value="{{postid}}"/>
                 <input type="hidden" name="sesskey" value="{{sesskey}}"/>
                 <div class="d-flex mt-3 align-items-center flex-wrap">
index cb900d3..706c74a 100644 (file)
@@ -33,6 +33,7 @@
     }
 }}
 {{<core/drawer}}
+    {{$drawerid}}grading-drawer-{{uniqid}}{{/drawerid}}
     {{$drawerclasses}}grader-grading-panel flex-shrink-0{{/drawerclasses}}
     {{$drawercontent}}
         <div class="h-100 w-100 bg-white d-flex flex-column">
@@ -52,7 +53,9 @@
                                 data-region="user-search-input"
                                 class="form-control form-control-lg"
                                 placeholder="{{#str}} searchusers, mod_forum {{/str}}"
+                                aria-labelledby="user-search-input-{{uniqid}}-label"
                             >
+                            <span id="user-search-input-{{uniqid}}-label" class="sr-only">{{#str}}searchusers, mod_forum{{/str}}</span>
                             <button
                                 class="toggle-search-button btn btn-icon icon-no-margin"
                                 aria-expanded="false"
@@ -74,7 +77,7 @@
                         <button
                             class="btn btn-icon icon-size-3 icon-no-margin colour-inherit"
                             data-action="collapse-grading-drawer"
-                            aria-controls="grading-drawer"
+                            aria-controls="grading-drawer-{{uniqid}}"
                             aria-expanded="true"
                             title="{{#str}} closebuttontitle, core {{/str}}"
                         >
                             {{>core/help_icon}}
                         {{/helpicon}}
                     </div>
-                    <div data-region="grade-errors" role="alert" aria-role="assertive"></div>
+                    <div data-region="grade-errors" role="alert" aria-live="assertive"></div>
                 </div>
             </div>
             <div class="hidden overflow-auto" data-region="search-results-container"></div>
         </div>
-        <div data-region="grade-errors" role="alert" aria-role="assertive"></div>
+        <div data-region="grade-errors" role="alert" aria-live="assertive"></div>
         <hr/>
     {{/drawercontent}}
 {{/core/drawer}}
index 8313ee7..6008663 100644 (file)
@@ -51,7 +51,7 @@
         <button
             class="btn btn-icon icon-no-margin drawer-button mr-1 active"
             data-action="expand-grading-drawer"
-            aria-controls="grading-drawer"
+            aria-controls="grading-drawer-{{uniqid}}"
             aria-expanded="true"
             type="button"
             title="{{#str}} showgraderpanel, mod_forum {{/str}}"
@@ -63,7 +63,7 @@
             class="btn btn-icon icon-no-margin drawer-button mr-1"
             aria-label="Open or close grader panel"
             data-action="collapse-grading-drawer"
-            aria-controls="grading-drawer"
+            aria-controls="grading-drawer-{{uniqid}}"
             aria-expanded="true"
             type="button"
             title="{{#str}} hidegraderpanel, mod_forum {{/str}}"
index d167882..6dfdb7e 100644 (file)
@@ -435,7 +435,7 @@ class behat_mod_forum extends behat_base {
         // Navigate to forum.
         $this->execute('behat_general::click_link', $this->escape($forumname));
         $this->execute('behat_general::click_link', $buttonstr);
-        $this->execute('behat_forms::press_button', get_string('advanced'));
+        $this->execute('behat_forms::press_button', get_string('showadvancededitor'));
 
         $this->fill_new_discussion_form($table);
     }
index 3b5dc57..7e85c10 100644 (file)
@@ -63,7 +63,8 @@ class mod_forum_exporters_author_testcase extends advanced_testcase {
 
         $exporter = new author_exporter($author, 1, [], true, [
             'urlfactory' => \mod_forum\local\container::get_url_factory(),
-            'context' => $context
+            'context' => $context,
+            'forum' => $forum,
         ]);
 
         $exportedauthor = $exporter->export($renderer);
@@ -104,7 +105,8 @@ class mod_forum_exporters_author_testcase extends advanced_testcase {
 
         $exporter = new author_exporter($author, 1, [$group], true, [
             'urlfactory' => \mod_forum\local\container::get_url_factory(),
-            'context' => $context
+            'context' => $context,
+            'forum' => $forum,
         ]);
 
         $exportedauthor = $exporter->export($renderer);
@@ -142,7 +144,8 @@ class mod_forum_exporters_author_testcase extends advanced_testcase {
 
         $exporter = new author_exporter($author, 1, [$group], false, [
             'urlfactory' => \mod_forum\local\container::get_url_factory(),
-            'context' => $context
+            'context' => $context,
+            'forum' => $forum,
         ]);
 
         $exportedauthor = $exporter->export($renderer);
index 1e3878d..3ba370a 100644 (file)
@@ -527,7 +527,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
      * Tests is similar to the get_forum_discussion_posts only utilizing the new return structure and entities
      */
     public function test_mod_forum_get_discussion_posts() {
-        global $CFG, $PAGE;
+        global $CFG;
 
         $this->resetAfterTest(true);
 
@@ -538,6 +538,9 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $legacyfactory = mod_forum\local\container::get_legacy_data_mapper_factory();
         $entityfactory = mod_forum\local\container::get_entity_factory();
 
+        // Create course to add the module.
+        $course1 = self::getDataGenerator()->create_course();
+
         // Create a user who can track forums.
         $record = new stdClass();
         $record->trackforums = true;
@@ -551,7 +554,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'isdeleted' => false,
             'groups' => [],
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user2entity),
+                'profile' => $urlfactory->get_author_profile_url($user2entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user2entity),
             ]
         ];
@@ -565,7 +568,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'groups' => [],
             'isdeleted' => false,
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user3entity),
+                'profile' => $urlfactory->get_author_profile_url($user3entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user3entity),
             ]
         ];
@@ -575,9 +578,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // Set the first created user to the test user.
         self::setUser($user1);
 
-        // Create course to add the module.
-        $course1 = self::getDataGenerator()->create_course();
-
         // Forum with tracking off.
         $record = new stdClass();
         $record->course = $course1->id;
@@ -652,7 +652,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'groups' => [],
             'isdeleted' => true,
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user3entity),
+                'profile' => $urlfactory->get_author_profile_url($user3entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user3entity),
             ]
         ];
@@ -2618,6 +2618,9 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $legacydatamapper = mod_forum\local\container::get_legacy_data_mapper_factory();
         $legacypostmapper = $legacydatamapper->get_post_data_mapper();
 
+        // Create course to add the module.
+        $course1 = self::getDataGenerator()->create_course();
+
         $user1 = self::getDataGenerator()->create_user();
         $user1entity = $entityfactory->get_author_from_stdclass($user1);
         $exporteduser1 = [
@@ -2625,7 +2628,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'fullname' => fullname($user1),
             'groups' => [],
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user1entity),
+                'profile' => $urlfactory->get_author_profile_url($user1entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user1entity),
             ],
             'isdeleted' => false,
@@ -2638,7 +2641,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'fullname' => fullname($user2),
             'groups' => [],
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user2entity),
+                'profile' => $urlfactory->get_author_profile_url($user2entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user2entity),
             ],
             'isdeleted' => false,
@@ -2650,9 +2653,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // Set the first created user to the test user.
         self::setUser($user1);
 
-        // Create course to add the module.
-        $course1 = self::getDataGenerator()->create_course();
-
         // Forum with tracking off.
         $record = new stdClass();
         $record->course = $course1->id;
index 2f37a14..22255c1 100644 (file)
@@ -10,7 +10,7 @@ class mod_glossary_import_form extends moodleform {
     function definition() {
         global $CFG;
         $mform =& $this->_form;
-        $cmid = $this->_customdata['id'];
+        $cmid = $this->_customdata['id'] ?? null;
 
         $mform->addElement('filepicker', 'file', get_string('filetoimport', 'glossary'));
         $mform->addHelpButton('file', 'filetoimport', 'glossary');
index fe53834..c8769f9 100644 (file)
@@ -10,8 +10,8 @@ class mod_wiki_comments_form extends moodleform {
     protected function definition() {
         $mform = $this->_form;
 
-        $current = $this->_customdata['current'];
-        $commentoptions = $this->_customdata['commentoptions'];
+        $current = $this->_customdata['current'] ?? null;
+        $commentoptions = $this->_customdata['commentoptions'] ?? null;
 
         // visible elements
         $mform->addElement('editor', 'entrycomment_editor', get_string('comment', 'glossary'), null, $commentoptions);
index 07a8df5..4fefa00 100644 (file)
@@ -38,17 +38,17 @@ class wiki_parser_proxy {
             return false;
         }
     }
-    
+
     public static function get_section(&$string, $type, $section, $all_content = false) {
         if(self::create_parser_instance($type)) {
             $content = self::$parsers[$type]->get_section($section, $string, true);
-            
+
             if($all_content) {
-               return $content;
+                return $content;
             }
             else {
-               return $content[1];
-               }
+                return is_array($content) ? $content[1] : null;
+            }
         }
         else {
             return false;
@@ -84,17 +84,17 @@ abstract class generic_parser {
     private $rulestack = array();
 
     protected $parser_status = 'Before';
-    
+
     /**
      * Dynamic return values
      */
-     
+
     protected $returnvalues = array();
 
     private $nowikiindex = array();
 
     protected $nowikitoken = "%!";
-    
+
     public function __construct() {}
 
     /**
@@ -115,7 +115,7 @@ abstract class generic_parser {
         if(method_exists($this, 'before_parsing')) {
             $this->before_parsing();
         }
-        
+
         $this->parser_status = 'Parsing';
 
         foreach($this->blockrules as $name => $block) {
@@ -129,7 +129,7 @@ abstract class generic_parser {
         if(method_exists($this, 'after_parsing')) {
             $this->after_parsing();
         }
-        
+
         return array('parsed_text' => $this->string) + $this->returnvalues;
     }
 
@@ -220,7 +220,7 @@ abstract class generic_parser {
             else {
                 $replace = parser_utils::h($rule['tag'], "$1");
             }
-            
+
             $text = preg_replace($rule['expression'], $replace, $text);
         }
     }
@@ -277,5 +277,5 @@ abstract class generic_parser {
          }
 
          return false;
-     }     
+     }
 }
index 606c30b..061df4a 100644 (file)
@@ -701,7 +701,7 @@ class question_usage_by_activity {
             // Behaviour vars should not be processed by question type, just add prefix.
             $behaviourvars = $this->get_question_attempt($slot)->get_behaviour()->get_expected_data();
             foreach (array_keys($responsedata) as $responsedatakey) {
-                if ($responsedatakey[0] === '-') {
+                if (is_string($responsedatakey) && $responsedatakey[0] === '-') {
                     $behaviourvarname = substr($responsedatakey, 1);
                     if (isset($behaviourvars[$behaviourvarname])) {
                         // Expected behaviour var found.
index f65aabb..845fac2 100644 (file)
@@ -504,7 +504,14 @@ class report_log_table_log extends table_sql {
 
         if (!($this->filterparams->logreader instanceof logstore_legacy\log\store)) {
             // Filter out anonymous actions, this is N/A for legacy log because it never stores them.
-            $joins[] = "anonymous = 0";
+            if ($this->filterparams->modid) {
+                $context = context_module::instance($this->filterparams->modid);
+            } else {
+                $context = context_course::instance($this->filterparams->courseid);
+            }
+            if (!has_capability('moodle/site:viewanonymousevents', $context)) {
+                $joins[] = "anonymous = 0";
+            }
         }
 
         $selector = implode(' AND ', $joins);
index 2c72b9b..d122d07 100644 (file)
@@ -180,7 +180,11 @@ class report_loglive_renderable implements renderable {
         $filter->logreader = $readers[$this->selectedlogreader];
         $filter->date = $this->date;
         $filter->orderby = $this->order;
-        $filter->anonymous = 0;
+
+        $context = context_course::instance($filter->courseid);
+        if (!has_capability('moodle/site:viewanonymousevents', $context)) {
+            $filter->anonymous = 0;
+        }
 
         return $filter;
     }
index 81724b5..dceee7d 100644 (file)
@@ -181,7 +181,7 @@ class dropbox extends \oauth2_client {
      * @throws  moodle_exception
      */
     protected function check_and_handle_api_errors($data) {
-        if ($this->info['http_code'] == 200) {
+        if (!is_array($this->info) or $this->info['http_code'] == 200) {
             // Dropbox only returns errors on non-200 response codes.
             return;
         }
index 8008038..2b710fc 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js and b/theme/boost/amd/build/loader.min.js differ
index 89b4410..f3c1ad4 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js.map and b/theme/boost/amd/build/loader.min.js.map differ
index ecc0d12..a81bc8d 100644 (file)
@@ -55,7 +55,8 @@ define(['jquery', './tether', 'core/event', 'core/custom_interaction_events'], f
             customEvents.events.escape,
         ]);
         jQuery('body').on(customEvents.events.escape, '[data-toggle=popover]', function() {
-            jQuery(this).popover('hide');
+            // Use "blur" instead of "popover('hide')" to prevent issue that the same tooltip can't be opened again.
+            jQuery(this).trigger('blur');
         });
 
         jQuery("html").popover({
index 45f88be..7b0ae6d 100644 (file)
@@ -28,7 +28,7 @@
 
         <ul class="nav navbar-nav ml-auto">
             <li class="nav-item">
-            {{{ output.secure_login_info }}}
+                {{{ output.secure_login_info }}}
             </li>
         </ul>
 </nav>
index 491d350..e3067d9 100644 (file)
             {{{ output.page_heading_menu }}}
         </ul>
         <ul class="nav navbar-nav ml-auto">
-            <div class="d-none d-lg-block">
-            {{{ output.search_box }}}
-        </div>
+            <li class="d-none d-lg-block">
+                {{{ output.search_box }}}
+            </li>
             <!-- navbar_plugin_output -->
             <li class="nav-item">
-            {{{ output.navbar_plugin_output }}}
+                {{{ output.navbar_plugin_output }}}
             </li>
             <!-- user_menu -->
             <li class="nav-item d-flex align-items-center">
index 55dd58a..3dc50c4 100644 (file)
@@ -39,7 +39,7 @@
 
     <ul class="nav navbar-nav ml-auto">
         <li class="nav-item">
-        {{{ output.secure_login_info }}}
+            {{{ output.secure_login_info }}}
         </li>
     </ul>
-</nav>
\ No newline at end of file
+</nav>
index 41e011e..c5cd4ab 100644 (file)
             {{{ output.page_heading_menu }}}
         </ul>
         <ul class="nav navbar-nav ml-auto">
-            <div class="d-none d-lg-block">
-            {{{ output.search_box }}}
-        </div>
+            <li class="d-none d-lg-block">
+                {{{ output.search_box }}}
+            </li>
             <!-- navbar_plugin_output -->
             <li class="nav-item">
-            {{{ output.navbar_plugin_output }}}
+                {{{ output.navbar_plugin_output }}}
             </li>
             <!-- user_menu -->
             <li class="nav-item d-flex align-items-center">
index 46e7a05..ec99292 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020010300.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020010900.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9dev (Build: 20200103)'; // Human-friendly version name
+$release  = '3.9dev (Build: 20200109)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.