php:
# We only run the highest and lowest supported versions to reduce the load on travis-ci.org.
- 7.3
- - 7.1.30 # Make this sticky because current default version (7.1.11) has a bug with redis-extension output (MDL-66062)
+ - 7.2
addons:
postgresql: "9.6"
* @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 + '/');
}
/**
* @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;
};
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: {
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: {
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'));
};
/**
grunt: true,
// Run from current working dir and inherit stdio from process.
opts: {
- cwd: cwd,
+ cwd: fullRunDir,
stdio: 'inherit'
},
args: [task, filesOption]
);
};
- 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;
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
});
// 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);
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.
return;
}
- grunt.log.ok('Listening for changes to files in ' + cwd);
+ grunt.log.ok('Listening for changes to files in ' + fullRunDir);
});
});
});
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);
--- /dev/null
+// 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,
+};
$return = optional_param('return','', PARAM_ALPHA);
$adminediting = optional_param('adminedit', -1, PARAM_BOOL);
-require_admin();
+require_login(0, false);
$PAGE->set_context(context_system::instance());
$PAGE->set_url('/admin/category.php', array('category' => $category));
$PAGE->set_pagetype('admin-setting-' . $category);
die;
}
+\core\local\cli\shutdown::script_supports_graceful_exit();
+
cron_run();
// Test plugin dependencies.
$failed = array();
-if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
cli_problem(get_string('pluginscheckfailed', 'admin', array('pluginslist' => implode(', ', array_unique($failed)))));
cli_error(get_string('pluginschecktodo', 'admin'));
}
</CUSTOM_CHECK>
</CUSTOM_CHECKS>
</MOODLE>
+ <MOODLE version="3.9" requires="3.5">
+ <UNICODE level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unicoderequired" />
+ </FEEDBACK>
+ </UNICODE>
+ <DATABASE level="required">
+ <VENDOR name="mariadb" version="10.2.29" />
+ <VENDOR name="mysql" version="5.6" />
+ <VENDOR name="postgres" version="9.5" />
+ <VENDOR name="mssql" version="11.0" />
+ <VENDOR name="oracle" version="11.2" />
+ </DATABASE>
+ <PHP version="7.2.0" level="required">
+ </PHP>
+ <PCREUNICODE level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="pcreunicodewarning" />
+ </FEEDBACK>
+ </PCREUNICODE>
+ <PHP_EXTENSIONS>
+ <PHP_EXTENSION name="iconv" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="iconvrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="mbstring" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="mbstringrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="curl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="curlrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="openssl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="opensslrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="tokenizer" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="tokenizerrecommended" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="xmlrpc" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="xmlrpcrecommended" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="soap" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="soaprecommended" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="ctype" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="ctyperequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="zip" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="ziprequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="zlib" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="gd" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="gdrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="simplexml" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="simplexmlrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="spl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="splrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="pcre" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="dom" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="xml" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="xmlreader" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="intl" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="intlrequired" />
+ </FEEDBACK>
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="json" level="required">
+ </PHP_EXTENSION>
+ <PHP_EXTENSION name="hash" level="required"/>
+ <PHP_EXTENSION name="fileinfo" level="required"/>
+ </PHP_EXTENSIONS>
+ <PHP_SETTINGS>
+ <PHP_SETTING name="memory_limit" value="96M" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="settingmemorylimit" />
+ </FEEDBACK>
+ </PHP_SETTING>
+ <PHP_SETTING name="file_uploads" value="1" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="settingfileuploads" />
+ </FEEDBACK>
+ </PHP_SETTING>
+ <PHP_SETTING name="opcache.enable" value="1" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="opcacherecommended" />
+ </FEEDBACK>
+ </PHP_SETTING>
+ </PHP_SETTINGS>
+ <CUSTOM_CHECKS>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_storage_engine" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddbstorageengine" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="question/engine/upgrade/upgradelib.php" function="quiz_attempts_upgraded" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="quizattemptsupgradedmessage" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_slasharguments" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="slashargumentswarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_tables_row_format" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="unsupporteddbtablerowformat" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_unoconv_version" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="unoconvwarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_libcurl_version" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="libcurlwarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_file_format" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddbfileformat" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_file_per_table" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddbfilepertable" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_large_prefix" level="required">
+ <FEEDBACK>
+ <ON_ERROR message="unsupporteddblargeprefix" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_is_https" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="ishttpswarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_incomplete_unicode_support" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="incompleteunicodesupport" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ <CUSTOM_CHECK file="lib/upgradelib.php" function="check_sixtyfour_bits" level="optional">
+ <FEEDBACK>
+ <ON_CHECK message="sixtyfourbitswarning" />
+ </FEEDBACK>
+ </CUSTOM_CHECK>
+ </CUSTOM_CHECKS>
+ </MOODLE>
</COMPATIBILITY_MATRIX>
// check plugin dependencies
$failed = array();
- if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+ if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
$PAGE->navbar->add(get_string('pluginscheck', 'admin'));
$PAGE->set_title($strinstallation);
$PAGE->set_heading($strinstallation . ' - Moodle ' . $CFG->target_release);
} else {
// Always verify plugin dependencies!
$failed = array();
- if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+ if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
die();
}
// Make sure plugin dependencies are always checked.
$failed = array();
- if (!$pluginman->all_plugins_ok($version, $failed)) {
+ if (!$pluginman->all_plugins_ok($version, $failed, $CFG->branch)) {
$output = $PAGE->get_renderer('core', 'admin');
echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
die();
if ($mnet_peer && !empty($mnet_peer->deleted)) {
$radioarray = array();
$radioarray[] = $mform->createElement('static', 'deletedinfo', '',
- $OUTPUT->container(get_string('deletedhostinfo', 'mnet'), 'deletedhostinfo'));
+ $OUTPUT->container(get_string('deletedhostinfo', 'mnet'), 'alert alert-warning'));
$radioarray[] = $mform->createElement('radio', 'deleted', '', get_string('yes'), 1);
$radioarray[] = $mform->createElement('radio', 'deleted', '', get_string('no'), 0);
$mform->addGroup($radioarray, 'radioar', get_string('deleted'), array(' ', ' '), false);
if ($version) {
$row[] = $version;
} else {
- $row[] = html_writer::tag('span', get_string('nodatabase', 'admin'), array('class' => 'disabled'));
+ $row[] = html_writer::tag('span', get_string('nodatabase', 'admin'), array('class' => 'text-muted'));
}
// Other question types required by this one.
if ($version) {
$row[] = $version;
} else {
- $row[] = html_writer::tag('span', get_string('nodatabase', 'admin'), array('class' => 'disabled'));
+ $row[] = html_writer::tag('span', get_string('nodatabase', 'admin'), array('class' => 'text-muted'));
}
// Other question types required by this one.
$out .= $this->output->container(get_string('cancelinstallinfodir', 'core_plugin', $pluginfo->rootdir));
if ($repotype = $pluginman->plugin_external_source($pluginfo->component)) {
$out .= $this->output->container(get_string('uninstalldeleteconfirmexternal', 'core_plugin', $repotype),
- 'uninstalldeleteconfirmexternal');
+ 'alert alert-warning mt-2');
}
}
if ($repotype = $pluginman->plugin_external_source($pluginfo->component)) {
$confirm .= $this->output->container(get_string('uninstalldeleteconfirmexternal', 'core_plugin', $repotype),
- 'uninstalldeleteconfirmexternal');
+ 'alert alert-warning mt-2');
}
// After any uninstall we must execute full upgrade to finish the cleanup!
* @return string HTML to output.
*/
protected function warning($message, $type = 'warning') {
- return $this->box($message, 'generalbox admin' . $type);
+ return $this->box($message, 'generalbox alert alert-' . $type);
}
/**
return $this->warning(get_string('datarootsecuritywarning', 'admin', $CFG->dataroot));
} else if ($insecuredataroot == INSECURE_DATAROOT_ERROR) {
- return $this->warning(get_string('datarootsecurityerror', 'admin', $CFG->dataroot), 'error');
+ return $this->warning(get_string('datarootsecurityerror', 'admin', $CFG->dataroot), 'danger');
} else {
return '';
if ($devlibdir) {
$moreinfo = new moodle_url('/report/security/index.php');
$warning = get_string('devlibdirpresent', 'core_admin', ['moreinfourl' => $moreinfo->out()]);
- return $this->warning($warning, 'error');
+ return $this->warning($warning, 'danger');
} else {
return '';
return $this->warning(
$this->container(get_string('maturitycorewarning', 'admin', $maturitylevel)) .
$this->container($this->doc_link('admin/versions', get_string('morehelp'))),
- 'error');
+ 'danger');
}
/*
}
$warning = (get_string('testsiteupgradewarning', 'admin', $testsite));
- return $this->warning($warning, 'error');
+ return $this->warning($warning, 'danger');
}
/**
$level = 'warning';
if ($maturity == MATURITY_ALPHA) {
- $level = 'error';
+ $level = 'danger';
}
$maturitylevel = get_string('maturity' . $maturity, 'admin');
protected function release_notes_link() {
$releasenoteslink = get_string('releasenoteslink', 'admin', 'http://docs.moodle.org/dev/Releases');
$releasenoteslink = str_replace('target="_blank"', 'onclick="this.target=\'_blank\'"', $releasenoteslink); // extremely ugly validation hack
- return $this->box($releasenoteslink, 'generalbox releasenoteslink');
+ return $this->box($releasenoteslink, 'generalbox alert alert-info');
}
/**
* @return string HTML code
*/
public function plugins_check_table(core_plugin_manager $pluginman, $version, array $options = array()) {
-
+ global $CFG;
$plugininfo = $pluginman->get_plugins();
if (empty($plugininfo)) {
}
$coredependency = $plugin->is_core_dependency_satisfied($version);
+ $incompatibledependency = $plugin->is_core_compatible_satisfied($CFG->branch);
+
$otherpluginsdependencies = $pluginman->are_dependencies_satisfied($plugin->get_other_required_plugins());
- $dependenciesok = $coredependency && $otherpluginsdependencies;
+ $dependenciesok = $coredependency && $otherpluginsdependencies && $incompatibledependency;
$statuscode = $plugin->get_status();
$row->attributes['class'] .= ' status-' . $statuscode;
}
$status = new html_table_cell($sourcelabel.' '.$status);
-
- $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version));
+ if ($plugin->pluginsupported != null) {
+ $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version, $CFG->branch));
+ } else {
+ $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version));
+ }
$statusisboring = in_array($statuscode, array(
core_plugin_manager::PLUGIN_STATUS_NODB, core_plugin_manager::PLUGIN_STATUS_UPTODATE));
* @param \core\plugininfo\base $plugin the plugin we are rendering the row for.
* @param core_plugin_manager $pluginman provides data on all the plugins.
* @param string $version
+ * @param int $branch the current Moodle branch
* @return string HTML code
*/
- protected function required_column(\core\plugininfo\base $plugin, core_plugin_manager $pluginman, $version) {
+ protected function required_column(\core\plugininfo\base $plugin, core_plugin_manager $pluginman, $version, $branch = null) {
$requires = array();
$displayuploadlink = false;
$displayupdateslink = false;
- foreach ($pluginman->resolve_requirements($plugin, $version) as $reqname => $reqinfo) {
+ $requirements = $pluginman->resolve_requirements($plugin, $version, $branch);
+ foreach ($requirements as $reqname => $reqinfo) {
if ($reqname === 'core') {
if ($reqinfo->status == $pluginman::REQUIREMENT_STATUS_OK) {
$class = 'requires-ok';
$class = 'requires-failed';
$label = html_writer::span(get_string('dependencyfails', 'core_plugin'), 'badge badge-danger');
}
- if ($reqinfo->reqver != ANY_VERSION) {
+
+ if ($branch != null && !$plugin->is_core_compatible_satisfied($branch)) {
+ $requires[] = html_writer::tag('li',
+ html_writer::span(get_string('incompatibleversion', 'core_plugin', $branch), 'dep dep-core').
+ ' '.$label, array('class' => $class));
+
+ } else if ($branch != null && $plugin->pluginsupported != null) {
+ $requires[] = html_writer::tag('li',
+ html_writer::span(get_string('moodlebranch', 'core_plugin',
+ array('min' => $plugin->pluginsupported[0], 'max' => $plugin->pluginsupported[1])), 'dep dep-core').
+ ' '.$label, array('class' => $class));
+
+ } else if ($reqinfo->reqver != ANY_VERSION) {
$requires[] = html_writer::tag('li',
html_writer::span(get_string('moodleversion', 'core_plugin', $plugin->versionrequires), 'dep dep-core').
' '.$label, array('class' => $class));
);
}
+ // Check if supports is present, and $branch is not in, only if $incompatible check was ok.
+ if ($plugin->pluginsupported != null && $class == 'requires-ok' && $branch != null) {
+ if ($pluginman->check_explicitly_supported($plugin, $branch) == $pluginman::VERSION_NOT_SUPPORTED) {
+ $out .= html_writer::div(get_string('notsupported', 'core_plugin', $branch));
+ }
+ }
+
return $out;
}
get_string('status'),
);
$servertable->colclasses = array('centeralign name', 'centeralign info', 'leftalign report', 'leftalign plugin', 'centeralign status');
- $servertable->attributes['class'] = 'admintable environmenttable generaltable';
+ $servertable->attributes['class'] = 'admintable environmenttable generaltable table-sm';
$servertable->id = 'serverstatus';
$serverdata = array('ok'=>array(), 'warn'=>array(), 'error'=>array());
get_string('status'),
);
$othertable->colclasses = array('aligncenter info', 'alignleft report', 'alignleft plugin', 'aligncenter status');
- $othertable->attributes['class'] = 'admintable environmenttable generaltable';
+ $othertable->attributes['class'] = 'admintable environmenttable generaltable table-sm';
$othertable->id = 'otherserverstatus';
$otherdata = array('ok'=>array(), 'warn'=>array(), 'error'=>array());
protected $id;
/** Added to the class="" attribute on output. */
- protected $classes = array('rolecap');
+ protected $classes = array('rolecap table-hover');
/** Default number of capabilities in the table for the search UI to be shown. */
const NUM_CAPS_FOR_SEARCH = 12;
$this->contextname = $contextname;
$this->stryes = get_string('yes');
$this->strno = get_string('no');
+ $this->add_classes(['table-striped']);
}
protected function add_header_cells() {
foreach ($levels as $level => $classname) {
$this->allcontextlevels[$level] = context_helper::get_level_name($level);
}
+ $this->add_classes(['table-striped']);
}
protected function load_current_permissions() {
// 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.
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'));
}
$rowattributes = parent::get_row_attributes($capability);
if ($this->permissions[$capability->name] !== 0) {
if (empty($rowattributes['class'])) {
- $rowattributes['class'] = "overriddenpermission";
+ $rowattributes['class'] = "overriddenpermission table-warning";
} else {
- $rowattributes['class'] .= " overriddenpermission";
+ $rowattributes['class'] .= " overriddenpermission table-warning";
}
}
return $rowattributes;
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));
<label {{#labelfor}}for="{{labelfor}}"{{/labelfor}}>
{{{title}}}
{{#override}}
- <div class="form-overridden">{{override}}</div>
+ <div class="alert alert-info">{{override}}</div>
{{/override}}
{{#warning}}
- <div class="form-warning">{{warning}}</div>
+ <div class="alert alert-warning">{{warning}}</div>
{{/warning}}
</label>
<span class="form-shortname d-block small text-muted">{{{name}}}</span>
* 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">
new PasswordUnmask("{{ id }}");
});
{{/js}}
+{{/forced}}
<h3 class="adminpagetitle"><a href="{{url}}">{{{title}}}</a></h3>
<ul class="adminpagepath" aria-label="{{#str}} pagepath, core {{/str}}">
{{#path}}
- <li>{{.}}</li>
+ <li class="small text-muted">{{.}}</li>
{{/path}}
</ul>
<fieldset class="adminsettings">
Background:
Given the following "users" exist:
- | username | firstname | lastname | email | auth | confirmed | lastip |
- | user1 | User | One | one@example.com | manual | 0 | 127.0.1.1 |
- | user2 | User | Two | two@example.com | ldap | 1 | 0.0.0.0 |
- | user3 | User | Three | three@example.com | manual | 1 | 0.0.0.0 |
- | user4 | User | Four | four@example.com | ldap | 0 | 127.0.1.2 |
+ | username | firstname | lastname | email | auth | confirmed | lastip | institution | department |
+ | user1 | User | One | one@example.com | manual | 0 | 127.0.1.1 | moodle | red |
+ | user2 | User | Two | two@example.com | ldap | 1 | 0.0.0.0 | moodle | blue |
+ | user3 | User | Three | three@example.com | manual | 1 | 0.0.0.0 | | |
+ | user4 | User | Four | four@example.com | ldap | 0 | 127.0.1.2 | | |
And the following "cohorts" exist:
| name | idnumber |
| Cohort 1 | CH1 |
And I should see "User Two"
And I should see "User Three"
And I should see "User Four"
+
+ Scenario: Filter users by institution and department
+ When I set the field "id_institution" to "moodle"
+ And I press "Add filter"
+ Then I should see "User One"
+ And I should see "User Two"
+ And I should not see "User Three"
+ And I should not see "User Four"
+ And I set the field "id_department" to "red"
+ And I press "Add filter"
+ And I should see "User One"
+ And I should not see "User Two"
\ No newline at end of file
Then the "groups" select box should contain "Group 1 (1)"
And the "groups" select box should contain "Group 2 (1)"
And I set the field "groups" to "Group 1 (1)"
- And the "members" select box should contain "Student 1"
+ And the "members" select box should contain "Student 1 (student1@example.com)"
And I set the field "groups" to "Group 2 (1)"
- And the "members" select box should contain "Student 2"
+ And the "members" select box should contain "Student 2 (student2@example.com)"
Scenario: Add cohorts and cohort members with data generator
Given the following "categories" exist:
And I navigate to "Users > Groups" in current page administration
# Select (multi-select & AJAX) - Checking "I set the field" and "select box should contain".
And I set the field "groups" to "Group 2"
- And the "members" select box should contain "Student 2"
- And the "members" select box should contain "Student 3"
- And the "members" select box should not contain "Student 1"
+ And the "members" select box should contain "Student 2 (s2@example.com)"
+ And the "members" select box should contain "Student 3 (s3@example.com)"
+ And the "members" select box should not contain "Student 1 (s1@example.com)"
And I set the field "groups" to "Group 1"
- And the "members" select box should contain "Student 1"
- And the "members" select box should contain "Student 2"
- And the "members" select box should not contain "Student 3"
+ And the "members" select box should contain "Student 1 (s1@example.com)"
+ And the "members" select box should contain "Student 2 (s2@example.com)"
+ And the "members" select box should not contain "Student 3 (s3@example.com)"
# Checkbox (AJAX) - Checking "I set the field" and "I set the following fields to these values".
And I am on "Course 1" course homepage
And I add a "Lesson" to section "1"
$form->addElement('select', 'roles', get_string('roleslabel', 'tool_capability'), $roles, $attributes);
$form->setType('roles', PARAM_TEXT);
+ $form->addElement('checkbox', 'onlydiff',
+ get_string('filters', 'tool_capability'),
+ get_string('onlydiff', 'tool_capability'));
+ $form->setType('onlydiff', PARAM_BOOL);
+
$form->addElement('submit', 'submitbutton', get_string('getreport', 'tool_capability'));
}
$rolestoshow = array();
$roleids = array('0');
$cleanedroleids = array();
+$onlydiff = false;
if ($data = $form->get_data()) {
$roleids = array();
}
}
}
+
+ if (isset($data->onlydiff)) {
+ $onlydiff = $data->onlydiff;
+ }
}
\tool_capability\event\report_viewed::create()->trigger();
// If we have a capability, generate the report.
if (count($capabilities) && count($rolestoshow)) {
/* @var tool_capability_renderer $renderer */
- echo $renderer->capability_comparison_table($capabilities, $context->id, $rolestoshow);
+ echo $renderer->capability_comparison_table($capabilities, $context->id, $rolestoshow, $onlydiff);
}
// Footer.
// If there are any role overrides here, print them.
if (!empty($contexts[$contextid]->rolecapabilities)) {
$rowcounter = 0;
- echo '<table class="generaltable rolecaps"><tbody>';
+ echo '<table class="generaltable table-striped"><tbody>';
foreach ($allroles as $role) {
if (isset($contexts[$contextid]->rolecapabilities[$role->id])) {
$permission = $contexts[$contextid]->rolecapabilities[$role->id];
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+$string['onlydiff'] = 'Show differences only';
$string['capabilitylabel'] = 'Capability:';
$string['capabilityreport'] = 'Capability overview';
$string['eventreportviewed'] = 'Report viewed';
+$string['filters'] = 'Filter results';
$string['forroles'] = 'For roles {$a}';
$string['getreport'] = 'Get the overview';
$string['changeoverrides'] = 'Change overrides in this context';
$string['changeroles'] = 'Change role definitions';
$string['intro'] = 'This report shows, for a particular capability, what permission that capability has in the definition of every role (or a selection of roles), and everywhere in the site where that capability is overridden.';
$string['pluginname'] = 'Capability overview';
+$string['nodifferences'] = 'There are no differences to show between selected roles in this context';
$string['reportforcapability'] = 'Report for capability \'{$a}\'';
$string['reportsettings'] = 'Report settings';
$string['roleslabel'] = 'Roles:';
* @param array $capabilities An array of capabilities to show comparison for.
* @param int $contextid The context we are displaying for.
* @param array $roles An array of roles to show comparison for.
+ * @param bool $onlydiff show only different permissions
* @return string
*/
- public function capability_comparison_table(array $capabilities, $contextid, array $roles) {
+ public function capability_comparison_table(array $capabilities, $contextid, array $roles, $onlydiff=false) {
$strpermissions = $this->get_permission_strings();
$permissionclasses = $this->get_permission_classes();
$row = new html_table_row(array($captitle));
+ $permissiontypes = array();
foreach ($roles as $role) {
if (isset($contexts[$contextid]->rolecapabilities[$role->id])) {
$permission = $contexts[$contextid]->rolecapabilities[$role->id];
} else {
$permission = CAP_INHERIT;
}
+ if (!in_array($permission, $permissiontypes)) {
+ $permissiontypes[] = $permission;
+ }
$cell = new html_table_cell($strpermissions[$permission]);
$cell->attributes['class'] = $permissionclasses[$permission];
$row->cells[] = $cell;
}
-
- $table->data[] = $row;
+ if (!$onlydiff || count($permissiontypes) > 1) {
+ $table->data[] = $row;
+ }
}
// Start the list item, and print the context name as a link to the place to make changes.
$title = get_string('permissionsincontext', 'core_role', $context->get_context_name());
$html = $this->output->heading(html_writer::link($url, $title), 3);
- $html .= html_writer::table($table);
+ if (!empty($table->data)) {
+ $html .= html_writer::table($table);
+ } else {
+ $html .= html_writer::tag('p', get_string('nodifferences', 'tool_capability'));
+ }
// If there are any child contexts, print them recursively.
if (!empty($contexts[$contextid]->children)) {
foreach ($contexts[$contextid]->children as $childcontextid) {
- $html .= $this->capability_comparison_table($capabilities, $childcontextid, $roles, true);
+ $html .= $this->capability_comparison_table($capabilities, $childcontextid, $roles, $onlydiff);
}
}
return $html;
--- /dev/null
+@tool @tool_capability
+Feature: show capabilities for selected roles
+ In order to check roles capabilities
+ As an admin
+ I need to be able to customize capabilities report viewing only specific roles and capabilities
+
+ Background:
+ Given the following "roles" exist:
+ | shortname | name | archetype |
+ | studenteq | Studenteq | student |
+ | studentdf | Studentdf | student |
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | moodle/course:changefullname | Allow | studentdf | System | |
+ | moodle/course:changeshortname | Prohibit | studentdf | System | |
+ | moodle/course:changeidnumber | Prevent | studentdf | System | |
+ And I log in as "admin"
+ And I navigate to "Users > Permissions > Capability overview" in site administration
+
+ Scenario: visualize capabilities table with a limited number of capabilities
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changefullname, moodle/course:changeshortname |
+ | Roles: | Studentdf |
+ And I click on "Get the overview" "button"
+ Then I should see "moodle/course:changefullname" in the "comparisontable" "table"
+ And I should see "moodle/course:changeshortname" in the "comparisontable" "table"
+ And I should not see "moodle/course:changecategory" in the "comparisontable" "table"
+
+ Scenario: visualize an allow capability
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changefullname |
+ | Roles: | Studentdf |
+ And I click on "Get the overview" "button"
+ Then I should see "Allow" in the "comparisontable" "table"
+ And I should not see "Prevent" in the "comparisontable" "table"
+ And I should not see "Prohibit" in the "comparisontable" "table"
+ And I should not see "Not set" in the "comparisontable" "table"
+
+ Scenario: visualize a prohibit capability
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changeshortname |
+ | Roles: | Studentdf |
+ And I click on "Get the overview" "button"
+ Then I should not see "Allow" in the "comparisontable" "table"
+ And I should not see "Prevent" in the "comparisontable" "table"
+ And I should see "Prohibit" in the "comparisontable" "table"
+ And I should not see "Not set" in the "comparisontable" "table"
+
+ Scenario: visualize a not set capability
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changecategory |
+ | Roles: | Studentdf |
+ And I click on "Get the overview" "button"
+ Then I should not see "Allow" in the "comparisontable" "table"
+ And I should not see "Prevent" in the "comparisontable" "table"
+ And I should not see "Prohibit" in the "comparisontable" "table"
+ And I should see "Not set" in the "comparisontable" "table"
+
+ Scenario: visualize more than one role
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changecategory |
+ | Roles: | Student, Studentdf |
+ And I click on "Get the overview" "button"
+ Then I should see "Student" in the "comparisontable" "table"
+ And I should see "Studentdf" in the "comparisontable" "table"
+ And I should not see "Teacher" in the "comparisontable" "table"
+
+ Scenario: visualize all roles without selecting any role
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changecategory |
+ And I click on "Get the overview" "button"
+ Then I should see "Student" in the "comparisontable" "table"
+ And I should see "Studentdf" in the "comparisontable" "table"
+ And I should see "Teacher" in the "comparisontable" "table"
+
+ Scenario: visualize all roles by selecting All option
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changecategory |
+ | Roles: | All |
+ And I click on "Get the overview" "button"
+ Then I should see "Student" in the "comparisontable" "table"
+ And I should see "Studentdf" in the "comparisontable" "table"
+ And I should see "Teacher" in the "comparisontable" "table"
+
+ @javascript
+ Scenario: filter capability list using javascript
+ Given I should see "moodle/site:config" in the "Capability" "field"
+ And I should see "moodle/course:change" in the "Capability" "field"
+ When I wait until the page is ready
+ And I set the field "capabilitysearch" to "moodle/course:change"
+ Then I should see "moodle/course:change" in the "Capability" "field"
+ And I should not see "moodle/site:config" in the "Capability" "field"
+
+ @javascript
+ Scenario: selecting capabilities using filters
+ Given I should see "moodle/course:change" in the "Capability" "field"
+ When I wait until the page is ready
+ And I set the field "capabilitysearch" to "moodle/course:change"
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changecategory |
+ | Roles: | Student |
+ And I set the field "capabilitysearch" to ""
+ And I click on "Get the overview" "button"
+ Then I should see "moodle/course:changecategory" in the "comparisontable" "table"
--- /dev/null
+@tool @tool_capability
+Feature: show only differences between roles for selected capabilities
+ In order to check roles capabilities
+ As an admin
+ I need to be able to filter capabilities report viewing only role differences
+
+ Background:
+ Given the following "roles" exist:
+ | shortname | name | archetype |
+ | studenteq | Studenteq | student |
+ | studentdf | Studentdf | student |
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | moodle/course:changefullname | Allow | studentdf | System | |
+ | moodle/course:changeshortname | Prohibit | studentdf | System | |
+ And I log in as "admin"
+ And I navigate to "Users > Permissions > Capability overview" in site administration
+
+ Scenario: Compare identical roles
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changefullname, moodle/course:changeshortname, moodle/course:changeidnumber, moodle/course:changesummary |
+ | Roles: | Student, Studenteq |
+ And I set the field "Show differences only" to "1"
+ And I click on "Get the overview" "button"
+ Then I should see "There are no differences to show between selected roles in this context"
+
+ Scenario: Compare different roles
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changefullname, moodle/course:changeshortname, moodle/course:changeidnumber, moodle/course:changesummary |
+ | Roles: | Student, Studentdf |
+ And I set the field "Show differences only" to "1"
+ And I click on "Get the overview" "button"
+ Then I should not see "There are no differences to show between selected roles in this context"
+ And I should see "moodle/course:changefullname" in the "comparisontable" "table"
+ And I should see "moodle/course:changeshortname" in the "comparisontable" "table"
+ And I should not see "moodle/course:changesummary" in the "comparisontable" "table"
+
+ Scenario: Compare different roles but comparing capabilities that are equals on both
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changeidnumber, moodle/course:changesummary |
+ | Roles: | Student, Studentdf |
+ And I set the field "Show differences only" to "1"
+ And I click on "Get the overview" "button"
+ Then I should see "There are no differences to show between selected roles in this context"
+
+ Scenario: Compare all roles without selecting specific role
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changefullname, moodle/site:config |
+ And I set the field "Show differences only" to "1"
+ And I click on "Get the overview" "button"
+ Then I should not see "moodle/site:config" in the "comparisontable" "table"
+ And I should see "moodle/course:changefullname" in the "comparisontable" "table"
+
+ Scenario: Compare all roles without selecting specific role on not defined capability
+ When I set the following fields to these values:
+ | Capability: | moodle/site:config |
+ And I set the field "Show differences only" to "1"
+ And I click on "Get the overview" "button"
+ Then I should see "There are no differences to show between selected roles in this context"
+
+ Scenario: Comparing only one role
+ When I set the following fields to these values:
+ | Capability: | moodle/course:changefullname, moodle/course:changeshortname, moodle/course:changeidnumber, moodle/course:changesummary |
+ | Roles: | Student |
+ And I set the field "Show differences only" to "1"
+ And I click on "Get the overview" "button"
+ Then I should see "There are no differences to show between selected roles in this context"
this.button = this.form.all('input[type=submit]');
this.lastsearch = this.form.one('input[name=search]');
- var div = Y.Node.create('<div id="capabilitysearchui"></div>'),
+ var div = Y.Node.create('<div id="capabilitysearchui" data-fieldtype="text"></div>'),
label = Y.Node.create('<label for="capabilitysearch">' + this.get('strsearch') + '</label>');
this.input = Y.Node.create('<input type="text" id="capabilitysearch" />');
function xmldb_tool_customlang_upgrade($oldversion) {
global $CFG;
- // Automatically generated Moodle v3.3.0 release upgrade line.
- // Put any upgrade step following this.
-
- // Automatically generated Moodle v3.4.0 release upgrade line.
- // Put any upgrade step following this.
-
// Automatically generated Moodle v3.5.0 release upgrade line.
// Put any upgrade step following this.
*
* @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));
}
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
// 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.
// 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);
}
}
$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;
}
'requestedby' => $requestedby->fullname,
'requesttype' => $typetext,
'requestdate' => userdate($requestdata->timecreated),
- 'requestorigin' => $SITE->fullname,
+ 'requestorigin' => format_string($SITE->fullname, true, ['context' => context_system::instance()]),
'requestoriginurl' => new moodle_url('/'),
'requestcomments' => $requestdata->messagehtml,
'datarequestsurl' => $datarequestsurl
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);
+ }
}
'type' => PARAM_INT,
'default' => FORMAT_PLAIN
],
+ 'systemapproved' => [
+ 'default' => false,
+ 'type' => PARAM_BOOL,
+ ],
'creationmethod' => [
'default' => self::DATAREQUEST_CREATION_MANUAL,
'choices' => [
use action_link;
use coding_exception;
+use context_system;
use core\message\message;
use core\task\adhoc_task;
use core_user;
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.
$message->contexturl = $datarequestsurl;
$message->contexturlname = get_string('datarequests', 'tool_dataprivacy');
// Message to the recipient.
- $messagetextdata['message'] = get_string('resultdownloadready', 'tool_dataprivacy', $SITE->fullname);
+ $messagetextdata['message'] = get_string('resultdownloadready', 'tool_dataprivacy',
+ format_string($SITE->fullname, true, ['context' => context_system::instance()]));
// Prepare download link.
$downloadurl = moodle_url::make_pluginfile_url($usercontext->id, 'tool_dataprivacy', 'export', $thing->get_itemid(),
$thing->get_filepath(), $thing->get_filename(), true);
// No point notifying a deleted user in Moodle.
$message->notification = 0;
// Message to the recipient.
- $messagetextdata['message'] = get_string('resultdeleted', 'tool_dataprivacy', $SITE->fullname);
+ $messagetextdata['message'] = get_string('resultdeleted', 'tool_dataprivacy',
+ format_string($SITE->fullname, true, ['context' => context_system::instance()]));
// Message will be sent to the deleted user via email only.
$emailonly = true;
break;
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);
// 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);
}
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);
<?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"
>
<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"/>
// 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;
}
$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';
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.
<hr />
<div class="p-l-3">
<dl class="row">
- <dt class="col-xs-3">
+ <dt class="col-3">
{{#link}}
<a href="#{{name}}"><strong style="word-wrap:break-word">{{name}}</strong></a>
{{/link}}
{{/link}}
<div class="small text-muted" style="word-wrap:break-word">{{type}}</div>
</dt>
- <dd class="col-xs-9">{{summary}}</dd>
+ <dd class="col-9">{{summary}}</dd>
</dl>
<dl>
{{#fields}}
<div class="row">
- <dt class="col-xs-3 font-weight-normal" style="word-wrap:break-word">{{field_name}}</dt>
- <dd class="col-xs-9">{{field_summary}}</dd>
+ <dt class="col-3 font-weight-normal" style="word-wrap:break-word">{{field_name}}</dt>
+ <dd class="col-9">{{field_summary}}</dd>
</div>
{{/fields}}
</dl>
<hr />
<div class="p-l-3">
<div class="row">
- <div class="col-xs-12">
+ <div class="col-12">
{{nullprovider}}
</div>
</div>
$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);
}
/**
}
/**
- * 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();
$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);
}
/**
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));
+ }
}
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"
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"
defined('MOODLE_INTERNAL') || die;
-$plugin->version = 2019111800;
+$plugin->version = 2019121700;
$plugin->requires = 2019111200; // Moodle 3.5dev (Build 2018031600) and upwards.
$plugin->component = 'tool_dataprivacy';
$out = $this->heading(get_string('pluginname', 'tool_filetypes'));
if ($restricted) {
$out .= html_writer::div(
- html_writer::div(get_string('configoverride', 'admin'), 'form-overridden'),
+ html_writer::div(get_string('configoverride', 'admin'), 'alert alert-info'),
'', array('id' => 'adminsettings'));
}
if (count($combined) > 1) {
.path-admin-tool-filetypes .generaltable .nonstandard {
font-weight: bold;
}
-
-/* Spacing around the 'Defined in config.php' stripe */
-.path-admin-tool-filetypes .form-overridden {
- display: inline-block;
- margin-bottom: 1em;
- padding: 4px 6px;
-}
function xmldb_tool_log_upgrade($oldversion) {
global $CFG;
- // Automatically generated Moodle v3.3.0 release upgrade line.
- // Put any upgrade step following this.
-
- // Automatically generated Moodle v3.4.0 release upgrade line.
- // Put any upgrade step following this.
-
// Automatically generated Moodle v3.5.0 release upgrade line.
// Put any upgrade step following this.
function xmldb_logstore_database_upgrade($oldversion) {
global $CFG;
- // Automatically generated Moodle v3.3.0 release upgrade line.
- // Put any upgrade step following this.
-
- // Automatically generated Moodle v3.4.0 release upgrade line.
- // Put any upgrade step following this.
-
// Automatically generated Moodle v3.5.0 release upgrade line.
// Put any upgrade step following this.
function xmldb_logstore_standard_upgrade($oldversion) {
global $CFG;
- // Automatically generated Moodle v3.3.0 release upgrade line.
- // Put any upgrade step following this.
-
- // Automatically generated Moodle v3.4.0 release upgrade line.
- // Put any upgrade step following this.
-
// Automatically generated Moodle v3.5.0 release upgrade line.
// Put any upgrade step following this.
+++ /dev/null
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * This file contains renamed classes mappings.
- *
- * @package tool_lp
- * @copyright 2016 Frédéric Massart - FMCorz.net
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-$renamedclasses = array(
- 'tool_lp\\external\\cohort_summary_exporter' => 'core_cohort\\external\\cohort_summary_exporter',
- 'tool_lp\\external\\course_module_summary_exporter' => 'core_course\\external\\course_module_summary_exporter',
- 'tool_lp\\external\\course_summary_exporter' => 'core_course\\external\\course_summary_exporter',
- 'tool_lp\\form\\persistent' => 'core\\form\\persistent',
-);
function xmldb_tool_monitor_upgrade($oldversion) {
global $CFG, $DB;
- if ($oldversion < 2017021300) {
-
- // Delete "orphaned" subscriptions.
- $sql = "SELECT DISTINCT s.courseid
- FROM {tool_monitor_subscriptions} s
- LEFT OUTER JOIN {course} c ON c.id = s.courseid
- WHERE s.courseid <> 0 and c.id IS NULL";
- $deletedcourses = $DB->get_field_sql($sql);
- if ($deletedcourses) {
- list($sql, $params) = $DB->get_in_or_equal($deletedcourses);
- $DB->execute("DELETE FROM {tool_monitor_subscriptions} WHERE courseid " . $sql, $params);
- }
-
- // Monitor savepoint reached.
- upgrade_plugin_savepoint(true, 2017021300, 'tool', 'monitor');
- }
-
- // Automatically generated Moodle v3.3.0 release upgrade line.
- // Put any upgrade step following this.
-
- // Automatically generated Moodle v3.4.0 release upgrade line.
- // Put any upgrade step following this.
-
// Automatically generated Moodle v3.5.0 release upgrade line.
// Put any upgrade step following this.