diff --git a/.eslintrc b/.eslintrc index 961467d66f484..99f07132798c2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -203,7 +203,7 @@ } }, { - files: ["**/amd/src/*.js"], + files: ["**/amd/src/*.js", "Gruntfile.js"], // Check AMD with some slightly stricter rules. rules: { 'no-unused-vars': 'error', diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 0000000000000..5e61b7c699d3a --- /dev/null +++ b/.jshintignore @@ -0,0 +1,2 @@ +**/amd/** +/*.js diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000000..7796292bb4a0d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v8.16.1 diff --git a/.travis.yml b/.travis.yml index 229c46cf80d0b..b36c40a4156a5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,21 +11,18 @@ notifications: language: php +dist: xenial + +services: + - mysql + php: # We only run the highest and lowest supported versions to reduce the load on travis-ci.org. - - 7.2 - - 7.1 + - 7.3 + - 7.1.30 # Make this sticky because current default version (7.1.11) has a bug with redis-extension output (MDL-66062) addons: postgresql: "9.6" - packages: - - mysql-server-5.6 - - mysql-client-core-5.6 - - mysql-client-5.6 - -# Redis tests are currently failing on php 7.2 due to https://bugs.php.net/bug.php?id=75628 -# services: -# - redis-server env: # Although we want to run these jobs and see failures as quickly as possible, we also want to get the slowest job to @@ -35,9 +32,6 @@ env: # CI Tests should be second-highest in priority as these only take <= 60 seconds to run under normal circumstances. # Postgres is significantly is pretty reasonable in its run-time. - # Run unit tests on MySQL - - DB=mysqli TASK=PHPUNIT - # Run CI Tests without running PHPUnit. - DB=none TASK=CITEST @@ -54,32 +48,32 @@ matrix: fast_finish: true include: + # Run mysql only on 7.3 - it's just too slow + - php: 7.3 + env: DB=mysqli TASK=PHPUNIT # Run grunt/npm install on highest version ('node' is an alias for the latest node.js version.) - php: 7.2 env: DB=none TASK=GRUNT NVM_VERSION='lts/carbon' - exclude: - # MySQL - it's just too slow. - # Exclude it on all versions except for 7.2 - - - env: DB=mysqli TASK=PHPUNIT - php: 7.1 - cache: directories: - $HOME/.composer/cache - $HOME/.npm +before_install: + # Avoid IPv6 default binding as service (causes redis not to start). + sudo service redis-server start --bind 127.0.0.1 + install: - > if [ "$DB" = 'mysqli' ]; then sudo mkdir /mnt/ramdisk sudo mount -t tmpfs -o size=1024m tmpfs /mnt/ramdisk - sudo stop mysql + sudo service mysql stop sudo mv /var/lib/mysql /mnt/ramdisk sudo ln -s /mnt/ramdisk/mysql /var/lib/mysql - sudo start mysql + sudo service mysql restart fi - > if [ "$DB" = 'pgsql' ]; @@ -99,10 +93,8 @@ install: echo 'auth.json' >> .git/info/exclude fi - # Enable Redis. - # Redis tests are currently failing on php 7.2 due to https://bugs.php.net/bug.php?id=75628 - # echo 'extension="redis.so"' > /tmp/redis.ini - # phpenv config-add /tmp/redis.ini + echo 'extension="redis.so"' > /tmp/redis.ini + phpenv config-add /tmp/redis.ini # Install composer dependencies. # We need --no-interaction in case we hit API limits for composer. This causes it to fall back to a standard clone. @@ -173,12 +165,19 @@ before_script: mkdir -p "$HOME"/roots/phpunit # The phpunit dataroot and prefix.. - # Redis tests are currently failing on php 7.2 due to https://bugs.php.net/bug.php?id=75628 - # -e "/require_once/i \\define('TEST_SESSION_REDIS_HOST', '127.0.0.1');" \ sed -i \ -e "/require_once/i \\\$CFG->phpunit_dataroot = '\/home\/travis\/roots\/phpunit';" \ -e "/require_once/i \\\$CFG->phpunit_prefix = 'p_';" \ config.php ; + # Redis cache store tests + sed -i \ + -e "/require_once/i \\define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1');" \ + config.php ; + # Redis session tests, but not for PHP 7.2 and up. See MDL-60978 for more info. + redissession="define('TEST_SESSION_REDIS_HOST', '127.0.0.1');" + sed -i \ + -e "/require_once/i \\${redissession}" \ + config.php ; # Initialise PHPUnit for Moodle. php admin/tool/phpunit/cli/init.php diff --git a/Gruntfile.js b/Gruntfile.js index 85e3abe7cf534..441073dc5110e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -24,6 +24,7 @@ * Grunt configuration */ +/* eslint-env node */ module.exports = function(grunt) { var path = require('path'), tasks = {}, @@ -150,6 +151,9 @@ module.exports = function(grunt) { tasks: ['yui'] }, gherkinlint: { + options: { + nospawn: false, + }, files: ['**/tests/behat/*.feature'], tasks: ['gherkinlint'] } @@ -292,19 +296,31 @@ module.exports = function(grunt) { }; tasks.gherkinlint = function() { - var done = this.async(), - options = grunt.config('gherkinlint.options'); - - var args = grunt.file.expand(options.files); - args.unshift(path.normalize(__dirname + '/node_modules/.bin/gherkin-lint')); - grunt.util.spawn({ - cmd: 'node', - args: args, - opts: {stdio: 'inherit', env: process.env} - }, function(error, result, code) { - // Propagate the exit code. - done(code === 0); - }); + const done = this.async(); + const options = grunt.config('gherkinlint.options'); + + // Grab the gherkin-lint linter and required scaffolding. + const linter = require('gherkin-lint/src/linter.js'); + const featureFinder = require('gherkin-lint/src/feature-finder.js'); + const configParser = require('gherkin-lint/src/config-parser.js'); + const formatter = require('gherkin-lint/src/formatters/stylish.js'); + + // Run the linter. + const results = linter.lint( + featureFinder.getFeatureFiles(grunt.file.expand(options.files)), + configParser.getConfiguration(configParser.defaultConfigFileName) + ); + + // Print the results out uncondtionally. + formatter.printResults(results); + + // Report on the results. + // We exit 1 if there is at least one error, otherwise we exit cleanly. + if (results.some(result => result.errors.length > 0)) { + done(1); + } else { + done(0); + } }; tasks.startup = function() { diff --git a/admin/classes/task_log_table.php b/admin/classes/task_log_table.php index 2bb892db2a474..949e757422eca 100644 --- a/admin/classes/task_log_table.php +++ b/admin/classes/task_log_table.php @@ -131,7 +131,8 @@ public function query_db($pagesize, $useinitialsbar = true) { } $sql = "SELECT - tl.*, + tl.id, tl.type, tl.component, tl.classname, tl.userid, tl.timestart, tl.timeend, + tl.dbreads, tl.dbwrites, tl.result, tl.dbreads + tl.dbwrites AS db, tl.timeend - tl.timestart AS duration, {$userfields} diff --git a/admin/cli/install.php b/admin/cli/install.php index e9203ec11213e..41503b3ba427f 100644 --- a/admin/cli/install.php +++ b/admin/cli/install.php @@ -514,100 +514,105 @@ $database = $databases[$CFG->dbtype]; -// ask for db host -if ($interactive) { - cli_separator(); - cli_heading(get_string('databasehost', 'install')); - if ($options['dbhost'] !== '') { - $prompt = get_string('clitypevaluedefault', 'admin', $options['dbhost']); +// We cannot do any validation until all DB connection data is provided. +$hintdatabase = ''; +do { + echo $hintdatabase; + + // Ask for db host. + if ($interactive) { + cli_separator(); + cli_heading(get_string('databasehost', 'install')); + if ($options['dbhost'] !== '') { + $prompt = get_string('clitypevaluedefault', 'admin', $options['dbhost']); + } else { + $prompt = get_string('clitypevalue', 'admin'); + } + $CFG->dbhost = cli_input($prompt, $options['dbhost']); + } else { - $prompt = get_string('clitypevalue', 'admin'); + $CFG->dbhost = $options['dbhost']; } - $CFG->dbhost = cli_input($prompt, $options['dbhost']); -} else { - $CFG->dbhost = $options['dbhost']; -} + // Ask for db name. + if ($interactive) { + cli_separator(); + cli_heading(get_string('databasename', 'install')); + if ($options['dbname'] !== '') { + $prompt = get_string('clitypevaluedefault', 'admin', $options['dbname']); + } else { + $prompt = get_string('clitypevalue', 'admin'); + } + $CFG->dbname = cli_input($prompt, $options['dbname']); -// ask for db name -if ($interactive) { - cli_separator(); - cli_heading(get_string('databasename', 'install')); - if ($options['dbname'] !== '') { - $prompt = get_string('clitypevaluedefault', 'admin', $options['dbname']); } else { - $prompt = get_string('clitypevalue', 'admin'); + $CFG->dbname = $options['dbname']; } - $CFG->dbname = cli_input($prompt, $options['dbname']); -} else { - $CFG->dbname = $options['dbname']; -} + // Ask for db prefix. + if ($interactive) { + cli_separator(); + cli_heading(get_string('dbprefix', 'install')); + //TODO: solve somehow the prefix trouble for oci. + if ($options['prefix'] !== '') { + $prompt = get_string('clitypevaluedefault', 'admin', $options['prefix']); + } else { + $prompt = get_string('clitypevalue', 'admin'); + } + $CFG->prefix = cli_input($prompt, $options['prefix']); -// ask for db prefix -if ($interactive) { - cli_separator(); - cli_heading(get_string('dbprefix', 'install')); - //TODO: solve somehow the prefix trouble for oci - if ($options['prefix'] !== '') { - $prompt = get_string('clitypevaluedefault', 'admin', $options['prefix']); } else { - $prompt = get_string('clitypevalue', 'admin'); + $CFG->prefix = $options['prefix']; } - $CFG->prefix = cli_input($prompt, $options['prefix']); -} else { - $CFG->prefix = $options['prefix']; -} + // Ask for db port. + if ($interactive) { + cli_separator(); + cli_heading(get_string('databaseport', 'install')); + $prompt = get_string('clitypevaluedefault', 'admin', $options['dbport']); + $CFG->dboptions['dbport'] = (int) cli_input($prompt, $options['dbport']); -// ask for db port -if ($interactive) { - cli_separator(); - cli_heading(get_string('databaseport', 'install')); - $prompt = get_string('clitypevaluedefault', 'admin', $options['dbport']); - $CFG->dboptions['dbport'] = (int)cli_input($prompt, $options['dbport']); + } else { + $CFG->dboptions['dbport'] = (int) $options['dbport']; + } + if ($CFG->dboptions['dbport'] <= 0) { + $CFG->dboptions['dbport'] = ''; + } -} else { - $CFG->dboptions['dbport'] = (int)$options['dbport']; -} -if ($CFG->dboptions['dbport'] <= 0) { - $CFG->dboptions['dbport'] = ''; -} + // Ask for db socket. + if ($CFG->ostype === 'WINDOWS') { + $CFG->dboptions['dbsocket'] = ''; -// ask for db socket -if ($CFG->ostype === 'WINDOWS') { - $CFG->dboptions['dbsocket'] = ''; + } else if ($interactive and empty($CFG->dboptions['dbport'])) { + cli_separator(); + cli_heading(get_string('databasesocket', 'install')); + $prompt = get_string('clitypevaluedefault', 'admin', $options['dbsocket']); + $CFG->dboptions['dbsocket'] = cli_input($prompt, $options['dbsocket']); -} else if ($interactive and empty($CFG->dboptions['dbport'])) { - cli_separator(); - cli_heading(get_string('databasesocket', 'install')); - $prompt = get_string('clitypevaluedefault', 'admin', $options['dbsocket']); - $CFG->dboptions['dbsocket'] = cli_input($prompt, $options['dbsocket']); + } else { + $CFG->dboptions['dbsocket'] = $options['dbsocket']; + } -} else { - $CFG->dboptions['dbsocket'] = $options['dbsocket']; -} + // Ask for db user. + if ($interactive) { + cli_separator(); + cli_heading(get_string('databaseuser', 'install')); + if ($options['dbuser'] !== '') { + $prompt = get_string('clitypevaluedefault', 'admin', $options['dbuser']); + } else { + $prompt = get_string('clitypevalue', 'admin'); + } + $CFG->dbuser = cli_input($prompt, $options['dbuser']); -// ask for db user -if ($interactive) { - cli_separator(); - cli_heading(get_string('databaseuser', 'install')); - if ($options['dbuser'] !== '') { - $prompt = get_string('clitypevaluedefault', 'admin', $options['dbuser']); } else { - $prompt = get_string('clitypevalue', 'admin'); + $CFG->dbuser = $options['dbuser']; } - $CFG->dbuser = cli_input($prompt, $options['dbuser']); -} else { - $CFG->dbuser = $options['dbuser']; -} + // Ask for db password. + if ($interactive) { + cli_separator(); + cli_heading(get_string('databasepass', 'install')); -// ask for db password -if ($interactive) { - cli_separator(); - cli_heading(get_string('databasepass', 'install')); - do { if ($options['dbpass'] !== '') { $prompt = get_string('clitypevaluedefault', 'admin', $options['dbpass']); } else { @@ -615,19 +620,23 @@ } $CFG->dbpass = cli_input($prompt, $options['dbpass']); - if (function_exists('distro_pre_create_db')) { // Hook for distros needing to do something before DB creation - $distro = distro_pre_create_db($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']), $distro); + if (function_exists('distro_pre_create_db')) { // Hook for distros needing to do something before DB creation. + $distro = distro_pre_create_db($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, + array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']), + $distro); } - $hint_database = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket'])); - } while ($hint_database !== ''); + $hintdatabase = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, + array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket'])); -} else { - $CFG->dbpass = $options['dbpass']; - $hint_database = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket'])); - if ($hint_database !== '') { - cli_error(get_string('dbconnectionerror', 'install')); + } else { + $CFG->dbpass = $options['dbpass']; + $hintdatabase = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, + array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket'])); + if ($hintdatabase !== '') { + cli_error(get_string('dbconnectionerror', 'install')); + } } -} +} while ($hintdatabase !== ''); // ask for fullname if ($interactive) { diff --git a/admin/environment.xml b/admin/environment.xml index ca20d0c68156b..6f6fb8141f249 100644 --- a/admin/environment.xml +++ b/admin/environment.xml @@ -2451,6 +2451,7 @@ + @@ -2632,6 +2633,7 @@ + @@ -2799,4 +2801,366 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/message.php b/admin/message.php index 166acccaed3a3..d004e9eebefc9 100644 --- a/admin/message.php +++ b/admin/message.php @@ -33,13 +33,17 @@ $processors = array_filter($allprocessors, function($processor) { return $processor->enabled; }); +$disabledprocessors = array_filter($allprocessors, function($processor) { + return !$processor->enabled; +}); + // Fetch message providers. $providers = get_message_providers(); // Fetch the manage message outputs interface. $preferences = get_message_output_default_preferences(); if (($form = data_submitted()) && confirm_sesskey()) { - $preferences = array(); + $newpreferences = array(); // Prepare default message outputs settings. foreach ($providers as $provider) { $componentproviderbase = $provider->component.'_'.$provider->name; @@ -47,9 +51,9 @@ $providerdisabled = false; if (!isset($form->$disableprovidersetting)) { $providerdisabled = true; - $preferences[$disableprovidersetting] = 1; + $newpreferences[$disableprovidersetting] = 1; } else { - $preferences[$disableprovidersetting] = 0; + $newpreferences[$disableprovidersetting] = 0; } foreach (array('permitted', 'loggedin', 'loggedoff') as $setting) { @@ -75,20 +79,36 @@ $form->{$componentproviderbase.'_loggedoff'}[$processor->name] = 1; } // Record the site preference. - $preferences[$processor->name.'_provider_'.$componentprovidersetting] = $value; + $newpreferences[$processor->name.'_provider_'.$componentprovidersetting] = $value; } - } else if (array_key_exists($componentprovidersetting, $form)) { - // We must be processing loggedin or loggedoff checkboxes. Store - // defained comma-separated processors as setting value. - // Using array_filter eliminates elements set to 0 above. - $value = join(',', array_keys(array_filter($form->{$componentprovidersetting}))); + } else { + $newsettings = array(); + if (array_key_exists($componentprovidersetting, $form)) { + // We must be processing loggedin or loggedoff checkboxes. + // Store defained comma-separated processors as setting value. + // Using array_filter eliminates elements set to 0 above. + $newsettings = array_keys(array_filter($form->{$componentprovidersetting})); + } + + // Let's join existing setting values for disabled processors. + $property = 'message_provider_'.$componentprovidersetting; + if (property_exists($preferences, $property)) { + $existingsetting = $preferences->$property; + foreach ($disabledprocessors as $disable) { + if (strpos($existingsetting, $disable->name) > -1) { + $newsettings[] = $disable->name; + } + } + } + + $value = join(',', $newsettings); if (empty($value)) { $value = null; } } if ($setting != 'permitted') { // We have already recoded site preferences for 'permitted' type. - $preferences['message_provider_'.$componentprovidersetting] = $value; + $newpreferences['message_provider_'.$componentprovidersetting] = $value; } } } @@ -102,7 +122,7 @@ \core_message\api::update_processor_status($processor, $enabled); } - foreach ($preferences as $name => $value) { + foreach ($newpreferences as $name => $value) { set_config($name, $value, 'message'); } $transaction->allow_commit(); diff --git a/admin/registration/confirmregistration.php b/admin/registration/confirmregistration.php index 7c9d422184b01..ff99680ac8bf0 100644 --- a/admin/registration/confirmregistration.php +++ b/admin/registration/confirmregistration.php @@ -46,7 +46,7 @@ admin_externalpage_setup('registrationmoodleorg'); if ($url !== HUB_MOODLEORGHUBURL) { - // Allow other plugins to confirm registration on hubs other than moodle.net . Plugins implementing this + // Allow other plugins to confirm registration on custom hubs. Plugins implementing this // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration . $callbacks = get_plugins_with_function('hub_registration'); foreach ($callbacks as $plugintype => $plugins) { diff --git a/admin/registration/forms.php b/admin/registration/forms.php index 6fe95536333af..af05a7a34fec5 100644 --- a/admin/registration/forms.php +++ b/admin/registration/forms.php @@ -32,5 +32,4 @@ defined('MOODLE_INTERNAL') || die(); -debugging('Support for alternative hubs has been removed from Moodle in 3.4. For communication with moodle.net ' . - 'see lib/classes/hub/ .', DEBUG_DEVELOPER); +debugging('Support for alternative hubs has been removed from Moodle in 3.4.', DEBUG_DEVELOPER); diff --git a/admin/registration/index.php b/admin/registration/index.php index 62f251a4b4455..2b1a01f8b3699 100644 --- a/admin/registration/index.php +++ b/admin/registration/index.php @@ -22,7 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com * - * This page displays the site registration form for Moodle.net. + * This page displays the site registration form. * It handles redirection to the hub to continue the registration workflow process. * It also handles update operation by web service. */ @@ -48,7 +48,7 @@ } echo $OUTPUT->header(); - echo $OUTPUT->heading(get_string('unregisterfrom', 'hub', 'Moodle.net'), 3, 'main'); + echo $OUTPUT->heading(get_string('registerwithmoodleorgremove', 'core_hub'), 3, 'main'); $siteunregistrationform->display(); echo $OUTPUT->footer(); exit; @@ -82,7 +82,7 @@ echo $OUTPUT->header(); -// Current status of registration on Moodle.net. +// Current status of registration. $notificationtype = \core\output\notification::NOTIFY_ERROR; if (\core\hub\registration::is_registered()) { @@ -104,11 +104,11 @@ // Heading. if (\core\hub\registration::is_registered()) { - echo $OUTPUT->heading(get_string('updatesite', 'hub', 'Moodle.net')); + echo $OUTPUT->heading(get_string('registerwithmoodleorgupdate', 'core_hub')); } else if ($isinitialregistration) { - echo $OUTPUT->heading(get_string('completeregistration', 'hub')); + echo $OUTPUT->heading(get_string('registerwithmoodleorgcomplete', 'core_hub')); } else { - echo $OUTPUT->heading(get_string('registerwithmoodleorg', 'admin')); + echo $OUTPUT->heading(get_string('registerwithmoodleorg', 'core_hub')); } $renderer = $PAGE->get_renderer('core', 'admin'); diff --git a/admin/registration/renewregistration.php b/admin/registration/renewregistration.php index 84e727be32300..bc05da60ffec4 100644 --- a/admin/registration/renewregistration.php +++ b/admin/registration/renewregistration.php @@ -40,7 +40,7 @@ admin_externalpage_setup('registrationmoodleorg'); if ($url !== HUB_MOODLEORGHUBURL) { - // Allow other plugins to renew registration on hubs other than moodle.net . Plugins implementing this + // Allow other plugins to renew registration on custom hubs. Plugins implementing this // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration . $callbacks = get_plugins_with_function('hub_registration'); foreach ($callbacks as $plugintype => $plugins) { @@ -56,7 +56,7 @@ echo $OUTPUT->header(); echo $OUTPUT->heading(get_string('renewregistration', 'hub'), 3, 'main'); -$hublink = html_writer::tag('a', 'Moodle.net', array('href' => HUB_MOODLEORGHUBURL)); +$hublink = html_writer::tag('a', HUB_MOODLEORGHUBURL, array('href' => HUB_MOODLEORGHUBURL)); $deletedregmsg = get_string('previousregistrationdeleted', 'hub', $hublink); diff --git a/admin/renderer.php b/admin/renderer.php index 922617ad27867..5abd08eaebb2f 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -1469,9 +1469,11 @@ protected function required_column(\core\plugininfo\base $plugin, core_plugin_ma $class = 'requires-failed'; $label = html_writer::span(get_string('dependencyfails', 'core_plugin'), 'badge badge-danger'); } - $requires[] = html_writer::tag('li', - html_writer::span(get_string('moodleversion', 'core_plugin', $plugin->versionrequires), 'dep dep-core'). - ' '.$label, array('class' => $class)); + 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)); + } } else { $actions = array(); @@ -2110,6 +2112,27 @@ protected function legacy_log_store_writing_error() { * @return string */ public function moodleorg_registration_message() { - return format_text(get_string('registermoodlenet', 'admin'), FORMAT_HTML, ['noclean' => true]); + + $out = format_text(get_string('registerwithmoodleorginfo', 'core_hub'), FORMAT_MARKDOWN); + + $out .= html_writer::link( + new moodle_url('/admin/settings.php', ['section' => 'moodleservices']), + $this->output->pix_icon('i/info', '').' '.get_string('registerwithmoodleorginfoapp', 'core_hub'), + ['class' => 'btn btn-link', 'role' => 'opener', 'target' => '_href'] + ); + + $out .= html_writer::link( + HUB_MOODLEORGHUBURL, + $this->output->pix_icon('i/stats', '').' '.get_string('registerwithmoodleorginfostats', 'core_hub'), + ['class' => 'btn btn-link', 'role' => 'opener', 'target' => '_href'] + ); + + $out .= html_writer::link( + HUB_MOODLEORGHUBURL.'/sites', + $this->output->pix_icon('i/location', '').' '.get_string('registerwithmoodleorginfosites', 'core_hub'), + ['class' => 'btn btn-link', 'role' => 'opener', 'target' => '_href'] + ); + + return $this->output->box($out); } } diff --git a/admin/roles/admins.php b/admin/roles/admins.php index 4b341b360a3a8..a4b034a8aec5d 100644 --- a/admin/roles/admins.php +++ b/admin/roles/admins.php @@ -36,15 +36,12 @@ } $admisselector = new core_role_admins_existing_selector(); -$admisselector->set_extra_fields(array('username', 'email')); - $potentialadmisselector = new core_role_admins_potential_selector(); -$potentialadmisselector->set_extra_fields(array('username', 'email')); if (optional_param('add', false, PARAM_BOOL) and confirm_sesskey()) { if ($userstoadd = $potentialadmisselector->get_selected_users()) { $user = reset($userstoadd); - $username = fullname($user) . " ($user->username, $user->email)"; + $username = $potentialadmisselector->output_user($user); echo $OUTPUT->header(); $yesurl = new moodle_url('/admin/roles/admins.php', array('confirmadd'=>$user->id, 'sesskey'=>sesskey())); echo $OUTPUT->confirm(get_string('confirmaddadmin', 'core_role', $username), $yesurl, $PAGE->url); @@ -58,7 +55,7 @@ if ($USER->id == $user->id) { // Can not remove self. } else { - $username = fullname($user) . " ($user->username, $user->email)"; + $username = $admisselector->output_user($user); echo $OUTPUT->header(); $yesurl = new moodle_url('/admin/roles/admins.php', array('confirmdel'=>$user->id, 'sesskey'=>sesskey())); echo $OUTPUT->confirm(get_string('confirmdeladmin', 'core_role', $username), $yesurl, $PAGE->url); diff --git a/admin/roles/classes/define_role_table_advanced.php b/admin/roles/classes/define_role_table_advanced.php index d5f6c18350898..6ddd5d2e5040b 100644 --- a/admin/roles/classes/define_role_table_advanced.php +++ b/admin/roles/classes/define_role_table_advanced.php @@ -157,19 +157,19 @@ public function read_submitted_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. @@ -589,7 +589,9 @@ protected function get_allow_role_control($type) { 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')); } @@ -623,7 +625,7 @@ protected function print_field($name, $caption, $field, $helpicon = null) { echo "\n"; } if ($helpicon) { - echo ''.$helpicon.''; + echo ''.$helpicon.''; } echo ''; if (isset($this->errors[$name])) { diff --git a/admin/roles/classes/permissions_table.php b/admin/roles/classes/permissions_table.php index 80e4b2067cea7..b877a70c76905 100644 --- a/admin/roles/classes/permissions_table.php +++ b/admin/roles/classes/permissions_table.php @@ -96,7 +96,7 @@ protected function add_row_cells($capability) { "linkclass" => "preventlink", "adminurl" => $adminurl->out(), "icon" => "", "iconalt" => ""); if (isset($overridableroles[$id]) and ($allowoverrides or ($allowsafeoverrides and is_safe_capability($capability)))) { $templatecontext['icon'] = 't/delete'; - $templatecontext['iconalt'] = get_string('delete'); + $templatecontext['iconalt'] = get_string('deletexrole', 'core_role', $name); } $neededroles[$id] = $renderer->render_from_template('core/permissionmanager_role', $templatecontext); } @@ -109,7 +109,7 @@ protected function add_row_cells($capability) { "icon" => "", "iconalt" => ""); if (isset($overridableroles[$id]) and prohibit_is_removable($id, $context, $capability->name)) { $templatecontext['icon'] = 't/delete'; - $templatecontext['iconalt'] = get_string('delete'); + $templatecontext['iconalt'] = get_string('deletexrole', 'core_role', $name); } $forbiddenroles[$id] = $renderer->render_from_template('core/permissionmanager_role', $templatecontext); } diff --git a/admin/roles/permissions.php b/admin/roles/permissions.php index 89668dc53c5f9..bc549484400ce 100644 --- a/admin/roles/permissions.php +++ b/admin/roles/permissions.php @@ -213,7 +213,7 @@ $PAGE->requires->strings_for_js( array('roleprohibitinfo', 'roleprohibitheader', 'roleallowinfo', 'roleallowheader', 'confirmunassigntitle', 'confirmroleunprohibit', 'confirmroleprevent', 'confirmunassignyes', - 'confirmunassignno'), 'core_role'); + 'confirmunassignno', 'deletexrole'), 'core_role'); $PAGE->requires->js_call_amd('core/permissionmanager', 'initialize', array($arguments)); $table = new core_role_permissions_table($context, $contextname, $allowoverrides, $allowsafeoverrides, $overridableroles); echo $OUTPUT->box_start('generalbox capbox'); diff --git a/admin/settings/courses.php b/admin/settings/courses.php index 99892d68dd990..ace90d3f05745 100644 --- a/admin/settings/courses.php +++ b/admin/settings/courses.php @@ -459,27 +459,31 @@ $ADMIN->add('backups', $temp); // Create a page for asynchronous backup and restore configuration and defaults. - if (!empty($CFG->enableasyncbackup)) { // Only add settings if async mode is enable at site level. - $temp = new admin_settingpage('asyncgeneralsettings', new lang_string('asyncgeneralsettings', 'backup')); - - $temp->add(new admin_setting_configcheckbox( - 'backup/backup_async_message_users', - new lang_string('asyncemailenable', 'backup'), - new lang_string('asyncemailenabledetail', 'backup'), 0)); - - $temp->add(new admin_setting_configtext( - 'backup/backup_async_message_subject', - new lang_string('asyncmessagesubject', 'backup'), - new lang_string('asyncmessagesubjectdetail', 'backup'), - new lang_string('asyncmessagesubjectdefault', 'backup'))); - - $temp->add(new admin_setting_confightmleditor( - 'backup/backup_async_message', - new lang_string('asyncmessagebody', 'backup'), - new lang_string('asyncmessagebodydetail', 'backup'), - new lang_string('asyncmessagebodydefault', 'backup'))); - - $ADMIN->add('backups', $temp); - } + $temp = new admin_settingpage('asyncgeneralsettings', new lang_string('asyncgeneralsettings', 'backup')); + + $temp->add(new admin_setting_configcheckbox('enableasyncbackup', new lang_string('enableasyncbackup', 'backup'), + new lang_string('enableasyncbackup_help', 'backup'), 0, 1, 0)); + + $temp->add(new admin_setting_configcheckbox( + 'backup/backup_async_message_users', + new lang_string('asyncemailenable', 'backup'), + new lang_string('asyncemailenabledetail', 'backup'), 0)); + $temp->hide_if('backup/backup_async_message_users', 'enableasyncbackup'); + + $temp->add(new admin_setting_configtext( + 'backup/backup_async_message_subject', + new lang_string('asyncmessagesubject', 'backup'), + new lang_string('asyncmessagesubjectdetail', 'backup'), + new lang_string('asyncmessagesubjectdefault', 'backup'))); + $temp->hide_if('backup/backup_async_message_subject', 'backup/backup_async_message_users'); + + $temp->add(new admin_setting_confightmleditor( + 'backup/backup_async_message', + new lang_string('asyncmessagebody', 'backup'), + new lang_string('asyncmessagebodydetail', 'backup'), + new lang_string('asyncmessagebodydefault', 'backup'))); + $temp->hide_if('backup/backup_async_message', 'backup/backup_async_message_users'); + + $ADMIN->add('backups', $temp); } diff --git a/admin/settings/subsystems.php b/admin/settings/subsystems.php index 0b627494d65b9..de5f75be9939a 100644 --- a/admin/settings/subsystems.php +++ b/admin/settings/subsystems.php @@ -48,10 +48,4 @@ $optionalsubsystems->add(new admin_setting_configcheckbox('allowstealth', new lang_string('allowstealthmodules'), new lang_string('allowstealthmodules_help'), 0, 1, 0)); - - $optionalsubsystems->add(new admin_setting_configcheckbox('enablecoursepublishing', - new lang_string('enablecoursepublishing', 'hub'), new lang_string('enablecoursepublishing_help', 'hub'), 0)); - - $optionalsubsystems->add(new admin_setting_configcheckbox('enableasyncbackup', new lang_string('enableasyncbackup', 'backup'), - new lang_string('enableasyncbackup_help', 'backup'), 0, 1, 0)); } diff --git a/admin/tasklogs.php b/admin/tasklogs.php index 6684a7ebc4bf1..7e5d244f1bd7d 100644 --- a/admin/tasklogs.php +++ b/admin/tasklogs.php @@ -49,6 +49,8 @@ $download = optional_param('download', false, PARAM_BOOL); if (null !== $logid) { + // Raise memory limit in case the log is large. + raise_memory_limit(MEMORY_HUGE); $log = $DB->get_record('task_log', ['id' => $logid], '*', MUST_EXIST); if ($download) { diff --git a/admin/testoutgoingmailconf.php b/admin/testoutgoingmailconf.php index ce5857f4ee493..7d0db03b07ac3 100644 --- a/admin/testoutgoingmailconf.php +++ b/admin/testoutgoingmailconf.php @@ -52,7 +52,7 @@ // Manage Moodle debugging options. $debuglevel = $CFG->debug; $debugdisplay = $CFG->debugdisplay; - $debugsmtp = $CFG->debugsmtp; + $debugsmtp = $CFG->debugsmtp ?? null; // This might not be set as it's optional. $CFG->debugdisplay = true; $CFG->debugsmtp = true; $CFG->debug = 15; @@ -66,7 +66,12 @@ // Restore Moodle debugging options. $CFG->debug = $debuglevel; $CFG->debugdisplay = $debugdisplay; - $CFG->debugsmtp = $debugsmtp; + + // Restore the debugsmtp config, if it was set originally. + unset($CFG->debugsmtp); + if (!is_null($debugsmtp)) { + $CFG->debugsmtp = $debugsmtp; + } if ($success) { $msgparams = new stdClass(); diff --git a/admin/tests/behat/behat_admin.php b/admin/tests/behat/behat_admin.php index 87a00b6cdad33..af027dec4c895 100644 --- a/admin/tests/behat/behat_admin.php +++ b/admin/tests/behat/behat_admin.php @@ -48,13 +48,11 @@ class behat_admin extends behat_base { * @param TableNode $table */ public function i_set_the_following_administration_settings_values(TableNode $table) { - if (!$data = $table->getRowsHash()) { return; } foreach ($data as $label => $value) { - $this->execute('behat_navigation::i_select_from_flat_navigation_drawer', [get_string('administrationsite')]); // Search by label. @@ -76,38 +74,17 @@ public function i_set_the_following_administration_settings_values(TableNode $ta "@id=//span[contains(normalize-space(.), $label)]/preceding-sibling::label[1]/@for]"; $fieldnode = $this->find('xpath', $fieldxpath, $exception); - $formfieldtypenode = $this->find('xpath', $fieldxpath . - "/ancestor::div[contains(concat(' ', @class, ' '), ' form-setting ')]" . - "/child::div[contains(concat(' ', @class, ' '), ' form-')]/child::*/parent::div"); - } catch (ElementNotFoundException $e) { - // Multi element settings, interacting only the first one. $fieldxpath = "//*[label[contains(., $label)]|span[contains(., $label)]]" . "/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' form-item ')]" . "/descendant::div[contains(concat(' ', @class, ' '), ' form-group ')]" . "/descendant::*[self::input | self::textarea | self::select]" . "[not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]"; - $fieldnode = $this->find('xpath', $fieldxpath); - - // It is the same one that contains the type. - $formfieldtypenode = $fieldnode; - } - - // Getting the class which contains the field type. - $classes = explode(' ', $formfieldtypenode->getAttribute('class')); - $type = false; - foreach ($classes as $class) { - if (substr($class, 0, 5) == 'form-') { - $type = substr($class, 5); - } } - // Instantiating the appropiate field type. - $field = behat_field_manager::get_field_instance($type, $fieldnode, $this->getSession()); - $field->set_value($value); - - $this->find_button(get_string('savechanges'))->press(); + $this->execute('behat_forms::i_set_the_field_with_xpath_to', [$fieldxpath, $value]); + $this->execute("behat_general::i_click_on", [get_string('savechanges'), 'button']); } } diff --git a/admin/tests/behat/manage_tokens.feature b/admin/tests/behat/manage_tokens.feature index d30e2300bd15e..dc1cd948a600e 100644 --- a/admin/tests/behat/manage_tokens.feature +++ b/admin/tests/behat/manage_tokens.feature @@ -9,6 +9,7 @@ Feature: Manage tokens | username | password | firstname | lastname | | testuser | testuser | Joe | Bloggs | | testuser2 | testuser2 | TestFirstname | TestLastname | + And I change window size to "small" And I log in as "admin" And I am on site homepage diff --git a/admin/tool/analytics/classes/output/models_list.php b/admin/tool/analytics/classes/output/models_list.php index c6831d0e20aa2..9c918ce1ef42a 100644 --- a/admin/tool/analytics/classes/output/models_list.php +++ b/admin/tool/analytics/classes/output/models_list.php @@ -181,7 +181,7 @@ public function export_for_template(\renderer_base $output) { if ($contextid == SYSCONTEXTID) { $contextname = get_string('allpredictions', 'tool_analytics'); } else { - $contextname = shorten_text($context->get_context_name(true, true), 90); + $contextname = shorten_text($context->get_context_name(false, true), 40); } $predictioncontexts[$contextid] = $contextname; } diff --git a/admin/tool/analytics/classes/task/predict_models.php b/admin/tool/analytics/classes/task/predict_models.php index 6ea0d0d635618..4551e79a8a904 100644 --- a/admin/tool/analytics/classes/task/predict_models.php +++ b/admin/tool/analytics/classes/task/predict_models.php @@ -59,6 +59,9 @@ public function execute() { } foreach ($models as $model) { + + $renderer = $PAGE->get_renderer('tool_analytics'); + $result = $model->predict(); // Reset the page as some indicators may call external functions that overwrite the page context. @@ -66,7 +69,6 @@ public function execute() { if ($result) { echo $OUTPUT->heading(get_string('modelresults', 'tool_analytics', $model->get_name())); - $renderer = $PAGE->get_renderer('tool_analytics'); echo $renderer->render_get_predictions_results(false, array(), $result, $model->get_analyser()->get_logs()); } } diff --git a/admin/tool/analytics/classes/task/train_models.php b/admin/tool/analytics/classes/task/train_models.php index 67c0a3a5f81f4..c9387d3906be4 100644 --- a/admin/tool/analytics/classes/task/train_models.php +++ b/admin/tool/analytics/classes/task/train_models.php @@ -70,6 +70,8 @@ public function execute() { continue; } + $renderer = $PAGE->get_renderer('tool_analytics'); + $result = $model->train(); // Reset the page as some indicators may call external functions that overwrite the page context. @@ -77,8 +79,6 @@ public function execute() { if ($result) { echo $OUTPUT->heading(get_string('modelresults', 'tool_analytics', $model->get_name())); - - $renderer = $PAGE->get_renderer('tool_analytics'); echo $renderer->render_get_predictions_results($result, $model->get_analyser()->get_logs()); } } diff --git a/admin/tool/analytics/cli/evaluate_model.php b/admin/tool/analytics/cli/evaluate_model.php index 8473aba6ab041..3e647ef0b0c36 100644 --- a/admin/tool/analytics/cli/evaluate_model.php +++ b/admin/tool/analytics/cli/evaluate_model.php @@ -102,6 +102,8 @@ mtrace(get_string('evaluationinbatches', 'tool_analytics')); } +$renderer = $PAGE->get_renderer('tool_analytics'); + $analyseroptions = array( 'filter' => $options['filter'], 'timesplitting' => $options['timesplitting'], @@ -114,7 +116,6 @@ // Reset the page as some indicators may call external functions that overwrite the page context. \tool_analytics\output\helper::reset_page(); -$renderer = $PAGE->get_renderer('tool_analytics'); echo $renderer->render_evaluate_results($results, $model->get_analyser()->get_logs()); // Check that we have, at leasa,t 1 valid dataset (not necessarily good) to use. diff --git a/admin/tool/analytics/cli/guess_course_start_and_end.php b/admin/tool/analytics/cli/guess_course_start_and_end.php index f8a8aa5d6b5cc..f6e653ba77e93 100644 --- a/admin/tool/analytics/cli/guess_course_start_and_end.php +++ b/admin/tool/analytics/cli/guess_course_start_and_end.php @@ -173,7 +173,8 @@ function tool_analytics_calculate_course_dates($course, $options) { $formatoptions = $format->get_format_options(); // Change this for a course formats API level call in MDL-60702. - if (method_exists($format, 'update_end_date') && $formatoptions['automaticenddate']) { + if ((get_class($format) == 'format_weeks' || is_subclass_of($format, 'format_weeks')) && + method_exists($format, 'update_end_date') && $formatoptions['automaticenddate']) { // Special treatment for weeks-based formats with automatic end date. if ($options['update']) { diff --git a/admin/tool/behat/index.php b/admin/tool/behat/index.php index f3b1ac001258a..46d57066f6f41 100644 --- a/admin/tool/behat/index.php +++ b/admin/tool/behat/index.php @@ -32,7 +32,7 @@ // systems, but let's allow room for expansion. core_php_time_limit::raise(300); -$filter = optional_param('filter', '', PARAM_ALPHANUMEXT); +$filter = optional_param('filter', '', PARAM_NOTAGS); $type = optional_param('type', false, PARAM_ALPHAEXT); $component = optional_param('component', '', PARAM_ALPHAEXT); diff --git a/admin/tool/behat/lang/en/tool_behat.php b/admin/tool/behat/lang/en/tool_behat.php index 88e336516a8ba..f0664c507c7fe 100644 --- a/admin/tool/behat/lang/en/tool_behat.php +++ b/admin/tool/behat/lang/en/tool_behat.php @@ -31,7 +31,7 @@ $string['errorsetconfig'] = '$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot need to be set in config.php.'; $string['erroruniqueconfig'] = '$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot values need to be different than $CFG->dataroot, $CFG->prefix, $CFG->wwwroot, $CFG->phpunit_dataroot and $CFG->phpunit_prefix values.'; $string['fieldvalueargument'] = 'Field value arguments'; -$string['fieldvalueargument_help'] = 'This argument should be completed by a field value. There are many field types, including simple ones like checkboxes, selects or textareas, or complex ones like date selectors. See the dev documentation Acceptance_testing for details of expected field values.'; +$string['fieldvalueargument_help'] = 'This argument should be completed by a field value. There are many field types, including simple ones like checkboxes, selects or textareas, or complex ones like date selectors. See the dev documentation Acceptance_testing for details of expected field values.'; $string['giveninfo'] = 'Given. Processes to set up the environment'; $string['infoheading'] = 'Info'; $string['installinfo'] = 'Read {$a} for installation and tests execution info'; diff --git a/admin/tool/behat/locallib.php b/admin/tool/behat/locallib.php index 83c3a8d49f20e..f4e6537af6c06 100644 --- a/admin/tool/behat/locallib.php +++ b/admin/tool/behat/locallib.php @@ -55,13 +55,13 @@ public static function stepsdefinitions($type, $component, $filter) { // The loaded steps depends on the component specified. behat_config_manager::update_config_file($component, false); - // The Moodle\BehatExtension\HelpPrinter\MoodleDefinitionsPrinter will parse this search format. + // The Moodle\BehatExtension\Definition\Printer\ConsoleDefinitionInformationPrinter will parse this search format. if ($type) { $filter .= '&&' . $type; } if ($filter) { - $filteroption = ' -d "' . $filter . '"'; + $filteroption = ' -d ' . escapeshellarg($filter); } else { $filteroption = ' -di'; } diff --git a/admin/tool/behat/renderer.php b/admin/tool/behat/renderer.php index 7de834a6b07de..864dc53fc3d9d 100644 --- a/admin/tool/behat/renderer.php +++ b/admin/tool/behat/renderer.php @@ -44,13 +44,9 @@ public function render_stepsdefinitions($stepsdefinitions, $form) { global $CFG; require_once($CFG->libdir . '/behat/classes/behat_selectors.php'); - $html = $this->generic_info(); - - // Form. - ob_start(); - $form->display(); - $html .= ob_get_contents(); - ob_end_clean(); + $html = $this->output->header(); + $html .= $this->output->heading(get_string('pluginname', 'tool_behat')); + $html .= $form->render(); if (empty($stepsdefinitions)) { $stepsdefinitions = get_string('nostepsdefinitions', 'tool_behat'); @@ -128,7 +124,9 @@ function ($matches) { */ public function render_error($msg) { - $html = $this->generic_info(); + $html = $this->output->header(); + $html .= $this->output->heading(get_string('pluginname', 'tool_behat')); + $html .= $this->generic_info(); $a = new stdClass(); $a->errormsg = $msg; @@ -153,13 +151,7 @@ public function render_error($msg) { * * @return string */ - protected function generic_info() { - - $title = get_string('pluginname', 'tool_behat'); - - // Header. - $html = $this->output->header(); - $html .= $this->output->heading($title); + public function generic_info() { // Info. $installurl = behat_command::DOCS_URL; @@ -175,8 +167,7 @@ protected function generic_info() { ); // List of steps. - $html .= $this->output->box_start(); - $html .= html_writer::tag('h3', get_string('infoheading', 'tool_behat')); + $html = $this->output->box_start(); $html .= html_writer::tag('div', get_string('aim', 'tool_behat')); $html .= html_writer::start_tag('div'); $html .= html_writer::start_tag('ul'); diff --git a/admin/tool/behat/steps_definitions_form.php b/admin/tool/behat/steps_definitions_form.php index 12bbee6bb27d7..f9bef39c9a9b9 100644 --- a/admin/tool/behat/steps_definitions_form.php +++ b/admin/tool/behat/steps_definitions_form.php @@ -40,8 +40,14 @@ class steps_definitions_form extends moodleform { * @return void */ public function definition() { + global $PAGE; $mform = $this->_form; + $output = $PAGE->get_renderer('tool_behat'); + + $mform->addElement('header', 'info', get_string('infoheading', 'tool_behat')); + $mform->setExpanded('info', false); + $mform->addElement('html', $output->generic_info()); $mform->addElement('header', 'filters', get_string('stepsdefinitionsfilters', 'tool_behat')); diff --git a/admin/tool/behat/styles.css b/admin/tool/behat/styles.css index 8c5634ad757c0..c4fdb63ded9e5 100644 --- a/admin/tool/behat/styles.css +++ b/admin/tool/behat/styles.css @@ -1,25 +1,31 @@ -.steps-definitions { - border-style: solid; - border-width: 1px; - border-color: #bbb; - padding: 5px; - margin: auto; - width: 50%; +#page-admin-tool-behat-index .steps-definitions { + margin: 1rem auto; } -.steps-definitions .step { - margin: 10px 0 10px 0; +#page-admin-tool-behat-index .steps-definitions .step { + margin: 1rem 0 0 0; + border: 1px solid #eee; + padding: 1rem; } -.steps-definitions .stepdescription { - color: #bf8c12; +#page-admin-tool-behat-index .steps-definitions .stepdescription { + font-style: italic; } -.steps-definitions .steptype { +#page-admin-tool-behat-index .steps-definitions .stepcontent { + margin: 1rem 0; +} + +#page-admin-tool-behat-index .steps-definitions .steptype { color: #1467a6; - margin-right: 5px; + margin-right: 1ex; +} + +#page-admin-tool-behat-index .steps-definitions .stepapipath { + font-family: monospace; + font-size: smaller; } -.steps-definitions .stepregex { +#page-admin-tool-behat-index .steps-definitions .stepregex { color: #060; } diff --git a/admin/tool/behat/tests/behat/edit_permissions.feature b/admin/tool/behat/tests/behat/edit_permissions.feature index 5de8e0a0c866b..e3ffc92aef164 100644 --- a/admin/tool/behat/tests/behat/edit_permissions.feature +++ b/admin/tool/behat/tests/behat/edit_permissions.feature @@ -20,12 +20,12 @@ Feature: Edit capabilities And I set the following system permissions of "Teacher" role: | capability | permission | | block/mnet_hosts:myaddinstance | Allow | - | moodle/community:add | Inherit | + | moodle/site:messageanyuser | Inherit | | moodle/grade:managesharedforms | Prevent | | moodle/course:request | Prohibit | When I follow "Edit Teacher role" Then "block/mnet_hosts:myaddinstance" capability has "Allow" permission - And "moodle/community:add" capability has "Not set" permission + And "moodle/site:messageanyuser" capability has "Not set" permission And "moodle/grade:managesharedforms" capability has "Prevent" permission And "moodle/course:request" capability has "Prohibit" permission diff --git a/admin/tool/behat/tests/behat/get_and_set_fields_in_containers.feature b/admin/tool/behat/tests/behat/get_and_set_fields_in_containers.feature new file mode 100644 index 0000000000000..cbc84c36a6b57 --- /dev/null +++ b/admin/tool/behat/tests/behat/get_and_set_fields_in_containers.feature @@ -0,0 +1,27 @@ +@tool_behat +Feature: Behat steps for interacting with form work + In order to test my Moodle code + As a developer + I need the Behat steps for form elements to work reliably + + @javascript + Scenario: Test fields in containers + Given the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + When I log in as "admin" + And I am on "Course 1" course homepage + # Just get to any form. + And I navigate to "Edit settings" in current page administration + And I set the field "Course full name" in the "General" "fieldset" to "Frog" + And I set the following fields in the "Appearance" "fieldset" to these values: + | Show activity reports | Yes | + | Number of announcements | 1 | + Then the field "Show activity reports" in the "Appearance" "fieldset" matches value "Yes" + And the field "Show activity reports" in the "Appearance" "fieldset" does not match value "No" + And the following fields in the "region-main" "region" match these values: + | Course full name | Frog | + | Number of announcements | 1 | + And the following fields in the "region-main" "region" do not match these values: + | Course full name | Course 1 | + | Number of announcements | 5 | diff --git a/admin/tool/behat/tests/behat/list_steps.feature b/admin/tool/behat/tests/behat/list_steps.feature index 9ad81f9c2eee0..8e59cb8f50dec 100644 --- a/admin/tool/behat/tests/behat/list_steps.feature +++ b/admin/tool/behat/tests/behat/list_steps.feature @@ -26,3 +26,11 @@ Feature: List the system steps definitions Given I set the field "Contains" to "homepage" When I press "Filter" Then I should see "Opens Moodle homepage." + + @javascript + Scenario: Filtering by the multiple words pattern + Given I set the field "Contains" to "should exist" + When I press "Filter" + Then I should not see "There aren't steps definitions matching this filter" + And I should see "Checks the provided element and selector type exists in the current page." + And I should see "Checks that an element and selector type exists in another element and selector type on the current page." diff --git a/admin/tool/capability/renderer.php b/admin/tool/capability/renderer.php index dde7c3e6bdb50..427613ef1596b 100644 --- a/admin/tool/capability/renderer.php +++ b/admin/tool/capability/renderer.php @@ -75,6 +75,7 @@ protected function get_permission_classes() { * @return string */ public function capability_comparison_table(array $capabilities, $contextid, array $roles) { + static $capabilitycontexts = array(); $strpermissions = $this->get_permission_strings(); $permissionclasses = $this->get_permission_classes(); @@ -93,7 +94,11 @@ public function capability_comparison_table(array $capabilities, $contextid, arr $table->data = array(); foreach ($capabilities as $capability) { - $contexts = tool_capability_calculate_role_data($capability, $roles); + if (empty($capabilitycontexts[$capability])) { + $capabilitycontexts[$capability] = tool_capability_calculate_role_data($capability, $roles); + } + $contexts = $capabilitycontexts[$capability]; + $captitle = new html_table_cell(get_capability_string($capability) . html_writer::span($capability)); $captitle->header = true; @@ -114,15 +119,17 @@ public function capability_comparison_table(array $capabilities, $contextid, arr } // Start the list item, and print the context name as a link to the place to make changes. - if ($contextid == context_system::instance()->id) { + $context = context::instance_by_id($contextid); + + if ($context instanceof context_system) { $url = new moodle_url('/admin/roles/manage.php'); - $title = get_string('changeroles', 'tool_capability'); } else { - $url = new moodle_url('/admin/roles/override.php', array('contextid' => $contextid)); - $title = get_string('changeoverrides', 'tool_capability'); + $url = new moodle_url('/admin/roles/permissions.php', ['contextid' => $contextid]); } - $context = context::instance_by_id($contextid); - $html = $this->output->heading(html_writer::link($url, $context->get_context_name(), array('title' => $title)), 3); + + $title = get_string('permissionsincontext', 'core_role', $context->get_context_name()); + + $html = $this->output->heading(html_writer::link($url, $context->get_context_name(), ['title' => $title]), 3); $html .= html_writer::table($table); // If there are any child contexts, print them recursively. if (!empty($contexts[$contextid]->children)) { @@ -133,4 +140,4 @@ public function capability_comparison_table(array $capabilities, $contextid, arr return $html; } -} \ No newline at end of file +} diff --git a/admin/tool/cohortroles/classes/api.php b/admin/tool/cohortroles/classes/api.php index b3f0e541e0538..89f54aea73da5 100644 --- a/admin/tool/cohortroles/classes/api.php +++ b/admin/tool/cohortroles/classes/api.php @@ -144,6 +144,10 @@ public static function sync_all_cohort_roles() { $rolesadded = array(); $rolesremoved = array(); + // Remove any cohort role mappings for roles which have been deleted. + // The role assignments are not a consideration because these will have been removed when the role was. + $DB->delete_records_select('tool_cohortroles', "roleid NOT IN (SELECT id FROM {role})"); + // Get all cohort role assignments and group them by user and role. $all = cohort_role_assignment::get_records(array(), 'userid, roleid'); // We build an better structure to loop on. @@ -222,6 +226,48 @@ public static function sync_all_cohort_roles() { } } + // Clean the legacy role assignments which are stale. + $paramsclean['usercontext'] = CONTEXT_USER; + $paramsclean['component'] = 'tool_cohortroles'; + $sql = 'SELECT DISTINCT(ra.id), ra.roleid, ra.userid, ra.contextid, ctx.instanceid + FROM {role_assignments} ra + JOIN {context} ctx ON ctx.id = ra.contextid AND ctx.contextlevel = :usercontext + JOIN {cohort_members} cm ON cm.userid = ctx.instanceid + LEFT JOIN {tool_cohortroles} tc ON tc.cohortid = cm.cohortid + AND tc.userid = ra.userid + AND tc.roleid = ra.roleid + WHERE ra.component = :component + AND tc.id is null'; + if ($candidatelegacyassignments = $DB->get_records_sql($sql, $paramsclean)) { + $sql = 'SELECT DISTINCT(ra.id) + FROM {role_assignments} ra + JOIN {context} ctx ON ctx.id = ra.contextid AND ctx.contextlevel = :usercontext + JOIN {cohort_members} cm ON cm.userid = ctx.instanceid + JOIN {tool_cohortroles} tc ON tc.cohortid = cm.cohortid AND tc.userid = ra.userid + WHERE ra.component = :component'; + if ($currentvalidroleassignments = $DB->get_records_sql($sql, $paramsclean)) { + foreach ($candidatelegacyassignments as $candidate) { + if (!array_key_exists($candidate->id, $currentvalidroleassignments)) { + role_unassign($candidate->roleid, $candidate->userid, $candidate->contextid, 'tool_cohortroles'); + $rolesremoved[] = array( + 'useridassignedto' => $candidate->userid, + 'useridassignedover' => $candidate->instanceid, + 'roleid' => $candidate->roleid + ); + } + } + } else { + foreach ($candidatelegacyassignments as $candidate) { + role_unassign($candidate->roleid, $candidate->userid, $candidate->contextid, 'tool_cohortroles'); + $rolesremoved[] = array( + 'useridassignedto' => $candidate->userid, + 'useridassignedover' => $candidate->instanceid, + 'roleid' => $candidate->roleid + ); + } + } + } + return array('rolesadded' => $rolesadded, 'rolesremoved' => $rolesremoved); } diff --git a/admin/tool/cohortroles/classes/cohort_role_assignment.php b/admin/tool/cohortroles/classes/cohort_role_assignment.php index 08ac464107621..08255cfb119a3 100644 --- a/admin/tool/cohortroles/classes/cohort_role_assignment.php +++ b/admin/tool/cohortroles/classes/cohort_role_assignment.php @@ -103,5 +103,4 @@ protected function validate_cohortid($value) { return true; } - } diff --git a/admin/tool/cohortroles/tests/api_test.php b/admin/tool/cohortroles/tests/api_test.php index d276a2f13ed07..2d99e5c37acb2 100644 --- a/admin/tool/cohortroles/tests/api_test.php +++ b/admin/tool/cohortroles/tests/api_test.php @@ -50,8 +50,6 @@ class tool_cohortroles_api_testcase extends advanced_testcase { * Setup function- we will create a course and add an assign instance to it. */ protected function setUp() { - global $DB; - $this->resetAfterTest(true); // Create some users. @@ -133,14 +131,91 @@ public function test_delete_cohort_role_assignment_with_invalid_data() { public function test_delete_cohort_role_assignment() { $this->setAdminUser(); - $params = (object) array( + // Create a cohort role assigment. + $params = (object) [ 'userid' => $this->userassignto->id, 'roleid' => $this->roleid, 'cohortid' => $this->cohort->id - ); - $result = api::create_cohort_role_assignment($params); - $worked = api::delete_cohort_role_assignment($result->get('id')); - $this->assertTrue($worked); + ]; + $cohortroleassignment = api::create_cohort_role_assignment($params); + $sync = api::sync_all_cohort_roles(); + $rolesadded = [ + [ + 'useridassignedto' => $this->userassignto->id, + 'useridassignedover' => $this->userassignover->id, + 'roleid' => $this->roleid + ] + ]; + $expected = [ + 'rolesadded' => $rolesadded, + 'rolesremoved' => [] + ]; + $this->assertEquals($sync, $expected); + + // Delete the cohort role assigment and confirm the roles are removed. + $result = api::delete_cohort_role_assignment($cohortroleassignment->get('id')); + $this->assertTrue($result); + $sync = api::sync_all_cohort_roles(); + $expected = [ + 'rolesadded' => [], + 'rolesremoved' => $rolesadded + ]; + $this->assertEquals($expected, $sync); + } + + /** + * Test case verifying that syncing won't remove role assignments if they are valid for another cohort role assignment. + */ + public function test_delete_cohort_role_assignment_cohorts_having_same_members() { + $this->setAdminUser(); + + // Create 2 cohorts, with a 1 user (user1) present in both, + // and user2 and user3 members of 1 cohort each. + $cohort1 = $this->getDataGenerator()->create_cohort(); + $cohort2 = $this->getDataGenerator()->create_cohort(); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + cohort_add_member($cohort1->id, $user1->id); + cohort_add_member($cohort1->id, $user2->id); + cohort_add_member($cohort2->id, $user1->id); + cohort_add_member($cohort2->id, $user3->id); + + // And a role and a user to assign that role to. + $user4 = $this->getDataGenerator()->create_user(); // A cohort manager, for example. + $roleid = create_role('Role 1', 'myrole', 'test'); + + // Assign the role for user4 in both cohorts. + $params = (object) [ + 'userid' => $user4->id, + 'roleid' => $roleid, + 'cohortid' => $cohort1->id + ]; + $cohort1roleassignment = api::create_cohort_role_assignment($params); + $params->cohortid = $cohort2->id; + $cohort2roleassignment = api::create_cohort_role_assignment($params); + + $sync = api::sync_all_cohort_roles(); + + // There is no guarantee about the order of roles assigned. + // so confirm we have 3 role assignments, and they are for the users 1, 2 and 3. + $this->assertCount(3, $sync['rolesadded']); + $addedusers = array_column($sync['rolesadded'], 'useridassignedover'); + $this->assertContains($user1->id, $addedusers); + $this->assertContains($user2->id, $addedusers); + $this->assertContains($user3->id, $addedusers); + + // Remove the role assignment for user4/cohort1. + // Verify only 1 role is unassigned as the others are still valid for the other cohort role assignment. + $result = api::delete_cohort_role_assignment($cohort1roleassignment->get('id')); + $this->assertTrue($result); + + $sync = api::sync_all_cohort_roles(); + + $this->assertCount(0, $sync['rolesadded']); + $this->assertCount(1, $sync['rolesremoved']); + $removedusers = array_column($sync['rolesremoved'], 'useridassignedover'); + $this->assertContains($user2->id, $removedusers); } public function test_list_cohort_role_assignments() { diff --git a/admin/tool/customlang/db/upgrade.php b/admin/tool/customlang/db/upgrade.php index 53fa80a2039d9..823c1e72a6a77 100644 --- a/admin/tool/customlang/db/upgrade.php +++ b/admin/tool/customlang/db/upgrade.php @@ -41,5 +41,8 @@ function xmldb_tool_customlang_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/customlang/templates/translator.mustache b/admin/tool/customlang/templates/translator.mustache index 5092f68bab968..62cbdac7c3107 100644 --- a/admin/tool/customlang/templates/translator.mustache +++ b/admin/tool/customlang/templates/translator.mustache @@ -113,14 +113,14 @@
{{#str}}headingstandard, tool_customlang{{/str}}
- {{{ master }}} + {{ master }}
{{{ placeholderhelp }}} {{{ outdatedhelp}}}
{{#showoriginalvsmaster}}
- {{{ original }}} + {{ original }}
{{/showoriginalvsmaster}} @@ -146,5 +146,14 @@ {{/strings}} + +
+ + +
{{/hasstrings}} diff --git a/admin/tool/dataprivacy/amd/build/expand_contract.min.js b/admin/tool/dataprivacy/amd/build/expand_contract.min.js index 3419d58da10fa..4bd453b6870c2 100644 --- a/admin/tool/dataprivacy/amd/build/expand_contract.min.js +++ b/admin/tool/dataprivacy/amd/build/expand_contract.min.js @@ -1 +1 @@ -define(["jquery","core/url","core/str"],function(a,b,c){var d=a(''),e=a('');return{expandCollapse:function(a,b){a.hasClass("hide")?(a.removeClass("hide"),a.addClass("visible"),a.attr("aria-expanded",!0),b.find(":header i.fa").removeClass("fa-plus-square"),b.find(":header i.fa").addClass("fa-minus-square"),b.find(":header img.icon").attr("src",d.attr("src"))):(a.removeClass("visible"),a.addClass("hide"),a.attr("aria-expanded",!1),b.find(":header i.fa").removeClass("fa-minus-square"),b.find(":header i.fa").addClass("fa-plus-square"),b.find(":header img.icon").attr("src",e.attr("src")))},expandCollapseAll:function(b){var f="visible"==b?"hide":"visible",g="visible"==b,h="visible"==b?"fa-plus-square":"fa-minus-square",i="visible"==b?"fa-minus-square":"fa-plus-square",j="visible"==b?d.attr("src"):e.attr("src");a("."+f).each(function(){a(this).removeClass(f),a(this).addClass(b),a(this).attr("aria-expanded",g)}),a(".tool_dataprivacy-expand-all").data("visibilityState",f),c.get_string(f,"tool_dataprivacy").then(function(b){a(".tool_dataprivacy-expand-all").html(b)})["catch"](Notification.exception),a(":header i.fa").each(function(){a(this).removeClass(h),a(this).addClass(i)}),a(":header img.icon").each(function(){a(this).attr("src",j)})}}}); \ No newline at end of file +define(["jquery","core/url","core/str"],function(a,b,c){var d=a(''),e=a(''),f={EXPAND:"fa-caret-right",COLLAPSE:"fa-caret-down"};return{expandCollapse:function(a,b){a.hasClass("hide")?(a.removeClass("hide"),a.addClass("visible"),a.attr("aria-expanded",!0),b.find(":header i.fa").removeClass(f.EXPAND),b.find(":header i.fa").addClass(f.COLLAPSE),b.find(":header img.icon").attr("src",d.attr("src"))):(a.removeClass("visible"),a.addClass("hide"),a.attr("aria-expanded",!1),b.find(":header i.fa").removeClass(f.COLLAPSE),b.find(":header i.fa").addClass(f.EXPAND),b.find(":header img.icon").attr("src",e.attr("src")))},expandCollapseAll:function(b){var g="visible"==b?"hide":"visible",h="visible"==b,i="visible"==b?f.EXPAND:f.COLLAPSE,j="visible"==b?f.COLLAPSE:f.EXPAND,k="visible"==b?d.attr("src"):e.attr("src");a("."+g).each(function(){a(this).removeClass(g),a(this).addClass(b),a(this).attr("aria-expanded",h)}),a(".tool_dataprivacy-expand-all").data("visibilityState",g),c.get_string(g,"tool_dataprivacy").then(function(b){a(".tool_dataprivacy-expand-all").html(b)})["catch"](Notification.exception),a(":header i.fa").each(function(){a(this).removeClass(i),a(this).addClass(j)}),a(":header img.icon").each(function(){a(this).attr("src",k)})}}}); \ No newline at end of file diff --git a/admin/tool/dataprivacy/amd/src/expand_contract.js b/admin/tool/dataprivacy/amd/src/expand_contract.js index cf509b5574a1c..a369f7c87213e 100644 --- a/admin/tool/dataprivacy/amd/src/expand_contract.js +++ b/admin/tool/dataprivacy/amd/src/expand_contract.js @@ -28,6 +28,14 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) { var expandedImage = $(''); var collapsedImage = $(''); + /* + * Class names to apply when expanding/collapsing nodes. + */ + var CLASSES = { + EXPAND: 'fa-caret-right', + COLLAPSE: 'fa-caret-down' + }; + return /** @alias module:tool_dataprivacy/expand-collapse */ { /** * Expand or collapse a selected node. @@ -40,15 +48,15 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) { targetnode.removeClass('hide'); targetnode.addClass('visible'); targetnode.attr('aria-expanded', true); - thisnode.find(':header i.fa').removeClass('fa-plus-square'); - thisnode.find(':header i.fa').addClass('fa-minus-square'); + thisnode.find(':header i.fa').removeClass(CLASSES.EXPAND); + thisnode.find(':header i.fa').addClass(CLASSES.COLLAPSE); thisnode.find(':header img.icon').attr('src', expandedImage.attr('src')); } else { targetnode.removeClass('visible'); targetnode.addClass('hide'); targetnode.attr('aria-expanded', false); - thisnode.find(':header i.fa').removeClass('fa-minus-square'); - thisnode.find(':header i.fa').addClass('fa-plus-square'); + thisnode.find(':header i.fa').removeClass(CLASSES.COLLAPSE); + thisnode.find(':header i.fa').addClass(CLASSES.EXPAND); thisnode.find(':header img.icon').attr('src', collapsedImage.attr('src')); } }, @@ -61,8 +69,8 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) { expandCollapseAll: function(nextstate) { var currentstate = (nextstate == 'visible') ? 'hide' : 'visible'; var ariaexpandedstate = (nextstate == 'visible') ? true : false; - var iconclassnow = (nextstate == 'visible') ? 'fa-plus-square' : 'fa-minus-square'; - var iconclassnext = (nextstate == 'visible') ? 'fa-minus-square' : 'fa-plus-square'; + var iconclassnow = (nextstate == 'visible') ? CLASSES.EXPAND : CLASSES.COLLAPSE; + var iconclassnext = (nextstate == 'visible') ? CLASSES.COLLAPSE : CLASSES.EXPAND; var imagenow = (nextstate == 'visible') ? expandedImage.attr('src') : collapsedImage.attr('src'); $('.' + currentstate).each(function() { $(this).removeClass(currentstate); diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index 80cb7ee172ec0..9ae864722745a 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -712,7 +712,7 @@ public static function notify_dpo($dpo, data_request $request) { '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 @@ -774,7 +774,8 @@ public static function require_can_create_data_request_for_user($user, $requeste public static function can_create_data_deletion_request_for_self(int $userid = null): bool { global $USER; $userid = $userid ?: $USER->id; - return has_capability('tool/dataprivacy:requestdelete', \context_user::instance($userid), $userid); + return has_capability('tool/dataprivacy:requestdelete', \context_user::instance($userid), $userid) + && !is_primary_admin($userid); } /** @@ -803,7 +804,7 @@ public static function can_create_data_deletion_request_for_children(int $userid global $USER; $requesterid = $requesterid ?: $USER->id; return has_capability('tool/dataprivacy:makedatadeletionrequestsforchildren', \context_user::instance($userid), - $requesterid); + $requesterid) && !is_primary_admin($userid); } /** diff --git a/admin/tool/dataprivacy/classes/external.php b/admin/tool/dataprivacy/classes/external.php index 20c28f9187c2e..4c4e71944ab36 100644 --- a/admin/tool/dataprivacy/classes/external.php +++ b/admin/tool/dataprivacy/classes/external.php @@ -713,7 +713,6 @@ public static function get_users($query) { list($sql, $params) = users_search_sql($query, '', false, $extrafields, $excludedusers); $users = $DB->get_records_select('user', $sql, $params, $sort, $fields, 0, 30); - $useroptions = []; foreach ($users as $user) { $useroption = (object)[ @@ -722,9 +721,10 @@ public static function get_users($query) { ]; $useroption->extrafields = []; foreach ($extrafields as $extrafield) { + // Sanitize the extra fields to prevent potential XSS exploit. $useroption->extrafields[] = (object)[ 'name' => $extrafield, - 'value' => $user->$extrafield + 'value' => s($user->$extrafield) ]; } $useroptions[$user->id] = $useroption; diff --git a/admin/tool/dataprivacy/classes/form/purpose.php b/admin/tool/dataprivacy/classes/form/purpose.php index 72472e5c8d84f..1de9317c41f26 100644 --- a/admin/tool/dataprivacy/classes/form/purpose.php +++ b/admin/tool/dataprivacy/classes/form/purpose.php @@ -440,6 +440,9 @@ protected static function convert_fields(\stdClass $data) { } if (!empty($data->sensitivedatareasons) && is_array($data->sensitivedatareasons)) { $data->sensitivedatareasons = implode(',', $data->sensitivedatareasons); + } else { + // Nothing selected. Set default value of null. + $data->sensitivedatareasons = null; } // A single value. diff --git a/admin/tool/dataprivacy/classes/task/process_data_request_task.php b/admin/tool/dataprivacy/classes/task/process_data_request_task.php index 7b9fad33b0d95..780e25afa8977 100644 --- a/admin/tool/dataprivacy/classes/task/process_data_request_task.php +++ b/admin/tool/dataprivacy/classes/task/process_data_request_task.php @@ -26,6 +26,7 @@ use action_link; use coding_exception; +use context_system; use core\message\message; use core\task\adhoc_task; use core_user; @@ -127,13 +128,17 @@ public function execute() { $thing = $fs->create_file_from_pathname($filerecord, $exportedcontent); $completestatus = api::DATAREQUEST_STATUS_DOWNLOAD_READY; } else if ($request->type == api::DATAREQUEST_TYPE_DELETE) { - // Delete the data. - $manager = new \core_privacy\manager(); - $manager->set_observer(new \tool_dataprivacy\manager_observer()); + // Delete the data for users other than the primary admin, which is rejected. + if (is_primary_admin($foruser->id)) { + $completestatus = api::DATAREQUEST_STATUS_REJECTED; + } else { + $manager = new \core_privacy\manager(); + $manager->set_observer(new \tool_dataprivacy\manager_observer()); - $manager->delete_data_for_user($approvedclcollection); - $completestatus = api::DATAREQUEST_STATUS_DELETED; - $deleteuser = !$foruser->deleted; + $manager->delete_data_for_user($approvedclcollection); + $completestatus = api::DATAREQUEST_STATUS_DELETED; + $deleteuser = !$foruser->deleted; + } } // When the preparation of the metadata finishes, update the request status to awaiting approval. @@ -148,7 +153,7 @@ public function execute() { $message->name = 'datarequestprocessingresults'; $message->userfrom = $dpo; $message->replyto = $dpo->email; - $message->replytoname = fullname($dpo->email); + $message->replytoname = fullname($dpo); $typetext = null; // Prepare the context data for the email message body. @@ -176,7 +181,8 @@ public function execute() { $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); @@ -188,7 +194,8 @@ public function execute() { // 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; diff --git a/admin/tool/dataprivacy/db/upgrade.php b/admin/tool/dataprivacy/db/upgrade.php index 47989cb8b106d..49cedd626f4d3 100644 --- a/admin/tool/dataprivacy/db/upgrade.php +++ b/admin/tool/dataprivacy/db/upgrade.php @@ -316,5 +316,8 @@ function xmldb_tool_dataprivacy_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/dataprivacy/templates/form-user-selector-suggestion.mustache b/admin/tool/dataprivacy/templates/form-user-selector-suggestion.mustache index 0f8443d313e10..92896a6e5f9ba 100644 --- a/admin/tool/dataprivacy/templates/form-user-selector-suggestion.mustache +++ b/admin/tool/dataprivacy/templates/form-user-selector-suggestion.mustache @@ -47,6 +47,6 @@ {{fullname}} {{#extrafields}} - {{value}} + {{{value}}} {{/extrafields}} diff --git a/admin/tool/dataprivacy/tests/api_test.php b/admin/tool/dataprivacy/tests/api_test.php index f95dedda43934..65894ecd94656 100644 --- a/admin/tool/dataprivacy/tests/api_test.php +++ b/admin/tool/dataprivacy/tests/api_test.php @@ -301,6 +301,34 @@ public function test_approve_data_request_non_dpo_user() { $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 that deletion requests for the primary admin are rejected + */ + public function test_reject_data_deletion_request_primary_admin() { + $this->resetAfterTest(); + $this->setAdminUser(); + + $datarequest = api::create_data_request(get_admin()->id, api::DATAREQUEST_TYPE_DELETE); + + // Approve the request and execute the ad-hoc process task. + ob_start(); + api::approve_data_request($datarequest->get('id')); + $this->runAdhocTasks('\tool_dataprivacy\task\process_data_request_task'); + ob_end_clean(); + + $request = api::get_request($datarequest->get('id')); + $this->assertEquals(api::DATAREQUEST_STATUS_REJECTED, $request->get('status')); + + // Confirm they weren't deleted. + $user = core_user::get_user($request->get('userid')); + core_user::require_active_user($user); } /** @@ -2126,6 +2154,33 @@ public function test_can_create_data_deletion_request_for_self_no() { $this->assertFalse(api::can_create_data_deletion_request_for_self()); } + /** + * Test primary admin cannot create data deletion request for themselves + */ + public function test_can_create_data_deletion_request_for_self_primary_admin() { + $this->resetAfterTest(); + $this->setAdminUser(); + $this->assertFalse(api::can_create_data_deletion_request_for_self()); + } + + /** + * Test secondary admin can create data deletion request for themselves + */ + public function test_can_create_data_deletion_request_for_self_secondary_admin() { + $this->resetAfterTest(); + + $admin1 = $this->getDataGenerator()->create_user(); + $admin2 = $this->getDataGenerator()->create_user(); + + // The primary admin is the one listed first in the 'siteadmins' config. + set_config('siteadmins', implode(',', [$admin1->id, $admin2->id])); + + // Set the current user as the second admin (non-primary). + $this->setUser($admin2); + + $this->assertTrue(api::can_create_data_deletion_request_for_self()); + } + /** * Test user can create data deletion request for themselves if they have * "tool/dataprivacy:requestdelete" capability. @@ -2171,7 +2226,8 @@ public function test_can_create_data_deletion_request_for_other_yes() { } /** - * Check parents can create data deletion request for their children but not others. + * Check parents can create data deletion request for their children (unless the child is the primary admin), + * but not other users. * * @throws coding_exception * @throws dml_exception @@ -2194,5 +2250,9 @@ public function test_can_create_data_deletion_request_for_children() { $this->setUser($parent); $this->assertTrue(api::can_create_data_deletion_request_for_children($child->id)); $this->assertFalse(api::can_create_data_deletion_request_for_children($otheruser->id)); + + // Now make child the primary admin, confirm parent can't make deletion request. + set_config('siteadmins', $child->id); + $this->assertFalse(api::can_create_data_deletion_request_for_children($child->id)); } } diff --git a/admin/tool/dataprivacy/tests/behat/datadelete.feature b/admin/tool/dataprivacy/tests/behat/datadelete.feature index 5806102f6cc17..0a4da1b114f69 100644 --- a/admin/tool/dataprivacy/tests/behat/datadelete.feature +++ b/admin/tool/dataprivacy/tests/behat/datadelete.feature @@ -189,6 +189,12 @@ Feature: Data delete from the privacy API And I follow "Profile" in the user menu Then I should not see "Delete my account" + @javascript + Scenario: As a primary admin, the link to create a data deletion request should not be shown. + Given I log in as "admin" + When I follow "Profile" in the user menu + Then I should not see "Delete my account" + @javascript Scenario: As a Privacy Officer, I cannot Approve to Deny deletion data request without permission. Given the following "permission overrides" exist: diff --git a/admin/tool/log/db/upgrade.php b/admin/tool/log/db/upgrade.php index bbf196143f9a0..b3de05d459bba 100644 --- a/admin/tool/log/db/upgrade.php +++ b/admin/tool/log/db/upgrade.php @@ -45,5 +45,8 @@ function xmldb_tool_log_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/log/lang/en/tool_log.php b/admin/tool/log/lang/en/tool_log.php index 571b91fdd8fba..9bc9423a8c9f0 100644 --- a/admin/tool/log/lang/en/tool_log.php +++ b/admin/tool/log/lang/en/tool_log.php @@ -24,7 +24,7 @@ $string['actlogshdr'] = 'Available log stores'; $string['configlogplugins'] = 'Please enable all required plugins and arrange them in appropriate order.'; -$string['exportlog'] = 'Include logs when exporting.'; +$string['exportlog'] = 'Include logs when exporting'; $string['exportlogdetail'] = 'Include logs that relate to the user when exporting.'; $string['logging'] = 'Logging'; $string['managelogging'] = 'Manage log stores'; diff --git a/admin/tool/log/store/database/db/upgrade.php b/admin/tool/log/store/database/db/upgrade.php index b5536abdda7e6..e17a51e1ea839 100644 --- a/admin/tool/log/store/database/db/upgrade.php +++ b/admin/tool/log/store/database/db/upgrade.php @@ -46,5 +46,8 @@ function xmldb_logstore_database_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2019032800, 'logstore', 'database'); } + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/log/store/standard/db/upgrade.php b/admin/tool/log/store/standard/db/upgrade.php index e1ac5a2ddc102..a341ac8eab900 100644 --- a/admin/tool/log/store/standard/db/upgrade.php +++ b/admin/tool/log/store/standard/db/upgrade.php @@ -46,5 +46,8 @@ function xmldb_logstore_standard_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2019032800, 'logstore', 'standard'); } + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/lp/amd/build/competencies.min.js b/admin/tool/lp/amd/build/competencies.min.js index 6d24fd8aeaf14..51859f56099a7 100644 --- a/admin/tool/lp/amd/build/competencies.min.js +++ b/admin/tool/lp/amd/build/competencies.min.js @@ -1 +1 @@ -define(["jquery","core/notification","core/ajax","core/templates","core/str","tool_lp/competencypicker","tool_lp/dragdrop-reorder"],function(a,b,c,d,e,f,g){var h=function(b,c,d){this.itemid=b,this.itemtype=c,this.pageContextId=d,this.pickerInstance=null,a('[data-region="actions"] button').prop("disabled",!1),this.registerEvents(),this.registerDragDrop()};return h.prototype.registerDragDrop=function(){var a=this;e.get_string("movecompetency","tool_lp").done(function(b){g.dragdrop("movecompetency",b,{identifier:"movecompetency",component:"tool_lp"},{identifier:"movecompetencyafter",component:"tool_lp"},"drag-samenode","drag-parentnode","drag-handlecontainer",function(b,c){a.handleDrop(b,c)})}).fail(b.exception)},h.prototype.handleDrop=function(d,e){var f=a(d).data("id"),g=a(e).data("id"),h=this,i=[];if("course"==h.itemtype)i=c.call([{methodname:"core_competency_reorder_course_competency",args:{courseid:h.itemid,competencyidfrom:f,competencyidto:g}}]);else if("template"==h.itemtype)i=c.call([{methodname:"core_competency_reorder_template_competency",args:{templateid:h.itemid,competencyidfrom:f,competencyidto:g}}]);else{if("plan"!=h.itemtype)return;i=c.call([{methodname:"core_competency_reorder_plan_competency",args:{planid:h.itemid,competencyidfrom:f,competencyidto:g}}])}i[0].fail(b.exception)},h.prototype.pickCompetency=function(){var e,g,h,i,j=this;j.pickerInstance||("template"!==j.itemtype&&"course"!==j.itemtype||(i="parents"),j.pickerInstance=new f(j.pageContextId,(!1),i),j.pickerInstance.on("save",function(f,i){var k=i.competencyIds;"course"===j.itemtype?(e=[],a.each(k,function(a,b){e.push({methodname:"core_competency_add_competency_to_course",args:{courseid:j.itemid,competencyid:b}})}),e.push({methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:j.itemid,moduleid:0}}),g="tool_lp/course_competencies_page",h="coursecompetenciespage"):"template"===j.itemtype?(e=[],a.each(k,function(a,b){e.push({methodname:"core_competency_add_competency_to_template",args:{templateid:j.itemid,competencyid:b}})}),e.push({methodname:"tool_lp_data_for_template_competencies_page",args:{templateid:j.itemid,pagecontext:{contextid:j.pageContextId}}}),g="tool_lp/template_competencies_page",h="templatecompetenciespage"):"plan"===j.itemtype&&(e=[],a.each(k,function(a,b){e.push({methodname:"core_competency_add_competency_to_plan",args:{planid:j.itemid,competencyid:b}})}),e.push({methodname:"tool_lp_data_for_plan_page",args:{planid:j.itemid}}),g="tool_lp/plan_page",h="plan-page"),c.call(e)[e.length-1].then(function(a){return d.render(g,a)}).then(function(b,c){a('[data-region="'+h+'"]').replaceWith(b),d.runTemplateJS(c)})["catch"](b.exception)})),j.pickerInstance.display()},h.prototype.doDelete=function(e){var f=this,g=[],h="",i="";"course"==f.itemtype?(g=c.call([{methodname:"core_competency_remove_competency_from_course",args:{courseid:f.itemid,competencyid:e}},{methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:f.itemid,moduleid:0}}]),h="tool_lp/course_competencies_page",i="coursecompetenciespage"):"template"==f.itemtype?(g=c.call([{methodname:"core_competency_remove_competency_from_template",args:{templateid:f.itemid,competencyid:e}},{methodname:"tool_lp_data_for_template_competencies_page",args:{templateid:f.itemid,pagecontext:{contextid:f.pageContextId}}}]),h="tool_lp/template_competencies_page",i="templatecompetenciespage"):"plan"==f.itemtype&&(g=c.call([{methodname:"core_competency_remove_competency_from_plan",args:{planid:f.itemid,competencyid:e}},{methodname:"tool_lp_data_for_plan_page",args:{planid:f.itemid}}]),h="tool_lp/plan_page",i="plan-page"),g[1].done(function(c){d.render(h,c).done(function(b,c){a('[data-region="'+i+'"]').replaceWith(b),d.runTemplateJS(c)}).fail(b.exception)}).fail(b.exception)},h.prototype.deleteHandler=function(a){var d,f=this,g=[];if("course"==f.itemtype)d="unlinkcompetencycourse";else if("template"==f.itemtype)d="unlinkcompetencytemplate";else{if("plan"!=f.itemtype)return;d="unlinkcompetencyplan"}g=c.call([{methodname:"core_competency_read_competency",args:{id:a}}]),g[0].done(function(c){e.get_strings([{key:"confirm",component:"moodle"},{key:d,component:"tool_lp",param:c.shortname},{key:"confirm",component:"moodle"},{key:"cancel",component:"moodle"}]).done(function(c){b.confirm(c[0],c[1],c[2],c[3],function(){f.doDelete(a)})}).fail(b.exception)}).fail(b.exception)},h.prototype.registerEvents=function(){var e=this;"course"==e.itemtype&&a('[data-region="coursecompetenciespage"]').on("change",'select[data-field="ruleoutcome"]',function(f){var g=[],h="tool_lp/course_competencies_page",i="coursecompetenciespage",j=a(f.target).data("id"),k=a(f.target).val();g=c.call([{methodname:"core_competency_set_course_competency_ruleoutcome",args:{coursecompetencyid:j,ruleoutcome:k}},{methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:e.itemid,moduleid:0}}]),g[1].done(function(c){d.render(h,c).done(function(b,c){a('[data-region="'+i+'"]').replaceWith(b),d.runTemplateJS(c)}).fail(b.exception)}).fail(b.exception)}),a('[data-region="actions"] button').click(function(a){a.preventDefault(),e.pickCompetency()}),a('[data-action="delete-competency-link"]').click(function(b){b.preventDefault();var c=a(b.target).closest("[data-id]").data("id");e.deleteHandler(c)})},h}); \ No newline at end of file +define(["jquery","core/notification","core/ajax","core/templates","core/str","tool_lp/competencypicker","tool_lp/dragdrop-reorder","core/pending"],function(a,b,c,d,e,f,g,h){var i=function(b,c,d){this.itemid=b,this.itemtype=c,this.pageContextId=d,this.pickerInstance=null,a('[data-region="actions"] button').prop("disabled",!1),this.registerEvents(),this.registerDragDrop()};return i.prototype.registerDragDrop=function(){var a=this;e.get_string("movecompetency","tool_lp").done(function(b){g.dragdrop("movecompetency",b,{identifier:"movecompetency",component:"tool_lp"},{identifier:"movecompetencyafter",component:"tool_lp"},"drag-samenode","drag-parentnode","drag-handlecontainer",function(b,c){a.handleDrop(b,c)})}).fail(b.exception)},i.prototype.handleDrop=function(d,e){var f=a(d).data("id"),g=a(e).data("id"),h=this,i=[];if("course"==h.itemtype)i=c.call([{methodname:"core_competency_reorder_course_competency",args:{courseid:h.itemid,competencyidfrom:f,competencyidto:g}}]);else if("template"==h.itemtype)i=c.call([{methodname:"core_competency_reorder_template_competency",args:{templateid:h.itemid,competencyidfrom:f,competencyidto:g}}]);else{if("plan"!=h.itemtype)return;i=c.call([{methodname:"core_competency_reorder_plan_competency",args:{planid:h.itemid,competencyidfrom:f,competencyidto:g}}])}i[0].fail(b.exception)},i.prototype.pickCompetency=function(){var e,g,i,j,k=this;return k.pickerInstance||("template"!==k.itemtype&&"course"!==k.itemtype||(j="parents"),k.pickerInstance=new f(k.pageContextId,(!1),j),k.pickerInstance.on("save",function(f,j){var l=j.competencyIds,m=new h;"course"===k.itemtype?(e=[],a.each(l,function(a,b){e.push({methodname:"core_competency_add_competency_to_course",args:{courseid:k.itemid,competencyid:b}})}),e.push({methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:k.itemid,moduleid:0}}),g="tool_lp/course_competencies_page",i="coursecompetenciespage"):"template"===k.itemtype?(e=[],a.each(l,function(a,b){e.push({methodname:"core_competency_add_competency_to_template",args:{templateid:k.itemid,competencyid:b}})}),e.push({methodname:"tool_lp_data_for_template_competencies_page",args:{templateid:k.itemid,pagecontext:{contextid:k.pageContextId}}}),g="tool_lp/template_competencies_page",i="templatecompetenciespage"):"plan"===k.itemtype&&(e=[],a.each(l,function(a,b){e.push({methodname:"core_competency_add_competency_to_plan",args:{planid:k.itemid,competencyid:b}})}),e.push({methodname:"tool_lp_data_for_plan_page",args:{planid:k.itemid}}),g="tool_lp/plan_page",i="plan-page"),c.call(e)[e.length-1].then(function(a){return d.render(g,a)}).then(function(b,c){d.replaceNode(a('[data-region="'+i+'"]'),b,c)}).then(m.resolve)["catch"](b.exception)})),k.pickerInstance.display()},i.prototype.doDelete=function(e){var f=this,g=[],h="",i="";"course"==f.itemtype?(g=c.call([{methodname:"core_competency_remove_competency_from_course",args:{courseid:f.itemid,competencyid:e}},{methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:f.itemid,moduleid:0}}]),h="tool_lp/course_competencies_page",i="coursecompetenciespage"):"template"==f.itemtype?(g=c.call([{methodname:"core_competency_remove_competency_from_template",args:{templateid:f.itemid,competencyid:e}},{methodname:"tool_lp_data_for_template_competencies_page",args:{templateid:f.itemid,pagecontext:{contextid:f.pageContextId}}}]),h="tool_lp/template_competencies_page",i="templatecompetenciespage"):"plan"==f.itemtype&&(g=c.call([{methodname:"core_competency_remove_competency_from_plan",args:{planid:f.itemid,competencyid:e}},{methodname:"tool_lp_data_for_plan_page",args:{planid:f.itemid}}]),h="tool_lp/plan_page",i="plan-page"),g[1].done(function(c){d.render(h,c).done(function(b,c){a('[data-region="'+i+'"]').replaceWith(b),d.runTemplateJS(c)}).fail(b.exception)}).fail(b.exception)},i.prototype.deleteHandler=function(a){var d,f=this,g=[];if("course"==f.itemtype)d="unlinkcompetencycourse";else if("template"==f.itemtype)d="unlinkcompetencytemplate";else{if("plan"!=f.itemtype)return;d="unlinkcompetencyplan"}g=c.call([{methodname:"core_competency_read_competency",args:{id:a}}]),g[0].done(function(c){e.get_strings([{key:"confirm",component:"moodle"},{key:d,component:"tool_lp",param:c.shortname},{key:"confirm",component:"moodle"},{key:"cancel",component:"moodle"}]).done(function(c){b.confirm(c[0],c[1],c[2],c[3],function(){f.doDelete(a)})}).fail(b.exception)}).fail(b.exception)},i.prototype.registerEvents=function(){var e=this;"course"==e.itemtype&&a('[data-region="coursecompetenciespage"]').on("change",'select[data-field="ruleoutcome"]',function(f){var g=new h,i=[],j="tool_lp/course_competencies_page",k="coursecompetenciespage",l=a(f.target).data("id"),m=a(f.target).val();i=c.call([{methodname:"core_competency_set_course_competency_ruleoutcome",args:{coursecompetencyid:l,ruleoutcome:m}},{methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:e.itemid,moduleid:0}}]),i[1].then(function(a){return d.render(j,a)}).then(function(b,c){return d.replaceNode(a('[data-region="'+k+'"]'),b,c)}).then(g.resolve)["catch"](b.exception)}),a('[data-region="actions"] button').click(function(a){var b=new h;a.preventDefault(),e.pickCompetency().then(b.resolve)["catch"]()}),a('[data-action="delete-competency-link"]').click(function(b){b.preventDefault();var c=a(b.target).closest("[data-id]").data("id");e.deleteHandler(c)})},i}); \ No newline at end of file diff --git a/admin/tool/lp/amd/build/competencyactions.min.js b/admin/tool/lp/amd/build/competencyactions.min.js index 993a3ef943bf6..ebca76e08e770 100644 --- a/admin/tool/lp/amd/build/competencyactions.min.js +++ b/admin/tool/lp/amd/build/competencyactions.min.js @@ -1 +1 @@ -define(["jquery","core/url","core/templates","core/notification","core/str","core/ajax","tool_lp/dragdrop-reorder","tool_lp/tree","tool_lp/dialogue","tool_lp/menubar","tool_lp/competencypicker","tool_lp/competency_outcomes","tool_lp/competencyruleconfig"],function(a,b,c,d,e,f,g,h,i,j,k,l,m){var n,o,p,q,r,s,t=null,u=null,v=null,w=null,x=function(){var c=a('[data-region="competencyactions"]').data("competency"),f={competencyframeworkid:t.getCompetencyFrameworkId(),pagecontextid:n};null!==c&&(f.parentid=c.id);var g=function(){var c=a.param(f);window.location=b.relativeUrl("/admin/tool/lp/editcompetency.php?"+c)};null!==c&&t.hasRule(c.id)?e.get_strings([{key:"confirm",component:"moodle"},{key:"addingcompetencywillresetparentrule",component:"tool_lp",param:c.shortname},{key:"yes",component:"core"},{key:"no",component:"core"}]).done(function(a){d.confirm(a[0],a[1],a[2],a[3],g)}).fail(d.exception):g()},y=function(){var b=a('[data-region="filtercompetencies"]').data("frameworkid"),c=f.call([{methodname:"core_competency_set_parent_competency",args:{competencyid:u,parentid:v}},{methodname:"tool_lp_data_for_competencies_manage_page",args:{competencyframeworkid:b,search:a('[data-region="filtercompetencies"] input').val()}}]);c[1].done(E).fail(d.exception)},z=function(){if(v="undefined"==typeof v?0:v,v!=u){var a=t.getCompetency(v)||{},b=t.getCompetency(u)||{},c="movecompetencywillresetrules",f=!1;b.parentid!=v&&(a.path&&a.path.indexOf("/"+b.id+"/")>=0&&(c="movecompetencytochildofselfwillresetrules",f=f||t.hasRule(b.id)),f=f||t.hasRule(a.id)||t.hasRule(b.parentid),f?e.get_strings([{key:"confirm",component:"moodle"},{key:c,component:"tool_lp"},{key:"yes",component:"moodle"},{key:"no",component:"moodle"}]).done(function(a){d.confirm(a[0],a[1],a[2],a[3],y)}).fail(d.exception):y())}},A=function(b){var c=a(b.getContent()),d=c.find("[data-enhance=movetree]"),e=new h(d,(!1));e.on("selectionchanged",function(b,c){var d=c.selected;v=a(d).data("id")}),d.show(),c.on("click",'[data-action="move"]',function(){b.close(),z()}),c.on("click",'[data-action="cancel"]',function(){b.close()})},B=function(a,b){var c;for(c=0;cspan",O).on("dragover","li>span",P).on("dragenter","li>span",Q).on("dragleave","li>span",R).on("drop","li>span",S),b.on("selectionchanged",$),p=new m(t,s),p.on("save",L.bind(this))}}}); \ No newline at end of file +define(["jquery","core/url","core/templates","core/notification","core/str","core/ajax","tool_lp/dragdrop-reorder","tool_lp/tree","tool_lp/dialogue","tool_lp/menubar","tool_lp/competencypicker","tool_lp/competency_outcomes","tool_lp/competencyruleconfig","core/pending"],function(a,b,c,d,e,f,g,h,i,j,k,l,m,n){var o,p,q,r,s,t,u=null,v=null,w=null,x=null,y=function(){var c=a('[data-region="competencyactions"]').data("competency"),f={competencyframeworkid:u.getCompetencyFrameworkId(),pagecontextid:o};null!==c&&(f.parentid=c.id);var g=function(){var c=a.param(f);window.location=b.relativeUrl("/admin/tool/lp/editcompetency.php?"+c)};null!==c&&u.hasRule(c.id)?e.get_strings([{key:"confirm",component:"moodle"},{key:"addingcompetencywillresetparentrule",component:"tool_lp",param:c.shortname},{key:"yes",component:"core"},{key:"no",component:"core"}]).done(function(a){d.confirm(a[0],a[1],a[2],a[3],g)}).fail(d.exception):g()},z=function(){var b=a('[data-region="filtercompetencies"]').data("frameworkid"),c=f.call([{methodname:"core_competency_set_parent_competency",args:{competencyid:v,parentid:w}},{methodname:"tool_lp_data_for_competencies_manage_page",args:{competencyframeworkid:b,search:a('[data-region="filtercompetencies"] input').val()}}]);c[1].done(F).fail(d.exception)},A=function(){if(w="undefined"==typeof w?0:w,w!=v){var a=u.getCompetency(w)||{},b=u.getCompetency(v)||{},c="movecompetencywillresetrules",f=!1;b.parentid!=w&&(a.path&&a.path.indexOf("/"+b.id+"/")>=0&&(c="movecompetencytochildofselfwillresetrules",f=f||u.hasRule(b.id)),f=f||u.hasRule(a.id)||u.hasRule(b.parentid),f?e.get_strings([{key:"confirm",component:"moodle"},{key:c,component:"tool_lp"},{key:"yes",component:"moodle"},{key:"no",component:"moodle"}]).done(function(a){d.confirm(a[0],a[1],a[2],a[3],z)}).fail(d.exception):z())}},B=function(b){var c=a(b.getContent()),d=c.find("[data-enhance=movetree]"),e=new h(d,(!1));e.on("selectionchanged",function(b,c){var d=c.selected;w=a(d).data("id")}),d.show(),c.on("click",'[data-action="move"]',function(){b.close(),A()}),c.on("click",'[data-action="cancel"]',function(){b.close()})},C=function(a,b){var c;for(c=0;cspan",P).on("dragover","li>span",Q).on("dragenter","li>span",R).on("dragleave","li>span",S).on("drop","li>span",T),b.on("selectionchanged",_),q=new m(u,t),q.on("save",M.bind(this))}}}); \ No newline at end of file diff --git a/admin/tool/lp/amd/build/competencypicker.min.js b/admin/tool/lp/amd/build/competencypicker.min.js index e32b0808ae228..d9e8f4099f178 100644 --- a/admin/tool/lp/amd/build/competencypicker.min.js +++ b/admin/tool/lp/amd/build/competencypicker.min.js @@ -1 +1 @@ -define(["jquery","core/notification","core/ajax","core/templates","tool_lp/dialogue","core/str","tool_lp/tree"],function(a,b,c,d,e,f,g){var h=function(b,c,d,e){var f=this;f._eventNode=a("
"),f._frameworks=[],f._reset(),f._pageContextId=b,f._pageContextIncludes=d||"children",f._multiSelect="undefined"==typeof e||e===!0,c&&(f._frameworkId=c,f._singleFramework=!0)};return h.prototype._competencies=null,h.prototype._disallowedCompetencyIDs=null,h.prototype._eventNode=null,h.prototype._frameworks=null,h.prototype._frameworkId=null,h.prototype._pageContextId=null,h.prototype._pageContextIncludes=null,h.prototype._popup=null,h.prototype._searchText="",h.prototype._selectedCompetencies=null,h.prototype._singleFramework=!1,h.prototype._multiSelect=!0,h.prototype._onlyVisible=!0,h.prototype._afterRender=function(){var c=this,d=new g(c._find("[data-enhance=linktree]"),c._multiSelect);c._find("[data-enhance=linktree]").show(),d.on("selectionchanged",function(b,d){var e=d.selected;b.preventDefault();var f=[];a.each(e,function(b,d){var e=a(d).data("id"),g=!0;"undefined"==typeof e?g=!1:a.each(c._disallowedCompetencyIDs,function(a,b){b==e&&(g=!1)}),g&&f.push(e)}),c._selectedCompetencies=f,c._selectedCompetencies.length?c._find('[data-region="competencylinktree"] [data-action="add"]').removeAttr("disabled"):c._find('[data-region="competencylinktree"] [data-action="add"]').attr("disabled","disabled")}),c._singleFramework||c._find('[data-action="chooseframework"]').change(function(d){c._frameworkId=a(d.target).val(),c._loadCompetencies().then(c._refresh.bind(c))["catch"](b.exception)}),c._find('[data-region="filtercompetencies"] button').click(function(b){return b.preventDefault(),a(b.target).attr("disabled","disabled"),c._searchText=c._find('[data-region="filtercompetencies"] input').val()||"",c._refresh().always(function(){a(b.target).removeAttr("disabled")})}),c._find('[data-region="competencylinktree"] [data-action="cancel"]').click(function(a){a.preventDefault(),c.close()}),c._find('[data-region="competencylinktree"] [data-action="add"]').click(function(a){a.preventDefault(),c._selectedCompetencies.length&&(c._multiSelect?c._trigger("save",{competencyIds:c._selectedCompetencies}):c._trigger("save",{competencyId:c._selectedCompetencies[0]}),c.close())});var e=c._selectedCompetencies.slice(0);a.each(e,function(a,b){var e=c._find("[data-id="+b+"]");e.length&&(d.toggleItem(e),d.updateFocus(e))})},h.prototype.close=function(){var a=this;a._popup.close(),a._reset()},h.prototype.display=function(){var c=this;return a.when(f.get_string("competencypicker","tool_lp"),c._render()).then(function(a,b){c._popup=new e(a,b[0],c._afterRender.bind(c))})["catch"](b.exception)},h.prototype._fetchCompetencies=function(a,d){var e=this;return c.call([{methodname:"core_competency_search_competencies",args:{searchtext:d,competencyframeworkid:a}}])[0].done(function(a){function b(a,c){for(var d=0;d0?a.when():(d=e._singleFramework?c.call([{methodname:"core_competency_read_competency_framework",args:{id:this._frameworkId}}])[0].then(function(a){return[a]}):c.call([{methodname:"core_competency_list_competency_frameworks",args:{sort:"shortname",context:{contextid:e._pageContextId},includes:e._pageContextIncludes,onlyvisible:e._onlyVisible}}])[0],d.done(function(a){e._frameworks=a}).fail(b.exception))},h.prototype.on=function(a,b){this._eventNode.on(a,b)},h.prototype._preRender=function(){var b=this;return b._loadFrameworks().then(function(){return!b._frameworkId&&b._frameworks.length>0&&(b._frameworkId=b._frameworks[0].id),b._frameworkId?b._loadCompetencies():(b._frameworks=[],a.when())})},h.prototype._refresh=function(){var a=this;return a._render().then(function(b){a._find('[data-region="competencylinktree"]').replaceWith(b),a._afterRender()})},h.prototype._render=function(){var b=this;return b._preRender().then(function(){b._singleFramework||a.each(b._frameworks,function(a,c){c.id==b._frameworkId?c.selected=!0:c.selected=!1});var c={competencies:b._competencies,framework:b._getFramework(b._frameworkId),frameworks:b._frameworks,search:b._searchText,singleFramework:b._singleFramework};return d.render("tool_lp/competency_picker",c)})},h.prototype._reset=function(){this._competencies=[],this._disallowedCompetencyIDs=[],this._popup=null,this._searchText="",this._selectedCompetencies=[]},h.prototype.setDisallowedCompetencyIDs=function(a){this._disallowedCompetencyIDs=a},h.prototype._trigger=function(a,b){this._eventNode.trigger(a,[b])},h}); \ No newline at end of file +define(["jquery","core/notification","core/ajax","core/templates","tool_lp/dialogue","core/str","tool_lp/tree","core/pending"],function(a,b,c,d,e,f,g,h){var i=function(b,c,d,e){var f=this;f._eventNode=a("
"),f._frameworks=[],f._reset(),f._pageContextId=b,f._pageContextIncludes=d||"children",f._multiSelect="undefined"==typeof e||e===!0,c&&(f._frameworkId=c,f._singleFramework=!0)};return i.prototype._competencies=null,i.prototype._disallowedCompetencyIDs=null,i.prototype._eventNode=null,i.prototype._frameworks=null,i.prototype._frameworkId=null,i.prototype._pageContextId=null,i.prototype._pageContextIncludes=null,i.prototype._popup=null,i.prototype._searchText="",i.prototype._selectedCompetencies=null,i.prototype._singleFramework=!1,i.prototype._multiSelect=!0,i.prototype._onlyVisible=!0,i.prototype._afterRender=function(){var c=this,d=new g(c._find("[data-enhance=linktree]"),c._multiSelect);c._find("[data-enhance=linktree]").show(),d.on("selectionchanged",function(b,d){var e=d.selected;b.preventDefault();var f=[];a.each(e,function(b,d){var e=a(d).data("id"),g=!0;"undefined"==typeof e?g=!1:a.each(c._disallowedCompetencyIDs,function(a,b){b==e&&(g=!1)}),g&&f.push(e)}),c._selectedCompetencies=f,c._selectedCompetencies.length?c._find('[data-region="competencylinktree"] [data-action="add"]').removeAttr("disabled"):c._find('[data-region="competencylinktree"] [data-action="add"]').attr("disabled","disabled")}),c._singleFramework||c._find('[data-action="chooseframework"]').change(function(d){c._frameworkId=a(d.target).val(),c._loadCompetencies().then(c._refresh.bind(c))["catch"](b.exception)}),c._find('[data-region="filtercompetencies"] button').click(function(b){return b.preventDefault(),a(b.target).attr("disabled","disabled"),c._searchText=c._find('[data-region="filtercompetencies"] input').val()||"",c._refresh().always(function(){a(b.target).removeAttr("disabled")})}),c._find('[data-region="competencylinktree"] [data-action="cancel"]').click(function(a){a.preventDefault(),c.close()}),c._find('[data-region="competencylinktree"] [data-action="add"]').click(function(a){a.preventDefault();var b=new h;c._selectedCompetencies.length&&(c._multiSelect?c._trigger("save",{competencyIds:c._selectedCompetencies}):c._trigger("save",{competencyId:c._selectedCompetencies[0]}),c.close(),b.resolve())});var e=c._selectedCompetencies.slice(0);a.each(e,function(a,b){var e=c._find("[data-id="+b+"]");e.length&&(d.toggleItem(e),d.updateFocus(e))})},i.prototype.close=function(){var a=this;a._popup.close(),a._reset()},i.prototype.display=function(){var c=this;return a.when(f.get_string("competencypicker","tool_lp"),c._render()).then(function(a,b){c._popup=new e(a,b[0],c._afterRender.bind(c))})["catch"](b.exception)},i.prototype._fetchCompetencies=function(a,d){var e=this;return c.call([{methodname:"core_competency_search_competencies",args:{searchtext:d,competencyframeworkid:a}}])[0].done(function(a){function b(a,c){for(var d=0;d0?a.when():(d=e._singleFramework?c.call([{methodname:"core_competency_read_competency_framework",args:{id:this._frameworkId}}])[0].then(function(a){return[a]}):c.call([{methodname:"core_competency_list_competency_frameworks",args:{sort:"shortname",context:{contextid:e._pageContextId},includes:e._pageContextIncludes,onlyvisible:e._onlyVisible}}])[0],d.done(function(a){e._frameworks=a}).fail(b.exception))},i.prototype.on=function(a,b){this._eventNode.on(a,b)},i.prototype._preRender=function(){var b=this;return b._loadFrameworks().then(function(){return!b._frameworkId&&b._frameworks.length>0&&(b._frameworkId=b._frameworks[0].id),b._frameworkId?b._loadCompetencies():(b._frameworks=[],a.when())})},i.prototype._refresh=function(){var a=this;return a._render().then(function(b){a._find('[data-region="competencylinktree"]').replaceWith(b),a._afterRender()})},i.prototype._render=function(){var b=this;return b._preRender().then(function(){b._singleFramework||a.each(b._frameworks,function(a,c){c.id==b._frameworkId?c.selected=!0:c.selected=!1});var c={competencies:b._competencies,framework:b._getFramework(b._frameworkId),frameworks:b._frameworks,search:b._searchText,singleFramework:b._singleFramework};return d.render("tool_lp/competency_picker",c)})},i.prototype._reset=function(){this._competencies=[],this._disallowedCompetencyIDs=[],this._popup=null,this._searchText="",this._selectedCompetencies=[]},i.prototype.setDisallowedCompetencyIDs=function(a){this._disallowedCompetencyIDs=a},i.prototype._trigger=function(a,b){this._eventNode.trigger(a,[b])},i}); \ No newline at end of file diff --git a/admin/tool/lp/amd/build/course_competency_settings.min.js b/admin/tool/lp/amd/build/course_competency_settings.min.js index fe53dc6c88156..7a68944f40e6b 100644 --- a/admin/tool/lp/amd/build/course_competency_settings.min.js +++ b/admin/tool/lp/amd/build/course_competency_settings.min.js @@ -1 +1 @@ -define(["jquery","core/notification","tool_lp/dialogue","core/str","core/ajax","core/templates"],function(a,b,c,d,e,f){var g=function(b){a(b).on("click",this.configureSettings.bind(this))};return g.prototype._dialogue=null,g.prototype.configureSettings=function(e){var g=a(e.target).closest("a").data("courseid"),h=a(e.target).closest("a").data("pushratingstouserplans"),i={courseid:g,settings:{pushratingstouserplans:h}};e.preventDefault(),f.render("tool_lp/course_competency_settings",i).done(function(a){d.get_string("configurecoursecompetencysettings","tool_lp").done(function(b){this._dialogue=new c(b,a,this.addListeners.bind(this))}.bind(this)).fail(b.exception)}.bind(this)).fail(b.exception)},g.prototype.addListeners=function(){var a=this._find('[data-action="save"]');a.on("click",this.saveSettings.bind(this));var b=this._find('[data-action="cancel"]');b.on("click",this.cancelChanges.bind(this))},g.prototype.cancelChanges=function(a){a.preventDefault(),this._dialogue.close()},g.prototype._find=function(b){return a('[data-region="coursecompetencysettings"]').find(b)},g.prototype.saveSettings=function(a){a.preventDefault();var c=this._find('input[name="pushratingstouserplans"]:checked').val(),d=this._find('input[name="courseid"]').val(),f={pushratingstouserplans:c};e.call([{methodname:"core_competency_update_course_competency_settings",args:{courseid:d,settings:f}}])[0].done(function(){this.refreshCourseCompetenciesPage()}.bind(this)).fail(b.exception)},g.prototype.refreshCourseCompetenciesPage=function(){var c=this._find('input[name="courseid"]').val();e.call([{methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:c,moduleid:0}}])[0].done(function(c){f.render("tool_lp/course_competencies_page",c).done(function(b,c){a('[data-region="coursecompetenciespage"]').replaceWith(b),f.runTemplateJS(c),this._dialogue.close()}.bind(this)).fail(b.exception)}.bind(this)).fail(b.exception)},g}); \ No newline at end of file +define(["jquery","core/notification","tool_lp/dialogue","core/str","core/ajax","core/templates","core/pending"],function(a,b,c,d,e,f,g){var h=function(b){a(b).on("click",this.configureSettings.bind(this))};return h.prototype._dialogue=null,h.prototype.configureSettings=function(e){var h=new g,i=a(e.target).closest("a").data("courseid"),j=a(e.target).closest("a").data("pushratingstouserplans"),k={courseid:i,settings:{pushratingstouserplans:j}};e.preventDefault(),a.when(d.get_string("configurecoursecompetencysettings","tool_lp"),f.render("tool_lp/course_competency_settings",k)).then(function(a,b){return this._dialogue=new c(a,b[0],this.addListeners.bind(this)),this._dialogue}.bind(this)).then(h.resolve)["catch"](b.exception)},h.prototype.addListeners=function(){var a=this._find('[data-action="save"]');a.on("click",this.saveSettings.bind(this));var b=this._find('[data-action="cancel"]');b.on("click",this.cancelChanges.bind(this))},h.prototype.cancelChanges=function(a){a.preventDefault(),this._dialogue.close()},h.prototype._find=function(b){return a('[data-region="coursecompetencysettings"]').find(b)},h.prototype.saveSettings=function(a){var c=new g;a.preventDefault();var d=this._find('input[name="pushratingstouserplans"]:checked').val(),f=this._find('input[name="courseid"]').val(),h={pushratingstouserplans:d};e.call([{methodname:"core_competency_update_course_competency_settings",args:{courseid:f,settings:h}}])[0].then(function(){return this.refreshCourseCompetenciesPage()}.bind(this)).then(c.resolve)["catch"](b.exception)},h.prototype.refreshCourseCompetenciesPage=function(){var c=this._find('input[name="courseid"]').val(),d=new g;e.call([{methodname:"tool_lp_data_for_course_competencies_page",args:{courseid:c,moduleid:0}}])[0].then(function(a){return f.render("tool_lp/course_competencies_page",a)}).then(function(b,c){f.replaceNode(a('[data-region="coursecompetenciespage"]'),b,c),this._dialogue.close()}.bind(this)).then(d.resolve)["catch"](b.exception)},h}); \ No newline at end of file diff --git a/admin/tool/lp/amd/build/grade_dialogue.min.js b/admin/tool/lp/amd/build/grade_dialogue.min.js index 6bfa3dfeec81d..06d1725d32ded 100644 --- a/admin/tool/lp/amd/build/grade_dialogue.min.js +++ b/admin/tool/lp/amd/build/grade_dialogue.min.js @@ -1 +1 @@ -define(["jquery","core/notification","core/templates","tool_lp/dialogue","tool_lp/event_base","core/str"],function(a,b,c,d,e,f){var g=function(a){e.prototype.constructor.apply(this,[]),this._ratingOptions=a};return g.prototype=Object.create(e.prototype),g.prototype._popup=null,g.prototype._ratingOptions=null,g.prototype._afterRender=function(){var b=this._find('[data-action="rate"]'),c=this._find('[name="rating"]'),d=this._find('[name="comment"]');this._find('[data-action="cancel"]').click(function(a){a.preventDefault(),this._trigger("cancelled"),this.close()}.bind(this)),c.change(function(){var c=a(this);c.val()?b.prop("disabled",!1):b.prop("disabled",!0)}).change(),b.click(function(a){a.preventDefault();var b=c.val();b&&(this._trigger("rated",{rating:b,note:d.val()}),this.close())}.bind(this))},g.prototype.close=function(){this._popup.close(),this._popup=null},g.prototype.display=function(){return this._render().then(function(a){return f.get_string("rate","tool_lp").then(function(b){this._popup=new d(b,a,this._afterRender.bind(this))}.bind(this))}.bind(this)).fail(b.exception)},g.prototype._find=function(b){return a(this._popup.getContent()).find(b)},g.prototype._render=function(){var a={cangrade:this._canGrade,ratings:this._ratingOptions};return c.render("tool_lp/competency_grader",a)},g}); \ No newline at end of file +define(["jquery","core/notification","core/templates","tool_lp/dialogue","tool_lp/event_base","core/str"],function(a,b,c,d,e,f){var g=function(a){e.prototype.constructor.apply(this,[]),this._ratingOptions=a};return g.prototype=Object.create(e.prototype),g.prototype._popup=null,g.prototype._ratingOptions=null,g.prototype._afterRender=function(){var b=this._find('[data-action="rate"]'),c=this._find('[name="rating"]'),d=this._find('[name="comment"]');this._find('[data-action="cancel"]').click(function(a){a.preventDefault(),this._trigger("cancelled"),this.close()}.bind(this)),c.change(function(){var c=a(this);c.val()?b.prop("disabled",!1):b.prop("disabled",!0)}).change(),b.click(function(a){a.preventDefault();var b=c.val();b&&(this._trigger("rated",{rating:b,note:d.val()}),this.close())}.bind(this))},g.prototype.close=function(){this._popup.close(),this._popup=null},g.prototype.display=function(){return a.when(f.get_string("rate","tool_lp"),this._render()).then(function(a,b){return this._popup=new d(a,b[0],this._afterRender.bind(this)),this._popup}.bind(this))["catch"](b.exception)},g.prototype._find=function(b){return a(this._popup.getContent()).find(b)},g.prototype._render=function(){var a={cangrade:this._canGrade,ratings:this._ratingOptions};return c.render("tool_lp/competency_grader",a)},g}); \ No newline at end of file diff --git a/admin/tool/lp/amd/build/grade_user_competency_inline.min.js b/admin/tool/lp/amd/build/grade_user_competency_inline.min.js index 6ea96241ba16c..80725888f2253 100644 --- a/admin/tool/lp/amd/build/grade_user_competency_inline.min.js +++ b/admin/tool/lp/amd/build/grade_user_competency_inline.min.js @@ -1 +1 @@ -define(["jquery","core/notification","core/ajax","core/log","tool_lp/grade_dialogue","tool_lp/event_base","tool_lp/scalevalues"],function(a,b,c,d,e,f,g){var h=function(b,c,d,e,g,h,i){f.prototype.constructor.apply(this,[]);var j=a(b);if(!j.length)throw new Error("Could not find the trigger");this._scaleId=c,this._competencyId=d,this._userId=e,this._planId=g,this._courseId=h,this._chooseStr=i,this._setUp(),j.click(function(a){a.preventDefault(),this._dialogue.display()}.bind(this)),this._planId?(this._methodName="core_competency_grade_competency_in_plan",this._args={competencyid:this._competencyId,planid:this._planId}):this._courseId?(this._methodName="core_competency_grade_competency_in_course",this._args={competencyid:this._competencyId,courseid:this._courseId,userid:this._userId}):(this._methodName="core_competency_grade_competency",this._args={userid:this._userId,competencyid:this._competencyId})};return h.prototype=Object.create(f.prototype),h.prototype._setUp=function(){var a=[],d=this,f=g.get_values(d._scaleId);f.done(function(f){a.push({value:"",name:d._chooseStr});for(var g=0;gnavigation[] = $addpage; $competenciesrepository = new single_button( - new moodle_url('https://moodle.net/competencies'), + new moodle_url('https://archive.moodle.net/competencies'), get_string('competencyframeworksrepository', 'tool_lp'), 'get' ); diff --git a/admin/tool/lp/templates/competency_plan_navigation.mustache b/admin/tool/lp/templates/competency_plan_navigation.mustache index 92add2659afff..66747cbe85526 100644 --- a/admin/tool/lp/templates/competency_plan_navigation.mustache +++ b/admin/tool/lp/templates/competency_plan_navigation.mustache @@ -32,7 +32,7 @@ // No example context because the JS is connected to webservices }} -
+
{{#hascompetencies}} diff --git a/admin/tool/lp/templates/course_competencies_page.mustache b/admin/tool/lp/templates/course_competencies_page.mustache index 8de78a04c5497..6d3622b8218d4 100644 --- a/admin/tool/lp/templates/course_competencies_page.mustache +++ b/admin/tool/lp/templates/course_competencies_page.mustache @@ -82,7 +82,7 @@ {{/comppath}} {{#usercompetencycourse}} {{#grade}} - {{gradename}} + {{gradename}} {{/grade}} {{/usercompetencycourse}} {{#canmanagecoursecompetencies}} diff --git a/admin/tool/lp/templates/course_competency_statistics.mustache b/admin/tool/lp/templates/course_competency_statistics.mustache index 31260af1d28e3..73c74de8f42a7 100644 --- a/admin/tool/lp/templates/course_competency_statistics.mustache +++ b/admin/tool/lp/templates/course_competency_statistics.mustache @@ -49,7 +49,7 @@ Template statistics template. }} {{#competencycount}} -
+
{{#canbegradedincourse}} {{< tool_lp/progress_bar}} {{$progresstext}} diff --git a/admin/tool/lp/templates/evidence_summary.mustache b/admin/tool/lp/templates/evidence_summary.mustache index 83984da434f34..5033ce977d804 100644 --- a/admin/tool/lp/templates/evidence_summary.mustache +++ b/admin/tool/lp/templates/evidence_summary.mustache @@ -44,7 +44,7 @@ "id": 1 } }} -
+
{{#candelete}}
{{#pix}}t/delete{{/pix}} diff --git a/admin/tool/lp/templates/manage_competencies_page.mustache b/admin/tool/lp/templates/manage_competencies_page.mustache index 92354ec7212d9..ccc67ba15af6d 100644 --- a/admin/tool/lp/templates/manage_competencies_page.mustache +++ b/admin/tool/lp/templates/manage_competencies_page.mustache @@ -50,10 +50,12 @@

-
+
- +
+ +

@@ -62,8 +64,8 @@
-
-
+
+

{{#str}}selectedcompetency, tool_lp{{/str}}

diff --git a/admin/tool/lp/templates/module_navigation.mustache b/admin/tool/lp/templates/module_navigation.mustache index aa10239bb6e96..4b68402c0a94e 100644 --- a/admin/tool/lp/templates/module_navigation.mustache +++ b/admin/tool/lp/templates/module_navigation.mustache @@ -28,8 +28,10 @@ // No example context because the JS is connected to webservices }} -
+
+ + {{#hasmodules}} diff --git a/admin/tool/lp/templates/template_competencies_page.mustache b/admin/tool/lp/templates/template_competencies_page.mustache index aa3895c662700..e5f7ca29f3a67 100644 --- a/admin/tool/lp/templates/template_competencies_page.mustache +++ b/admin/tool/lp/templates/template_competencies_page.mustache @@ -26,10 +26,8 @@
{{{template.description}}}
{{#canmanagetemplatecompetencies}} -
-
- -
+
+
{{/canmanagetemplatecompetencies}}

{{#str}}templatecompetencies, tool_lp{{/str}}

@@ -41,7 +39,7 @@
{{#competencies}}
-
+
{{#canmanagetemplatecompetencies}}
diff --git a/admin/tool/lp/templates/template_statistics.mustache b/admin/tool/lp/templates/template_statistics.mustache index 9751cbd09ea04..418b4934b9e44 100644 --- a/admin/tool/lp/templates/template_statistics.mustache +++ b/admin/tool/lp/templates/template_statistics.mustache @@ -45,8 +45,7 @@ Template statistics template. }} {{#competencycount}} -
-
+
{{< tool_lp/progress_bar}} {{$progresstext}} {{#str}}xcompetencieslinkedoutofy, tool_lp, { "x": "{{linkedcompetencycount}}", "y": "{{competencycount}}" } {{/str}} @@ -90,6 +89,5 @@
{{/leastproficientcount}} -
{{/competencycount}} diff --git a/admin/tool/lp/templates/user_competency_course_navigation.mustache b/admin/tool/lp/templates/user_competency_course_navigation.mustache index 331c3e15892ca..2a9f2deeda4c9 100644 --- a/admin/tool/lp/templates/user_competency_course_navigation.mustache +++ b/admin/tool/lp/templates/user_competency_course_navigation.mustache @@ -35,9 +35,12 @@ // No example context because the JS is connected to webservices }} -
+

{{{groupselector}}}

+ + + {{#hasusers}} diff --git a/admin/tool/lp/templates/user_competency_summary.mustache b/admin/tool/lp/templates/user_competency_summary.mustache index 4ee61f12b541e..863aaed949d58 100644 --- a/admin/tool/lp/templates/user_competency_summary.mustache +++ b/admin/tool/lp/templates/user_competency_summary.mustache @@ -57,7 +57,7 @@
{{#str}}proficient, tool_lp{{/str}}
- + {{proficiencyname}}
diff --git a/admin/tool/lp/templates/user_competency_summary_in_course.mustache b/admin/tool/lp/templates/user_competency_summary_in_course.mustache index a5acf4f747526..fe97642fd172d 100644 --- a/admin/tool/lp/templates/user_competency_summary_in_course.mustache +++ b/admin/tool/lp/templates/user_competency_summary_in_course.mustache @@ -81,7 +81,7 @@ {{#usercompetencycourse}}
{{#str}}proficient, tool_lp{{/str}}
- + {{proficiencyname}}
diff --git a/admin/tool/lp/templates/user_competency_summary_in_plan.mustache b/admin/tool/lp/templates/user_competency_summary_in_plan.mustache index 60ec206b0b934..bb13c95f6592d 100644 --- a/admin/tool/lp/templates/user_competency_summary_in_plan.mustache +++ b/admin/tool/lp/templates/user_competency_summary_in_plan.mustache @@ -68,7 +68,7 @@
{{#str}}proficient, tool_lp{{/str}}
- + {{proficiencyname}}
@@ -101,7 +101,7 @@
{{gradename}} - {{#str}}plancompleted, tool_lp{{/str}}
{{#str}}proficient, tool_lp{{/str}}
- + {{proficiencyname}}
diff --git a/admin/tool/lp/tests/behat/framework_crud.feature b/admin/tool/lp/tests/behat/framework_crud.feature index df34c22897985..168a028ef4e9d 100644 --- a/admin/tool/lp/tests/behat/framework_crud.feature +++ b/admin/tool/lp/tests/behat/framework_crud.feature @@ -6,6 +6,7 @@ Feature: Manage competency frameworks Background: Given I log in as "admin" + And I change window size to "small" And I am on site homepage Scenario: Create a new framework diff --git a/admin/tool/lp/tests/behat/template_crud.feature b/admin/tool/lp/tests/behat/template_crud.feature index 0156243a98d85..a5386ffccb99e 100644 --- a/admin/tool/lp/tests/behat/template_crud.feature +++ b/admin/tool/lp/tests/behat/template_crud.feature @@ -6,6 +6,7 @@ Feature: Manage plearning plan templates Background: Given I log in as "admin" + And I change window size to "small" And I am on site homepage Scenario: Create a new learning plan template diff --git a/admin/tool/messageinbound/lang/en/tool_messageinbound.php b/admin/tool/messageinbound/lang/en/tool_messageinbound.php index 1711f5ede9267..8daab15f8868c 100644 --- a/admin/tool/messageinbound/lang/en/tool_messageinbound.php +++ b/admin/tool/messageinbound/lang/en/tool_messageinbound.php @@ -105,7 +105,7 @@ $string['name'] = 'Name'; $string['ssl'] = 'SSL (Auto-detect SSL version)'; $string['sslv2'] = 'SSLv2 (Force SSL Version 2)'; -$string['sslv3'] = 'SSLv2 (Force SSL Version 3)'; +$string['sslv3'] = 'SSLv3 (Force SSL Version 3)'; $string['taskcleanup'] = 'Cleanup of unverified incoming email'; $string['taskpickup'] = 'Incoming email pickup'; $string['tls'] = 'TLS (TLS; started via protocol-level negotiation over unencrypted channel; RECOMMENDED way of initiating secure connection)'; diff --git a/admin/tool/mobile/classes/api.php b/admin/tool/mobile/classes/api.php index 02027bd4054f9..a60b8f6b95cbf 100644 --- a/admin/tool/mobile/classes/api.php +++ b/admin/tool/mobile/classes/api.php @@ -206,6 +206,12 @@ public static function get_public_config() { $identityprovidersdata = \auth_plugin_base::prepare_identity_providers_for_output($identityproviders, $OUTPUT); if (!empty($identityprovidersdata)) { $settings['identityproviders'] = $identityprovidersdata; + // Clean URLs to avoid breaking Web Services. + // We can't do it in prepare_identity_providers_for_output() because it may break the web output. + foreach ($settings['identityproviders'] as &$ip) { + $ip['url'] = (!empty($ip['url'])) ? clean_param($ip['url'], PARAM_URL) : ''; + $ip['iconurl'] = (!empty($ip['iconurl'])) ? clean_param($ip['iconurl'], PARAM_URL) : ''; + } } // If age is verified, return also the admin contact details. diff --git a/admin/tool/mobile/db/upgrade.php b/admin/tool/mobile/db/upgrade.php index 73748aa8d9ef4..6d5c5a79c41a7 100644 --- a/admin/tool/mobile/db/upgrade.php +++ b/admin/tool/mobile/db/upgrade.php @@ -42,5 +42,8 @@ function xmldb_tool_mobile_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2019021100, 'tool', 'mobile'); } + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/mobile/lang/en/tool_mobile.php b/admin/tool/mobile/lang/en/tool_mobile.php index 0b6ba8172c0fc..e9d2fd81d0a96 100644 --- a/admin/tool/mobile/lang/en/tool_mobile.php +++ b/admin/tool/mobile/lang/en/tool_mobile.php @@ -82,7 +82,7 @@ $string['mobileauthentication'] = 'Mobile authentication'; $string['mobilecssurl'] = 'CSS'; $string['mobilefeatures'] = 'Mobile features'; -$string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Manage message outputs.'; +$string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Notification settings.'; $string['mobilesettings'] = 'Mobile settings'; $string['offlineuse'] = 'Offline use'; $string['pluginname'] = 'Moodle app tools'; @@ -91,7 +91,7 @@ $string['remoteaddons'] = 'Remote add-ons'; $string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS certificate is self-signed or not trusted. The mobile app will only work with trusted sites.'; $string['setuplink'] = 'App download page'; -$string['setuplink_desc'] = 'URL of page with links to download the mobile app from the App Store and Google Play.'; +$string['setuplink_desc'] = 'URL of page with options to download the mobile app from the App Store and Google Play. The app download page link is displayed in the page footer and in a user\'s profile. Leave blank to not display a link.'; $string['smartappbanners'] = 'App Banners'; $string['typeoflogin'] = 'Type of login'; $string['typeoflogin_desc'] = 'If the site uses a SSO authentication method, then select via a browser window or via an embedded browser. An embedded browser provides a better user experience, though it doesn\'t work with all SSO plugins.'; diff --git a/admin/tool/mobile/launch.php b/admin/tool/mobile/launch.php index 1e74e37fc32dd..2c5fc181b50d3 100644 --- a/admin/tool/mobile/launch.php +++ b/admin/tool/mobile/launch.php @@ -30,7 +30,7 @@ $serviceshortname = required_param('service', PARAM_ALPHANUMEXT); $passport = required_param('passport', PARAM_RAW); // Passport send from the app to validate the response URL. -$urlscheme = optional_param('urlscheme', 'moodlemobile', PARAM_NOTAGS); // The URL scheme the app supports. +$urlscheme = optional_param('urlscheme', 'moodlemobile', PARAM_ALPHANUM); // The URL scheme the app supports. $confirmed = optional_param('confirmed', false, PARAM_BOOL); // If we are being redirected after user confirmation. $oauthsso = optional_param('oauthsso', 0, PARAM_INT); // Id of the OpenID issuer (for OAuth direct SSO). diff --git a/admin/tool/mobile/settings.php b/admin/tool/mobile/settings.php index 5a313ed3b1a6f..e4f94ece5e4cc 100644 --- a/admin/tool/mobile/settings.php +++ b/admin/tool/mobile/settings.php @@ -63,7 +63,7 @@ $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme', new lang_string('forcedurlscheme_key', 'tool_mobile'), - new lang_string('forcedurlscheme', 'tool_mobile'), '', PARAM_NOTAGS)); + new lang_string('forcedurlscheme', 'tool_mobile'), '', PARAM_ALPHANUM)); $ADMIN->add('mobileapp', $temp); diff --git a/admin/tool/mobile/tests/externallib_test.php b/admin/tool/mobile/tests/externallib_test.php index f3d1815eec2c4..f0b91995fd863 100644 --- a/admin/tool/mobile/tests/externallib_test.php +++ b/admin/tool/mobile/tests/externallib_test.php @@ -99,6 +99,7 @@ public function test_get_public_config() { ); $this->assertEquals($expected, $result); + $this->setAdminUser(); // Change some values. set_config('registerauth', 'email'); $authinstructions = 'Something with html tags'; @@ -112,6 +113,18 @@ public function test_get_public_config() { set_config('lang', 'a_b'); // Set invalid lang. set_config('disabledfeatures', 'myoverview', 'tool_mobile'); + // Enable couple of issuers. + $issuer = \core\oauth2\api::create_standard_issuer('google'); + $irecord = $issuer->to_record(); + $irecord->clientid = 'mock'; + $irecord->clientsecret = 'mock'; + core\oauth2\api::update_issuer($irecord); + + set_config('hostname', 'localhost', 'auth_cas'); + set_config('auth_logo', 'http://invalidurl.com//invalid/', 'auth_cas'); + set_config('auth_name', 'CAS', 'auth_cas'); + set_config('auth', 'oauth2,cas'); + list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id); $expected['registerauth'] = 'email'; $expected['authinstructions'] = $authinstructions; @@ -133,7 +146,26 @@ public function test_get_public_config() { $result = external::get_public_config(); $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result); + // First check providers. + $identityproviders = $result['identityproviders']; + unset($result['identityproviders']); + + $this->assertEquals('Google', $identityproviders[0]['name']); + $this->assertEquals($irecord->image, $identityproviders[0]['iconurl']); + $this->assertContains($CFG->wwwroot, $identityproviders[0]['url']); + + $this->assertEquals('CAS', $identityproviders[1]['name']); + $this->assertEmpty($identityproviders[1]['iconurl']); + $this->assertContains($CFG->wwwroot, $identityproviders[1]['url']); + $this->assertEquals($expected, $result); + + // Change providers img. + $newurl = 'validimage.png'; + set_config('auth_logo', $newurl, 'auth_cas'); + $result = external::get_public_config(); + $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result); + $this->assertContains($newurl, $result['identityproviders'][1]['iconurl']); } /** diff --git a/admin/tool/monitor/db/upgrade.php b/admin/tool/monitor/db/upgrade.php index f8579c9183ae0..139dec78dd186 100644 --- a/admin/tool/monitor/db/upgrade.php +++ b/admin/tool/monitor/db/upgrade.php @@ -62,5 +62,8 @@ function xmldb_tool_monitor_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/monitor/tests/privacy_test.php b/admin/tool/monitor/tests/privacy_test.php index 8993b7731ed53..3d419c7e4d5d9 100644 --- a/admin/tool/monitor/tests/privacy_test.php +++ b/admin/tool/monitor/tests/privacy_test.php @@ -28,6 +28,7 @@ use \tool_monitor\privacy\provider; use \core_privacy\local\request\approved_contextlist; use \core_privacy\local\request\approved_userlist; +use \core_privacy\tests\provider_testcase; /** * Privacy test for the event monitor @@ -37,7 +38,7 @@ * @copyright 2018 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class tool_monitor_privacy_testcase extends advanced_testcase { +class tool_monitor_privacy_testcase extends provider_testcase { /** * Set up method. diff --git a/admin/tool/oauth2/classes/form/issuer.php b/admin/tool/oauth2/classes/form/issuer.php index 5efe02b99b87c..a122a027352c1 100644 --- a/admin/tool/oauth2/classes/form/issuer.php +++ b/admin/tool/oauth2/classes/form/issuer.php @@ -46,6 +46,9 @@ class issuer extends persistent { /** @var string $type */ protected $type; + /** @var boolean $showrequireconfirm Whether to show the require confirmation email checkbox or not. */ + protected $showrequireconfirm; + /** * Constructor. * @@ -71,6 +74,7 @@ public function __construct($action = null, $customdata = null, $method = 'post' if (array_key_exists('type', $customdata)) { $this->type = $customdata['type']; } + $this->showrequireconfirm = !empty($customdata['showrequireconfirm']); parent::__construct($action, $customdata, $method, $target, $attributes, $editable, $ajaxformdata); } @@ -157,9 +161,11 @@ public function definition() { $mform->addElement('checkbox', 'showonloginpage', get_string('issuershowonloginpage', 'tool_oauth2')); $mform->addHelpButton('showonloginpage', 'issuershowonloginpage', 'tool_oauth2'); - // Require confirmation email for new accounts. - $mform->addElement('advcheckbox', 'requireconfirmation', get_string('issuerrequireconfirmation', 'tool_oauth2')); - $mform->addHelpButton('requireconfirmation', 'issuerrequireconfirmation', 'tool_oauth2'); + if ($this->showrequireconfirm) { + // Require confirmation email for new accounts. + $mform->addElement('advcheckbox', 'requireconfirmation', get_string('issuerrequireconfirmation', 'tool_oauth2')); + $mform->addHelpButton('requireconfirmation', 'issuerrequireconfirmation', 'tool_oauth2'); + } $mform->addElement('hidden', 'sortorder'); $mform->setType('sortorder', PARAM_INT); diff --git a/admin/tool/oauth2/issuers.php b/admin/tool/oauth2/issuers.php index 87fb17d11325a..55f5beaa8444c 100644 --- a/admin/tool/oauth2/issuers.php +++ b/admin/tool/oauth2/issuers.php @@ -58,7 +58,24 @@ $PAGE->navbar->add(get_string('createnewissuer', 'tool_oauth2')); } - $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer]); + $showrequireconfirm = false; + if (!empty($issuerid)) { + // Show the "Require confirmation email" checkbox for trusted issuers like Google, Facebook and Microsoft. + $likefacebook = $DB->sql_like('url', ':facebook'); + $likegoogle = $DB->sql_like('url', ':google'); + $likemicrosoft = $DB->sql_like('url', ':microsoft'); + $params = [ + 'issuerid' => $issuerid, + 'facebook' => '%facebook%', + 'google' => '%google%', + 'microsoft' => '%microsoft%', + ]; + $select = "issuerid = :issuerid AND ($likefacebook OR $likegoogle OR $likemicrosoft)"; + // We're querying from the oauth2_endpoint table because the base URLs of FB and Microsoft can be empty in the issuer table. + $showrequireconfirm = $DB->record_exists_select('oauth2_endpoint', $select, $params); + } + + $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'showrequireconfirm' => $showrequireconfirm]); } if ($mform && $mform->is_cancelled()) { @@ -108,9 +125,11 @@ $type = required_param('type', PARAM_ALPHA); $docs = required_param('docslink', PARAM_ALPHAEXT); + $showrequireconfirm = optional_param('showrequireconfirm', false, PARAM_BOOL); require_sesskey(); $issuer = core\oauth2\api::init_standard_issuer($type); - $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'type' => $type]); + $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'type' => $type, + 'showrequireconfirm' => $showrequireconfirm]); echo $OUTPUT->header(); $mform->display(); @@ -178,26 +197,37 @@ $issuers = core\oauth2\api::get_all_issuers(); echo $renderer->issuers_table($issuers); + // Google template. $docs = 'admin/tool/oauth2/issuers/google'; - $params = ['action' => 'edittemplate', 'type' => 'google', 'sesskey' => sesskey(), 'docslink' => $docs]; + $params = ['action' => 'edittemplate', 'type' => 'google', 'sesskey' => sesskey(), 'docslink' => $docs, + 'showrequireconfirm' => true]; $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params); echo $renderer->single_button($addurl, get_string('createnewgoogleissuer', 'tool_oauth2')); + + // Microsoft template. $docs = 'admin/tool/oauth2/issuers/microsoft'; - $params = ['action' => 'edittemplate', 'type' => 'microsoft', 'sesskey' => sesskey(), 'docslink' => $docs]; + $params = ['action' => 'edittemplate', 'type' => 'microsoft', 'sesskey' => sesskey(), 'docslink' => $docs, + 'showrequireconfirm' => true]; $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params); echo $renderer->single_button($addurl, get_string('createnewmicrosoftissuer', 'tool_oauth2')); + + // Facebook template. $docs = 'admin/tool/oauth2/issuers/facebook'; - $params = ['action' => 'edittemplate', 'type' => 'microsoft', 'sesskey' => sesskey(), 'docslink' => $docs]; - $params = ['action' => 'edittemplate', 'type' => 'facebook', 'sesskey' => sesskey(), 'docslink' => $docs]; + $params = ['action' => 'edittemplate', 'type' => 'facebook', 'sesskey' => sesskey(), 'docslink' => $docs, + 'showrequireconfirm' => true]; $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params); echo $renderer->single_button($addurl, get_string('createnewfacebookissuer', 'tool_oauth2')); - $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', ['action' => 'edit']); + + // Nextcloud template. $docs = 'admin/tool/oauth2/issuers/nextcloud'; $params = ['action' => 'edittemplate', 'type' => 'nextcloud', 'sesskey' => sesskey(), 'docslink' => $docs]; $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params); echo $renderer->single_button($addurl, get_string('createnewnextcloudissuer', 'tool_oauth2')); + + // Generic issuer. $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', ['action' => 'edit']); echo $renderer->single_button($addurl, get_string('createnewissuer', 'tool_oauth2')); + echo $OUTPUT->footer(); } diff --git a/admin/tool/phpunit/webrunner.php b/admin/tool/phpunit/webrunner.php index 47a2b42ecb66d..32db732aacf04 100644 --- a/admin/tool/phpunit/webrunner.php +++ b/admin/tool/phpunit/webrunner.php @@ -131,11 +131,12 @@ tool_phpunit_problem('Can not create configuration file'); } } - $configdir = escapeshellarg($configdir); - // no cleanup of path - this is tricky because we can not use escapeshellarg and friends for escaping, - // this is from admin user so PARAM_PATH must be enough chdir($CFG->dirroot); - passthru("php $CFG->admin/tool/phpunit/cli/util.php --run -c $configdir $testclass $testpath", $code); + $configdir = escapeshellarg($configdir); + $cleanclass = escapeshellarg($testclass); + $cleanpath = escapeshellarg($testpath); + + passthru("php $CFG->admin/tool/phpunit/cli/util.php --run -c $configdir $cleanclass $cleanpath", $code); chdir($oldcwd); echo ''; @@ -146,7 +147,7 @@ } echo $OUTPUT->box_start('generalbox boxwidthwide boxaligncenter'); -echo ''; +echo ''; echo '
'; echo ' '; echo ' (all test cases from webrunner.xml if empty)'; diff --git a/admin/tool/policy/amd/build/policyactions.min.js b/admin/tool/policy/amd/build/policyactions.min.js index 2e547734159a6..2fcb6849eb6c1 100644 --- a/admin/tool/policy/amd/build/policyactions.min.js +++ b/admin/tool/policy/amd/build/policyactions.min.js @@ -1 +1 @@ -define(["jquery","core/ajax","core/notification","core/modal_factory","core/modal_events"],function(a,b,c,d,e){var f={VIEW_POLICY:'[data-action="view"]'},g=function(){this.registerEvents()};return g.prototype.registerEvents=function(){a(f.VIEW_POLICY).click(function(f){f.preventDefault();var g=a(this).data("versionid"),h=a(this).data("behalfid"),i={versionid:g,behalfid:h},j={methodname:"tool_policy_get_policy_version",args:i},k=a.Deferred(),l=a.Deferred(),m=d.create({title:k,body:l,large:!0}).then(function(a){return a.getRoot().on(e.hidden,function(){a.destroy()}),a}).then(function(a){return a.show(),a})["catch"](c.exception),n=b.call([j]);a.when(n[0]).then(function(a){if(a.result.policy)return k.resolve(a.result.policy.name),l.resolve(a.result.policy.content),a;throw new Error(a.warnings[0].message)})["catch"](function(a){return m.then(function(a){return a.hide(),a.destroy(),a})["catch"](c.exception),c.addNotification({message:a,type:"error"})})})},{init:function(){return new g}}}); \ No newline at end of file +define(["jquery","core/ajax","core/notification","core/modal_factory","core/modal_events"],function(a,b,c,d,e){var f=function(a){this.registerEvents(a)};return f.prototype.registerEvents=function(f){f.on("click",function(f){f.preventDefault();var g=a(this).data("versionid"),h=a(this).data("behalfid"),i={versionid:g,behalfid:h},j={methodname:"tool_policy_get_policy_version",args:i},k=a.Deferred(),l=a.Deferred(),m=d.create({title:k,body:l,large:!0}).then(function(a){return a.getRoot().on(e.hidden,function(){a.destroy()}),a}).then(function(a){return a.show(),a})["catch"](c.exception),n=b.call([j]);a.when(n[0]).then(function(a){if(a.result.policy)return k.resolve(a.result.policy.name),l.resolve(a.result.policy.content),a;throw new Error(a.warnings[0].message)})["catch"](function(a){return m.then(function(a){return a.hide(),a.destroy(),a})["catch"](c.exception),c.addNotification({message:a,type:"error"})})})},{init:function(b){return b=a(b),new f(b)}}}); \ No newline at end of file diff --git a/admin/tool/policy/amd/src/policyactions.js b/admin/tool/policy/amd/src/policyactions.js index b3de54a487ccf..d8cd0f2bd9169 100644 --- a/admin/tool/policy/amd/src/policyactions.js +++ b/admin/tool/policy/amd/src/policyactions.js @@ -29,27 +29,18 @@ define([ 'core/modal_events'], function($, Ajax, Notification, ModalFactory, ModalEvents) { - /** - * List of action selectors. - * - * @type {{VIEW_POLICY: string}} - */ - var ACTIONS = { - VIEW_POLICY: '[data-action="view"]' - }; - /** * PolicyActions class. */ - var PolicyActions = function() { - this.registerEvents(); + var PolicyActions = function(root) { + this.registerEvents(root); }; /** * Register event listeners. */ - PolicyActions.prototype.registerEvents = function() { - $(ACTIONS.VIEW_POLICY).click(function(e) { + PolicyActions.prototype.registerEvents = function(root) { + root.on("click", function(e) { e.preventDefault(); var versionid = $(this).data('versionid'); @@ -127,8 +118,9 @@ function($, Ajax, Notification, ModalFactory, ModalEvents) { * @method init * @return {PolicyActions} */ - 'init': function() { - return new PolicyActions(); + 'init': function(root) { + root = $(root); + return new PolicyActions(root); } }; }); diff --git a/admin/tool/policy/classes/form/accept_policy.php b/admin/tool/policy/classes/form/accept_policy.php index e7bcec43a7d5d..d385b2b33a932 100644 --- a/admin/tool/policy/classes/form/accept_policy.php +++ b/admin/tool/policy/classes/form/accept_policy.php @@ -111,7 +111,7 @@ public function definition() { } } - $PAGE->requires->js_call_amd('tool_policy/policyactions', 'init'); + $PAGE->requires->js_call_amd('tool_policy/policyactions', 'init', ['[data-action="view"]']); } /** @@ -197,4 +197,4 @@ public function process() { } } } -} \ No newline at end of file +} diff --git a/admin/tool/policy/db/upgrade.php b/admin/tool/policy/db/upgrade.php index cf1a33b3ed4b4..87e99655470ab 100644 --- a/admin/tool/policy/db/upgrade.php +++ b/admin/tool/policy/db/upgrade.php @@ -63,5 +63,8 @@ function xmldb_tool_policy_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/policy/templates/guestconsent.mustache b/admin/tool/policy/templates/guestconsent.mustache index 872757e085e07..9595e6985c42c 100644 --- a/admin/tool/policy/templates/guestconsent.mustache +++ b/admin/tool/policy/templates/guestconsent.mustache @@ -58,7 +58,7 @@ require(['jquery', 'tool_policy/jquery-eu-cookie-law-popup', 'tool_policy/policy "
    {{#policies}}" + "
  • " + "" + + " data-action=\"view-guest\" data-versionid=\"{{id}}\" data-behalfid=\"1\" >" + "{{{name}}}" + "" + "
  • " + @@ -81,7 +81,7 @@ require(['jquery', 'tool_policy/jquery-eu-cookie-law-popup', 'tool_policy/policy {{/haspolicies}} // Initialise the JS for the modal window which displays the policy versions. - ActionsMod.init(); + ActionsMod.init('[data-action="view-guest"]'); }); }); diff --git a/admin/tool/policy/templates/page_agreedocs.mustache b/admin/tool/policy/templates/page_agreedocs.mustache index 40512ad59509d..60efaa6838e28 100644 --- a/admin/tool/policy/templates/page_agreedocs.mustache +++ b/admin/tool/policy/templates/page_agreedocs.mustache @@ -143,6 +143,6 @@ {{#js}} // Initialise the JS for the modal window which displays the policy versions. require(['tool_policy/policyactions'], function(ActionsMod) { - ActionsMod.init(); + ActionsMod.init('[data-action="view"]'); }); {{/js}} diff --git a/admin/tool/policy/templates/page_nopermission.mustache b/admin/tool/policy/templates/page_nopermission.mustache index 93b8296a3f79e..81f66f0dff9e5 100644 --- a/admin/tool/policy/templates/page_nopermission.mustache +++ b/admin/tool/policy/templates/page_nopermission.mustache @@ -73,6 +73,6 @@ {{#js}} // Initialise the JS for the modal window which displays the policy versions. require(['tool_policy/policyactions'], function(ActionsMod) { - ActionsMod.init(); + ActionsMod.init('[data-action="view"]'); }); {{/js}} diff --git a/admin/tool/spamcleaner/lang/en/tool_spamcleaner.php b/admin/tool/spamcleaner/lang/en/tool_spamcleaner.php index e9a36aa5cc6df..76a5f34871730 100644 --- a/admin/tool/spamcleaner/lang/en/tool_spamcleaner.php +++ b/admin/tool/spamcleaner/lang/en/tool_spamcleaner.php @@ -28,7 +28,7 @@ $string['spamcannotdelete'] = 'Cannot delete this user'; $string['spamcannotfinduser'] = 'No users matching your search'; $string['spamcleanerintro'] = '

    This script allows you to search all user profiles for certain strings and then delete those accounts which are obviously created by spammers. You can search for multiple keywords using commas (e.g. casino, porn).

    -

    For further information, see the documentation Reducing spam in Moodle.

    '; +

    For further information, see the documentation Reducing spam in Moodle.

    '; $string['spamdeleteall'] = 'Delete all these user accounts'; $string['spamdeleteallconfirm'] = 'Are you sure you want to delete all these user accounts? You can not undo this.'; $string['spamdeleteconfirm'] = 'Are you sure you want to delete this entry? You can not undo this.'; diff --git a/admin/tool/task/cli/schedule_task.php b/admin/tool/task/cli/schedule_task.php index cf03700bd4f9d..85d7a4fd6ceeb 100644 --- a/admin/tool/task/cli/schedule_task.php +++ b/admin/tool/task/cli/schedule_task.php @@ -50,7 +50,7 @@ -h, --help Print out this help Example: -\$sudo -u www-data /usr/bin/php admin/tool/task/cli/scheduled_task.php --execute=\\\\core\\\\task\\\\session_cleanup_task +\$sudo -u www-data /usr/bin/php admin/tool/task/cli/schedule_task.php --execute=\\\\core\\\\task\\\\session_cleanup_task "; diff --git a/admin/tool/uploadcourse/classes/course.php b/admin/tool/uploadcourse/classes/course.php index 7656ae8b59376..8ae7a8d51267e 100644 --- a/admin/tool/uploadcourse/classes/course.php +++ b/admin/tool/uploadcourse/classes/course.php @@ -412,6 +412,12 @@ public function prepare() { $this->error('invalidshortname', new lang_string('invalidshortname', 'tool_uploadcourse')); return false; } + + // Ensure we don't overflow the maximum length of the shortname field. + if (core_text::strlen($this->shortname) > 255) { + $this->error('invalidshortnametoolong', new lang_string('invalidshortnametoolong', 'tool_uploadcourse', 255)); + return false; + } } $exists = $this->exists(); @@ -479,6 +485,12 @@ public function prepare() { return false; } + // Ensure we don't overflow the maximum length of the fullname field. + if (!empty($coursedata['fullname']) && core_text::strlen($coursedata['fullname']) > 254) { + $this->error('invalidfullnametoolong', new lang_string('invalidfullnametoolong', 'tool_uploadcourse', 254)); + return false; + } + // If the course does not exist, or will be forced created. if (!$exists || $mode === tool_uploadcourse_processor::MODE_CREATE_ALL) { @@ -703,15 +715,29 @@ public function prepare() { $coursedata += $courseformat->validate_course_format_options($this->rawdata); } - // Special case, 'numsections' is not a course format option any more but still should apply from defaults. + // Special case, 'numsections' is not a course format option any more but still should apply from the template course, + // if any, and otherwise from defaults. if (!$exists || !array_key_exists('numsections', $coursedata)) { if (isset($this->rawdata['numsections']) && is_numeric($this->rawdata['numsections'])) { $coursedata['numsections'] = (int)$this->rawdata['numsections']; + } else if (isset($this->options['templatecourse'])) { + $numsections = tool_uploadcourse_helper::get_coursesection_count($this->options['templatecourse']); + if ($numsections != 0) { + $coursedata['numsections'] = $numsections; + } else { + $coursedata['numsections'] = get_config('moodlecourse', 'numsections'); + } } else { $coursedata['numsections'] = get_config('moodlecourse', 'numsections'); } } + // Visibility can only be 0 or 1. + if (!empty($coursedata['visible']) AND !($coursedata['visible'] == 0 OR $coursedata['visible'] == 1)) { + $this->error('invalidvisibilitymode', new lang_string('invalidvisibilitymode', 'tool_uploadcourse')); + return false; + } + // Saving data. $this->data = $coursedata; $this->enrolmentdata = tool_uploadcourse_helper::get_enrolment_data($this->rawdata); diff --git a/admin/tool/uploadcourse/classes/helper.php b/admin/tool/uploadcourse/classes/helper.php index 2e514a70f2127..2325c9c02ae93 100644 --- a/admin/tool/uploadcourse/classes/helper.php +++ b/admin/tool/uploadcourse/classes/helper.php @@ -286,6 +286,25 @@ public static function get_role_ids() { return $roles; } + /** + * Helper to detect how many sections a course with a given shortname has. + * + * @param string $shortname shortname of a course to count sections from. + * @return integer count of sections. + */ + public static function get_coursesection_count($shortname) { + global $DB; + if (!empty($shortname) || is_numeric($shortname)) { + // Creating restore from an existing course. + $course = $DB->get_record('course', array('shortname' => $shortname)); + } + if (!empty($course)) { + $courseformat = course_get_format($course); + return $courseformat->get_last_section_number(); + } + return 0; + } + /** * Get the role renaming data from the passed data. * diff --git a/admin/tool/uploadcourse/lang/en/tool_uploadcourse.php b/admin/tool/uploadcourse/lang/en/tool_uploadcourse.php index 6c20d8f49622c..9a0e2c6aad149 100644 --- a/admin/tool/uploadcourse/lang/en/tool_uploadcourse.php +++ b/admin/tool/uploadcourse/lang/en/tool_uploadcourse.php @@ -90,8 +90,11 @@ $string['invalidencoding'] = 'Invalid encoding'; $string['invalidmode'] = 'Invalid mode selected'; $string['invalideupdatemode'] = 'Invalid update mode selected'; +$string['invalidvisibilitymode'] = 'Invalid visible mode'; $string['invalidroles'] = 'Invalid role names: {$a}'; $string['invalidshortname'] = 'Invalid shortname'; +$string['invalidfullnametoolong'] = 'The fullname field is limited to {$a} characters'; +$string['invalidshortnametoolong'] = 'The shortname field is limited to {$a} characters'; $string['missingmandatoryfields'] = 'Missing value for mandatory fields: {$a}'; $string['missingshortnamenotemplate'] = 'Missing shortname and shortname template not set'; $string['mode'] = 'Upload mode'; diff --git a/admin/tool/uploadcourse/tests/course_test.php b/admin/tool/uploadcourse/tests/course_test.php index 092469fc3643e..0add18ea2ccf7 100644 --- a/admin/tool/uploadcourse/tests/course_test.php +++ b/admin/tool/uploadcourse/tests/course_test.php @@ -82,6 +82,47 @@ public function test_invalid_shortname() { $this->assertArrayHasKey('invalidshortname', $co->get_errors()); } + public function test_invalid_shortname_too_long() { + $this->resetAfterTest(); + + $mode = tool_uploadcourse_processor::MODE_CREATE_NEW; + $updatemode = tool_uploadcourse_processor::UPDATE_NOTHING; + + $upload = new tool_uploadcourse_course($mode, $updatemode, [ + 'category' => 1, + 'fullname' => 'New course', + 'shortname' => str_repeat('X', 2000), + ]); + + $this->assertFalse($upload->prepare()); + $this->assertArrayHasKey('invalidshortnametoolong', $upload->get_errors()); + } + + public function test_invalid_fullname_too_long() { + $this->resetAfterTest(); + + $mode = tool_uploadcourse_processor::MODE_CREATE_NEW; + $updatemode = tool_uploadcourse_processor::UPDATE_NOTHING; + + $upload = new tool_uploadcourse_course($mode, $updatemode, [ + 'category' => 1, + 'fullname' => str_repeat('X', 2000), + ]); + + $this->assertFalse($upload->prepare()); + $this->assertArrayHasKey('invalidfullnametoolong', $upload->get_errors()); + } + + public function test_invalid_visibility() { + $this->resetAfterTest(true); + $mode = tool_uploadcourse_processor::MODE_CREATE_NEW; + $updatemode = tool_uploadcourse_processor::UPDATE_NOTHING; + $data = array('shortname' => 'test', 'fullname' => 'New course', 'summary' => 'New', 'category' => 1, 'visible' => 2); + $co = new tool_uploadcourse_course($mode, $updatemode, $data); + $this->assertFalse($co->prepare()); + $this->assertArrayHasKey('invalidvisibilitymode', $co->get_errors()); + } + public function test_create() { global $DB; $this->resetAfterTest(true); diff --git a/admin/tool/uploaduser/locallib.php b/admin/tool/uploaduser/locallib.php index e988e777838ab..e389f8064e4f8 100644 --- a/admin/tool/uploaduser/locallib.php +++ b/admin/tool/uploaduser/locallib.php @@ -360,6 +360,7 @@ function uu_allowed_roles() { */ function uu_allowed_roles_cache() { $allowedroles = get_assignable_roles(context_course::instance(SITEID), ROLENAME_SHORT); + $rolecache = []; foreach ($allowedroles as $rid=>$rname) { $rolecache[$rid] = new stdClass(); $rolecache[$rid]->id = $rid; @@ -379,6 +380,7 @@ function uu_allowed_roles_cache() { */ function uu_allowed_sysroles_cache() { $allowedroles = get_assignable_roles(context_system::instance(), ROLENAME_SHORT); + $rolecache = []; foreach ($allowedroles as $rid => $rname) { $rolecache[$rid] = new stdClass(); $rolecache[$rid]->id = $rid; diff --git a/admin/tool/usertours/classes/manager.php b/admin/tool/usertours/classes/manager.php index f29f481aae35a..c7e5b3e533f48 100644 --- a/admin/tool/usertours/classes/manager.php +++ b/admin/tool/usertours/classes/manager.php @@ -257,7 +257,7 @@ protected function print_tour_list() { 'title' => get_string('importtour', 'tool_usertours'), ], (object) [ - 'link' => new \moodle_url('https://moodle.net/tours'), + 'link' => new \moodle_url('https://archive.moodle.net/tours'), 'linkproperties' => [ 'target' => '_blank', ], diff --git a/admin/tool/usertours/classes/privacy/provider.php b/admin/tool/usertours/classes/privacy/provider.php index 4441ba84b9994..3713d071236f0 100644 --- a/admin/tool/usertours/classes/privacy/provider.php +++ b/admin/tool/usertours/classes/privacy/provider.php @@ -77,18 +77,22 @@ public static function export_user_preferences(int $userid) { } if ($descriptionidentifier !== null) { - $time = transform::datetime($value); - $tour = \tool_usertours\tour::instance($tourid); + try { + $tour = \tool_usertours\tour::instance($tourid); + $time = transform::datetime($value); - writer::export_user_preference( - 'tool_usertours', - $name, - $time, - get_string($descriptionidentifier, 'tool_usertours', (object) [ - 'name' => $tour->get_name(), - 'time' => $time, - ]) - ); + writer::export_user_preference( + 'tool_usertours', + $name, + $time, + get_string($descriptionidentifier, 'tool_usertours', (object) [ + 'name' => $tour->get_name(), + 'time' => $time, + ]) + ); + } catch (\dml_missing_record_exception $ex) { + // The tour related to this user preference no longer exists. + } } } } diff --git a/admin/tool/usertours/db/upgrade.php b/admin/tool/usertours/db/upgrade.php index 47e499422ffe4..523a453ceda0e 100644 --- a/admin/tool/usertours/db/upgrade.php +++ b/admin/tool/usertours/db/upgrade.php @@ -61,5 +61,8 @@ function xmldb_tool_usertours_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2019030600, 'tool', 'usertours'); } + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/admin/tool/usertours/tests/privacy_provider_test.php b/admin/tool/usertours/tests/privacy_provider_test.php index 8cfc81a515c4e..db0139d4b2987 100644 --- a/admin/tool/usertours/tests/privacy_provider_test.php +++ b/admin/tool/usertours/tests/privacy_provider_test.php @@ -112,4 +112,37 @@ public function test_export_user_preferences_requested() { $this->assertCount(2, (array) $prefs); } + + /** + * Ensure that export_user_preferences excludes deleted tours. + */ + public function test_export_user_preferences_deleted_tour() { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $user = \core_user::get_user_by_username('admin'); + + $alltours = $DB->get_records('tool_usertours_tours'); + + $tour1 = tour::instance(array_shift($alltours)->id); + $tour1->mark_user_completed(); + + $tour2 = tour::instance(array_shift($alltours)->id); + $tour2->mark_user_completed(); + $tour2->remove(); + + $writer = writer::with_context(\context_system::instance()); + + provider::export_user_preferences($user->id); + $this->assertTrue($writer->has_any_data()); + + // We should have one preference. + $prefs = $writer->get_user_preferences('tool_usertours'); + $this->assertCount(1, (array) $prefs); + + // The preference should be related to the first tour. + $this->assertContains($tour1->get_name(), reset($prefs)->description); + } } diff --git a/admin/tool/xmldb/actions/load_xml_file/load_xml_file.class.php b/admin/tool/xmldb/actions/load_xml_file/load_xml_file.class.php index a6384153941da..3a7b01d59bedd 100644 --- a/admin/tool/xmldb/actions/load_xml_file/load_xml_file.class.php +++ b/admin/tool/xmldb/actions/load_xml_file/load_xml_file.class.php @@ -38,9 +38,6 @@ function init() { $this->can_subaction = ACTION_NONE; //$this->can_subaction = ACTION_HAVE_SUBACTIONS; - // Set own custom attributes - $this->sesskey_protected = false; // This action doesn't need sesskey protection - // Get needed strings $this->loadStrings(array( // 'key' => 'module', diff --git a/admin/tool/xmldb/actions/main_view/main_view.class.php b/admin/tool/xmldb/actions/main_view/main_view.class.php index 5ac58fc4577f6..feef78ae00fbc 100644 --- a/admin/tool/xmldb/actions/main_view/main_view.class.php +++ b/admin/tool/xmldb/actions/main_view/main_view.class.php @@ -169,7 +169,7 @@ function invoke() { file_exists($key . '/install.xml') && is_readable($key . '/install.xml') && empty($dbdir->xml_loaded)) { - $b .= '[' . $this->str['load'] . ']'; + $b .= '[' . $this->str['load'] . ']'; } else { $b .= '[' . $this->str['load'] . ']'; } @@ -239,7 +239,7 @@ function invoke() { is_readable($key . '/install.xml') && !empty($dbdir->xml_loaded) && empty($dbdir->xml_changed)) { - $b .= '[' . $this->str['unload'] . ']'; + $b .= '[' . $this->str['unload'] . ']'; } else { $b .= '[' . $this->str['unload'] . ']'; } diff --git a/admin/tool/xmldb/actions/unload_xml_file/unload_xml_file.class.php b/admin/tool/xmldb/actions/unload_xml_file/unload_xml_file.class.php index 0468cb2ca5d4c..78b2cab6d6e75 100644 --- a/admin/tool/xmldb/actions/unload_xml_file/unload_xml_file.class.php +++ b/admin/tool/xmldb/actions/unload_xml_file/unload_xml_file.class.php @@ -35,9 +35,6 @@ class unload_xml_file extends XMLDBAction { function init() { parent::init(); - // Set own custom attributes - $this->sesskey_protected = false; // This action doesn't need sesskey protection - // Get needed strings $this->loadStrings(array( // 'key' => 'module', diff --git a/admin/tool/xmldb/lang/en/tool_xmldb.php b/admin/tool/xmldb/lang/en/tool_xmldb.php index 2abd94722a51b..c465ed16c11c4 100644 --- a/admin/tool/xmldb/lang/en/tool_xmldb.php +++ b/admin/tool/xmldb/lang/en/tool_xmldb.php @@ -34,7 +34,7 @@ $string['confirmdeletekey'] = 'Are you absolutely sure that you want to delete the key:'; $string['confirmdeletetable'] = 'Are you absolutely sure that you want to delete the table:'; $string['confirmdeletexmlfile'] = 'Are you absolutely sure that you want to delete the file:'; -$string['confirmcheckbigints'] = 'This functionality will search for potential wrong integer fields in your Moodle server, generating (but not executing!) automatically the needed SQL statements to have all the integers in your DB properly defined. +$string['confirmcheckbigints'] = 'This functionality will search for potential wrong integer fields in your Moodle server, generating (but not executing!) automatically the needed SQL statements to have all the integers in your DB properly defined. Once generated you can copy such statements and execute them safely with your favourite SQL interface (don\'t forget to backup your data before doing that). @@ -60,7 +60,7 @@ It\'s highly recommended to be running the latest (+ version) available of your Moodle release before executing the search of missing indexes. This functionality doesn\'t perform any action against the DB (just reads from it), so can be safely executed at any moment.'; -$string['confirmcheckoraclesemantics'] = 'This functionality will search for Oracle varchar2 columns using BYTE semantics in your Moodle server, generating (but not executing!) automatically the needed SQL statements to have all the columns converted to use CHAR semantics instead (better for cross-db compatibility and increased contents max. length). +$string['confirmcheckoraclesemantics'] = 'This functionality will search for Oracle varchar2 columns using BYTE semantics in your Moodle server, generating (but not executing!) automatically the needed SQL statements to have all the columns converted to use CHAR semantics instead (better for cross-db compatibility and increased contents max. length). Once generated you can copy such statements and execute them safely with your favourite SQL interface (don\'t forget to backup your data before doing that). @@ -140,7 +140,7 @@ $string['key'] = 'Key'; $string['keynameempty'] = 'The key name cannot be empty'; $string['keys'] = 'Keys'; -$string['listreservedwords'] = 'List of Reserved Words
    (used to keep XMLDB_reserved_words updated)'; +$string['listreservedwords'] = 'List of reserved words
    (used to keep XMLDB reserved words updated)'; $string['load'] = 'Load'; $string['main_view'] = 'Main view'; $string['masterprimaryuniqueordernomatch'] = 'The fields in your foreign key must be listed in the same order as they are listed in the UNIQUE KEY on the referenced table.'; diff --git a/admin/user.php b/admin/user.php index f6ab61f6bf3e7..216906ccf4d25 100644 --- a/admin/user.php +++ b/admin/user.php @@ -401,7 +401,7 @@ $row = array (); $row[] = "id&course=$site->id\">$fullname"; foreach ($extracolumns as $field) { - $row[] = $user->{$field}; + $row[] = s($user->{$field}); } $row[] = $user->city; $row[] = $user->country; diff --git a/admin/user/user_bulk_cohortadd.php b/admin/user/user_bulk_cohortadd.php index 6ce1d9757356c..f5e39475835cc 100644 --- a/admin/user/user_bulk_cohortadd.php +++ b/admin/user/user_bulk_cohortadd.php @@ -138,7 +138,7 @@ function sort_compare($a, $b) { '' . $user->fullname . '', - $user->email, + s($user->email), $user->city, $user->country, $user->lastaccess ? format_time(time() - $user->lastaccess) : $strnever diff --git a/admin/user/user_bulk_display.php b/admin/user/user_bulk_display.php index dd7956629b342..ae6cd8a5395a0 100644 --- a/admin/user/user_bulk_display.php +++ b/admin/user/user_bulk_display.php @@ -72,7 +72,7 @@ function sort_compare($a, $b) { $table->data[] = array ( ''.$user->fullname.'', // $user->username, - $user->email, + s($user->email), $user->city, $user->country, $user->lastaccess ? format_time(time() - $user->lastaccess) : $strnever diff --git a/analytics/classes/analysis.php b/analytics/classes/analysis.php index 25d926a16981a..ddb9c6fce5b03 100644 --- a/analytics/classes/analysis.php +++ b/analytics/classes/analysis.php @@ -138,6 +138,9 @@ public function run() { } } } + + // Force GC to clean up the indicator instances used during the last iteration. + $this->analyser->instantiate_indicators(); } /** @@ -313,7 +316,8 @@ protected function process_time_splitting(\core_analytics\local\time_splitting\b } try { - $indicators = $this->analyser->get_indicators(); + // Instantiate empty indicators to ensure that no garbage is dragged from previous analyses. + $indicators = $this->analyser->instantiate_indicators(); foreach ($indicators as $key => $indicator) { // The analyser attaches the main entities the sample depends on and are provided to the // indicator to calculate the sample. @@ -473,6 +477,11 @@ protected function calculate_indicators(\core_analytics\local\time_splitting\bas list($samplesfeatures, $newindicatorcalculations, $indicatornotnulls) = $rangeindicator->calculate($sampleids, $this->analyser->get_samples_origin(), $range['start'], $range['end'], $prevcalculations); + // Free memory ASAP. + unset($rangeindicator); + gc_collect_cycles(); + gc_mem_caches(); + // Copy the features data to the dataset. foreach ($samplesfeatures as $analysersampleid => $features) { @@ -502,7 +511,7 @@ protected function calculate_indicators(\core_analytics\local\time_splitting\bas $indcalc->endtime = $range['end']; $indcalc->sampleid = $sampleid; $indcalc->sampleorigin = $this->analyser->get_samples_origin(); - $indcalc->indicator = $rangeindicator->get_id(); + $indcalc->indicator = $indicator->get_id(); $indcalc->value = $calculatedvalue; $indcalc->timecreated = $timecreated; $newcalculations[] = $indcalc; diff --git a/analytics/classes/insights_generator.php b/analytics/classes/insights_generator.php index 24fdfaf1d8d75..6101a961278d1 100644 --- a/analytics/classes/insights_generator.php +++ b/analytics/classes/insights_generator.php @@ -135,7 +135,7 @@ private function notification(\context $context, \stdClass $user, \moodle_url $i $message->component = 'moodle'; $message->name = 'insights'; - $message->userfrom = \core_user::get_noreply_user(); + $message->userfrom = \core_user::get_support_user(); $message->userto = $user; $message->subject = $this->target->get_insight_subject($this->modelid, $context); @@ -193,18 +193,23 @@ private function prediction_info(\core_analytics\prediction $prediction) { $insighturl = null; foreach ($predictionactions as $action) { $actionurl = $action->get_url(); + $opentoblank = false; if (!$actionurl->get_param('forwardurl')) { - $actiondoneurl = new \moodle_url('/report/insights/done.php'); + $params = ['actionvisiblename' => $action->get_text(), 'target' => '_blank']; + $actiondoneurl = new \moodle_url('/report/insights/done.php', $params); // Set the forward url to the 'done' script. $actionurl->param('forwardurl', $actiondoneurl->out(false)); + + $opentoblank = true; } if (empty($insighturl)) { // We use the primary action url as insight url so we log that the user followed the provided link. $insighturl = $action->get_url(); } - $actiondata = (object)['url' => $action->get_url()->out(false), 'text' => $action->get_text()]; + $actiondata = (object)['url' => $action->get_url()->out(false), 'text' => $action->get_text(), + 'opentoblank' => $opentoblank]; $fullmessageplaintext .= get_string('insightinfomessageaction', 'analytics', $actiondata) . PHP_EOL; $messageactions[] = $actiondata; } diff --git a/analytics/classes/local/analyser/base.php b/analytics/classes/local/analyser/base.php index 1c65e2c627352..0065f15ab85a7 100644 --- a/analytics/classes/local/analyser/base.php +++ b/analytics/classes/local/analyser/base.php @@ -237,6 +237,23 @@ public function get_indicators(): array { return $this->indicators; } + /** + * Instantiate the indicators. + * + * @return \core_analytics\local\indicator\base[] + */ + public function instantiate_indicators() { + foreach ($this->indicators as $key => $indicator) { + $this->indicators[$key] = call_user_func(array($indicator, 'instance')); + } + + // Free memory ASAP. + gc_collect_cycles(); + gc_mem_caches(); + + return $this->indicators; + } + /** * Samples data this analyser provides. * diff --git a/analytics/classes/local/analyser/by_course.php b/analytics/classes/local/analyser/by_course.php index 99e70c1e6a1c9..c0d308b0d70da 100644 --- a/analytics/classes/local/analyser/by_course.php +++ b/analytics/classes/local/analyser/by_course.php @@ -50,12 +50,11 @@ public function get_analysables_iterator(?string $action = null) { if (!empty($this->options['filter'])) { $courses = array(); foreach ($this->options['filter'] as $courseid) { - $courses[$courseid] = new \stdClass(); - $courses[$courseid]->id = $courseid; + $courses[$courseid] = intval($courseid); } list($coursesql, $courseparams) = $DB->get_in_or_equal($courses, SQL_PARAMS_NAMED); - $sql .= " AND c.id IN $coursesql"; + $sql .= " AND c.id $coursesql"; $params = $params + $courseparams; } diff --git a/analytics/classes/local/analysis/result_file.php b/analytics/classes/local/analysis/result_file.php index 7a6b61e47acb4..16ef9f198b236 100644 --- a/analytics/classes/local/analysis/result_file.php +++ b/analytics/classes/local/analysis/result_file.php @@ -78,9 +78,9 @@ public function retrieve_cached_result(\core_analytics\local\time_splitting\base // if this analyser was analysed less that 1 week ago we skip generating a new one. This // helps scale the evaluation process as sites with tons of courses may need a lot of time to // complete an evaluation. - if (!empty($options['evaluation']) && !empty($options['reuseprevanalysed'])) { + if (!empty($this->options['evaluation']) && !empty($this->options['reuseprevanalysed'])) { - $previousanalysis = \core_analytics\dataset_manager::get_evaluation_analysable_file($this->analyser->get_modelid(), + $previousanalysis = \core_analytics\dataset_manager::get_evaluation_analysable_file($this->modelid, $analysable->get_id(), $timesplitting->get_id()); // 1 week is a partly random time interval, no need to worry about DST. $boundary = time() - WEEKSECS; diff --git a/analytics/classes/local/indicator/community_of_inquiry_activity.php b/analytics/classes/local/indicator/community_of_inquiry_activity.php index 5deaeb6049e3c..555bb420e7c8b 100644 --- a/analytics/classes/local/indicator/community_of_inquiry_activity.php +++ b/analytics/classes/local/indicator/community_of_inquiry_activity.php @@ -836,7 +836,8 @@ protected function activity_completed_by(\cm_info $activity, $starttime, $endtim // When the course is using format weeks we use the week's end date. $format = course_get_format($activity->get_modinfo()->get_course()); // We should change this in MDL-60702. - if (method_exists($format, 'get_section_dates')) { + if (get_class($format) == 'format_weeks' || is_subclass_of($format, 'format_weeks') + && method_exists($format, 'get_section_dates')) { $dates = $format->get_section_dates($section); // We need to consider the +2 hours added by get_section_dates. diff --git a/analytics/classes/local/target/base.php b/analytics/classes/local/target/base.php index 0ea5e8b8e29c5..900b6a9593a07 100644 --- a/analytics/classes/local/target/base.php +++ b/analytics/classes/local/target/base.php @@ -95,6 +95,15 @@ public static function uses_insights() { return true; } + /** + * Should the insights of this model be linked from reports? + * + * @return bool + */ + public function link_insights_report(): bool { + return true; + } + /** * Based on facts (processed by machine learning backends) by default. * @@ -130,12 +139,14 @@ public function prediction_actions(\core_analytics\prediction $prediction, $incl global $PAGE; $predictionid = $prediction->get_prediction_data()->id; + $contextid = $prediction->get_prediction_data()->contextid; + $modelid = $prediction->get_prediction_data()->modelid; - $PAGE->requires->js_call_amd('report_insights/actions', 'init', array($predictionid)); + $PAGE->requires->js_call_amd('report_insights/actions', 'init', array($predictionid, $contextid, $modelid)); $actions = array(); - if ($includedetailsaction) { + if ($this->link_insights_report() && $includedetailsaction) { $predictionurl = new \moodle_url('/report/insights/prediction.php', array('id' => $predictionid)); $detailstext = $this->get_view_details_text(); @@ -227,7 +238,12 @@ public function generate_insight_notifications($modelid, $samplecontexts, array */ public function get_insights_users(\context $context) { if ($context->contextlevel === CONTEXT_USER) { - $users = [$context->instanceid => \core_user::get_user($context->instanceid)]; + if (!has_capability('moodle/analytics:listowninsights', $context, $context->instanceid)) { + $users = []; + } else { + $users = [$context->instanceid => \core_user::get_user($context->instanceid)]; + } + } else if ($context->contextlevel >= CONTEXT_COURSE) { // At course level or below only enrolled users although this is not ideal for // teachers assigned at category level. diff --git a/analytics/classes/manager.php b/analytics/classes/manager.php index 6c40795121867..178c36fa7e531 100644 --- a/analytics/classes/manager.php +++ b/analytics/classes/manager.php @@ -480,6 +480,9 @@ public static function get_indicator_calculations($analysable, $starttime, $endt /** * Returns the models with insights at the provided context. * + * Note that this method is used for display purposes. It filters out models whose insights + * are not linked from the reports page. + * * @param \context $context * @return \core_analytics\model[] */ @@ -490,13 +493,52 @@ public static function get_models_with_insights(\context $context) { $models = self::get_all_models(true, true, $context); foreach ($models as $key => $model) { // Check that it not only have predictions but also generates insights from them. - if (!$model->uses_insights()) { + if (!$model->uses_insights() || !$model->get_target()->link_insights_report()) { unset($models[$key]); } } return $models; } + /** + * Returns the models that generated insights in the provided context. It can also be used to add new models to the context. + * + * Note that if you use this function with $newmodelid is the caller responsibility to ensure that the + * provided model id generated insights for the provided context. + * + * @throws \coding_exception + * @param \context $context + * @param int|null $newmodelid A new model to add to the list of models with insights in the provided context. + * @return int[] + */ + public static function cached_models_with_insights(\context $context, int $newmodelid = null) { + + $cache = \cache::make('core', 'contextwithinsights'); + $modelids = $cache->get($context->id); + if ($modelids === false) { + // The cache is empty, but we don't know if it is empty because there are no insights + // in this context or because cache/s have been purged, we need to be conservative and + // "pay" 1 db read to fill up the cache. + + $models = \core_analytics\manager::get_models_with_insights($context); + + if ($newmodelid && empty($models[$newmodelid])) { + throw new \coding_exception('The provided modelid ' . $newmodelid . ' did not generate any insights'); + } + + $modelids = array_keys($models); + $cache->set($context->id, $modelids); + + } else if ($newmodelid && !in_array($newmodelid, $modelids)) { + // We add the context we got as an argument to the cache. + + array_push($modelids, $newmodelid); + $cache->set($context->id, $modelids); + } + + return $modelids; + } + /** * Returns a prediction * @@ -549,30 +591,32 @@ public static function add_builtin_models() { public static function cleanup() { global $DB; - // Clean up stuff that depends on contexts that do not exist anymore. - $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap - LEFT JOIN {context} ctx ON ap.contextid = ctx.id - WHERE ctx.id IS NULL"; - $apcontexts = $DB->get_records_sql($sql); - - $sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic - LEFT JOIN {context} ctx ON aic.contextid = ctx.id - WHERE ctx.id IS NULL"; - $indcalccontexts = $DB->get_records_sql($sql); + $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN + (SELECT ap.id FROM {analytics_predictions} ap + LEFT JOIN {context} ctx ON ap.contextid = ctx.id + WHERE ctx.id IS NULL)"); - $contexts = $apcontexts + $indcalccontexts; - if ($contexts) { - list($sql, $params) = $DB->get_in_or_equal(array_keys($contexts)); - $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN - (SELECT ap.id FROM {analytics_predictions} ap WHERE ap.contextid $sql)", $params); - - $DB->delete_records_select('analytics_predictions', "contextid $sql", $params); - $DB->delete_records_select('analytics_indicator_calc', "contextid $sql", $params); - } + $contextsql = "SELECT id FROM {context} ctx"; + $DB->delete_records_select('analytics_predictions', "contextid NOT IN ($contextsql)"); + $DB->delete_records_select('analytics_indicator_calc', "contextid NOT IN ($contextsql)"); // Clean up stuff that depends on analysable ids that do not exist anymore. + $models = self::get_all_models(); foreach ($models as $model) { + + // We first dump into memory the list of analysables we have in the database (we could probably do this with 1 single + // query for the 3 tables, but it may be safer to do it separately). + $predictsamplesanalysableids = $DB->get_fieldset_select('analytics_predict_samples', 'DISTINCT analysableid', + 'modelid = :modelid', ['modelid' => $model->get_id()]); + $predictsamplesanalysableids = array_flip($predictsamplesanalysableids); + $trainsamplesanalysableids = $DB->get_fieldset_select('analytics_train_samples', 'DISTINCT analysableid', + 'modelid = :modelid', ['modelid' => $model->get_id()]); + $trainsamplesanalysableids = array_flip($trainsamplesanalysableids); + $usedanalysablesanalysableids = $DB->get_fieldset_select('analytics_used_analysables', 'DISTINCT analysableid', + 'modelid = :modelid', ['modelid' => $model->get_id()]); + $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids); + $analyser = $model->get_analyser(array('notimesplitting' => true)); $analysables = $analyser->get_analysables_iterator(); @@ -581,17 +625,28 @@ public static function cleanup() { if (!$analysable) { continue; } - $analysableids[] = $analysable->get_id(); - } - if (empty($analysableids)) { - continue; + unset($predictsamplesanalysableids[$analysable->get_id()]); + unset($trainsamplesanalysableids[$analysable->get_id()]); + unset($usedanalysablesanalysableids[$analysable->get_id()]); } - list($notinsql, $params) = $DB->get_in_or_equal($analysableids, SQL_PARAMS_NAMED, 'param', false); - $params['modelid'] = $model->get_id(); + $param = ['modelid' => $model->get_id()]; - $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $notinsql", $params); - $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $notinsql", $params); + if ($predictsamplesanalysableids) { + list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($predictsamplesanalysableids), SQL_PARAMS_NAMED); + $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $idssql", + $param + $idsparams); + } + if ($trainsamplesanalysableids) { + list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($trainsamplesanalysableids), SQL_PARAMS_NAMED); + $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $idssql", + $param + $idsparams); + } + if ($usedanalysablesanalysableids) { + list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($usedanalysablesanalysableids), SQL_PARAMS_NAMED); + $DB->delete_records_select('analytics_used_analysables', "modelid = :modelid AND analysableid $idssql", + $param + $idsparams); + } } } diff --git a/analytics/classes/model.php b/analytics/classes/model.php index cfe614b74aa33..6907190dfd501 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -933,19 +933,11 @@ protected function trigger_insights($samplecontexts, $predictionrecords) { $this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts, $predictions); - // Update cache. - $cache = \cache::make('core', 'contextwithinsights'); - foreach ($samplecontexts as $context) { - $modelids = $cache->get($context->id); - if (!$modelids) { - // The cache is empty, but we don't know if it is empty because there are no insights - // in this context or because cache/s have been purged, we need to be conservative and - // "pay" 1 db read to fill up the cache. - $models = \core_analytics\manager::get_models_with_insights($context); - $cache->set($context->id, array_keys($models)); - } else if (!in_array($this->get_id(), $modelids)) { - array_push($modelids, $this->get_id()); - $cache->set($context->id, $modelids); + if ($this->get_target()->link_insights_report()) { + + // Update cache. + foreach ($samplecontexts as $context) { + \core_analytics\manager::cached_models_with_insights($context, $this->get_id()); } } } @@ -1000,7 +992,7 @@ protected function get_static_predictions(&$indicatorcalculations) { } // Get all samples data. - list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids); + list($sampleids, $samplesdata) = $this->get_samples($sampleids); // Calculate the targets. $predictions = array(); @@ -1314,7 +1306,7 @@ public function get_predictions(\context $context, $skiphidden = true, $page = f return $prediction->sampleid; }, $predictions); - list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids); + list($unused, $samplesdata) = $this->get_samples($sampleids); $current = 0; @@ -1356,7 +1348,7 @@ public function get_predictions(\context $context, $skiphidden = true, $page = f */ public function prediction_sample_data($predictionobj) { - list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid)); + list($unused, $samplesdata) = $this->get_samples(array($predictionobj->sampleid)); if (empty($samplesdata[$predictionobj->sampleid])) { throw new \moodle_exception('errorsamplenotavailable', 'analytics'); @@ -1668,12 +1660,8 @@ public function clear() { $predictor->clear_model($this->get_unique_id(), $this->get_output_dir()); } - $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid', - array('modelid' => $this->get_id())); - if ($predictionids) { - list($sql, $params) = $DB->get_in_or_equal($predictionids); - $DB->delete_records_select('analytics_prediction_actions', "predictionid $sql", $params); - } + $DB->delete_records_select('analytics_prediction_actions', "predictionid IN + (SELECT id FROM {analytics_predictions} WHERE modelid = :modelid)", ['modelid' => $this->get_id()]); $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id)); $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id)); @@ -1759,30 +1747,94 @@ private function add_prediction_ids($predictionrecords) { $contextids = array_map(function($predictionobj) { return $predictionobj->contextid; }, $predictionrecords); - list($contextsql, $contextparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED); - - // We select the fields that will allow us to map ids to $predictionrecords. Given that we already filter by modelid - // we have enough with sampleid and rangeindex. The reason is that the sampleid relation to a site is N - 1. - $fields = 'id, sampleid, rangeindex'; - - // We include the contextid and the timecreated filter to reduce the number of records in $dbpredictions. We can not - // add as many OR conditions as records in $predictionrecords. - $sql = "SELECT $fields - FROM {analytics_predictions} - WHERE modelid = :modelid - AND contextid $contextsql - AND timecreated >= :firsttimecreated"; - $params = $contextparams + ['modelid' => $this->model->id, 'firsttimecreated' => $firstprediction->timecreated]; - $dbpredictions = $DB->get_recordset_sql($sql, $params); - foreach ($dbpredictions as $id => $dbprediction) { - // The append_rangeindex implementation is the same regardless of the time splitting method in use. - $uniqueid = $this->get_time_splitting()->append_rangeindex($dbprediction->sampleid, $dbprediction->rangeindex); - $predictionrecords[$uniqueid]->id = $dbprediction->id; + + // Limited to 30000 records as a middle point between the ~65000 params limit in pgsql and the size limit for mysql which + // can be increased if required up to a reasonable point. + $chunks = array_chunk($contextids, 30000); + foreach ($chunks as $contextidschunk) { + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextidschunk, SQL_PARAMS_NAMED); + + // We select the fields that will allow us to map ids to $predictionrecords. Given that we already filter by modelid + // we have enough with sampleid and rangeindex. The reason is that the sampleid relation to a site is N - 1. + $fields = 'id, sampleid, rangeindex'; + + // We include the contextid and the timecreated filter to reduce the number of records in $dbpredictions. We can not + // add as many OR conditions as records in $predictionrecords. + $sql = "SELECT $fields + FROM {analytics_predictions} + WHERE modelid = :modelid + AND contextid $contextsql + AND timecreated >= :firsttimecreated"; + $params = $contextparams + ['modelid' => $this->model->id, 'firsttimecreated' => $firstprediction->timecreated]; + $dbpredictions = $DB->get_recordset_sql($sql, $params); + foreach ($dbpredictions as $id => $dbprediction) { + // The append_rangeindex implementation is the same regardless of the time splitting method in use. + $uniqueid = $this->get_time_splitting()->append_rangeindex($dbprediction->sampleid, $dbprediction->rangeindex); + $predictionrecords[$uniqueid]->id = $dbprediction->id; + } } return $predictionrecords; } + /** + * Wrapper around analyser's get_samples to skip DB's max-number-of-params exception. + * + * @param array $sampleids + * @return array + */ + public function get_samples(array $sampleids): array { + + if (empty($sampleids)) { + throw new \coding_exception('No sample ids provided'); + } + + $chunksize = count($sampleids); + + // We start with just 1 chunk, if it is too large for the db we split the list of sampleids in 2 and we + // try again. We repeat this process until the chunk is small enough for the db engine to process. The + // >= has been added in case there are other \dml_read_exceptions unrelated to the max number of params. + while (empty($done) && $chunksize >= 1) { + + $chunks = array_chunk($sampleids, $chunksize); + $allsampleids = []; + $allsamplesdata = []; + + foreach ($chunks as $index => $chunk) { + + try { + list($chunksampleids, $chunksamplesdata) = $this->get_analyser()->get_samples($chunk); + } catch (\dml_read_exception $e) { + + // Reduce the chunksize, we use floor() so the $chunksize is always less than the previous $chunksize value. + $chunksize = floor($chunksize / 2); + break; + } + + // We can sum as these two arrays are indexed by sampleid and there are no collisions. + $allsampleids = $allsampleids + $chunksampleids; + $allsamplesdata = $allsamplesdata + $chunksamplesdata; + + if ($index === count($chunks) - 1) { + // We successfully processed all the samples in all chunks, we are done. + $done = true; + } + } + } + + if (empty($done)) { + if (!empty($e)) { + // Throw the last exception we caught, the \dml_read_exception we have been catching is unrelated to the max number + // of param's exception. + throw new \dml_read_exception($e); + } else { + throw new \coding_exception('We should never reach this point, there is a bug in ' . + 'core_analytics\\model::get_samples\'s code'); + } + } + return [$allsampleids, $allsamplesdata]; + } + /** * Purges the insights cache. */ diff --git a/analytics/classes/privacy/provider.php b/analytics/classes/privacy/provider.php index 36f6069a82a3c..c147bdfd974a5 100644 --- a/analytics/classes/privacy/provider.php +++ b/analytics/classes/privacy/provider.php @@ -306,13 +306,9 @@ public static function delete_data_for_all_users_in_context(\context $context) { $idssql = "SELECT ap.id FROM {analytics_predictions} ap WHERE ap.contextid = :contextid AND ap.modelid = :modelid"; $idsparams = ['contextid' => $context->id, 'modelid' => $modelid]; - $predictionids = $DB->get_fieldset_sql($idssql, $idsparams); - if ($predictionids) { - list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED); - $DB->delete_records_select('analytics_prediction_actions', "predictionid IN ($idssql)", $idsparams); - $DB->delete_records_select('analytics_predictions', "id $predictionidssql", $params); - } + $DB->delete_records_select('analytics_prediction_actions', "predictionid IN ($idssql)", $idsparams); + $DB->delete_records_select('analytics_predictions', "contextid = :contextid AND modelid = :modelid", $idsparams); } // We delete them all this table is just a cache and we don't know which model filled it. diff --git a/analytics/templates/insight_info_message_prediction.mustache b/analytics/templates/insight_info_message_prediction.mustache index 9896031bf6d76..7bd76de7c0d85 100644 --- a/analytics/templates/insight_info_message_prediction.mustache +++ b/analytics/templates/insight_info_message_prediction.mustache @@ -33,7 +33,8 @@ "text": "Moodle" }, { "url": "https://en.wikipedia.org/wiki/Noodle", - "text": "Noodle" + "text": "Noodle", + "opentoblank": 1 } ] } @@ -65,5 +66,5 @@ body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
    {{#actions}} - {{text}} + {{text}} {{/actions}} diff --git a/analytics/tests/analysis_test.php b/analytics/tests/analysis_test.php index 0f5f3e6ff3f44..3ceb40e0c243c 100644 --- a/analytics/tests/analysis_test.php +++ b/analytics/tests/analysis_test.php @@ -65,7 +65,7 @@ public function test_fill_firstanalyses_cache() { $this->assertEquals($afewsecsago, $firstanalyses[$modelid . '_' . $course1->id]); $this->assertEquals($afewsecsago + 1, $firstanalyses[$modelid . '_' . $course2->id]); - // The cached elements gets refreshed. + // The cached elements get refreshed. $this->insert_used($modelid, $course1->id, 'prediction', $earliest); $firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache($modelid, $course1->id); $this->assertCount(1, $firstanalyses); @@ -78,7 +78,7 @@ public function test_fill_firstanalyses_cache() { // The generated ranges should start from the cached firstanalysis value, which is $earliest. $ranges = $seconds->get_all_ranges(); - $this->assertCount(7, $ranges); + $this->assertGreaterThanOrEqual(7, count($ranges)); $firstrange = reset($ranges); $this->assertEquals($earliest, $firstrange['time']); } diff --git a/analytics/tests/fixtures/test_analysis.php b/analytics/tests/fixtures/test_analysis.php index 6af04d1f2ea77..b4e85d2b032b0 100644 --- a/analytics/tests/fixtures/test_analysis.php +++ b/analytics/tests/fixtures/test_analysis.php @@ -41,7 +41,7 @@ class test_analysis extends \core_analytics\analysis { */ public function process_analysable(\core_analytics\analysable $analysable): array { // Half a second. - usleep(500000); + sleep(1); return parent::process_analysable($analysable); } } diff --git a/analytics/tests/fixtures/test_target_course_users.php b/analytics/tests/fixtures/test_target_course_users.php index 8907a3c5b7f56..7be0a87f6d918 100644 --- a/analytics/tests/fixtures/test_target_course_users.php +++ b/analytics/tests/fixtures/test_target_course_users.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -require_once(__DIR__ . '/test_target_shortname.php'); +require_once(__DIR__ . '/test_target_site_users.php'); /** * Test target. diff --git a/analytics/tests/manager_test.php b/analytics/tests/manager_test.php index 167d41dde0cbd..74e67216b636b 100644 --- a/analytics/tests/manager_test.php +++ b/analytics/tests/manager_test.php @@ -134,8 +134,9 @@ public function test_deleted_analysable() { $model->train(); $model->predict(); - $npredictsamples = $DB->count_records('analytics_predict_samples'); - $ntrainsamples = $DB->count_records('analytics_train_samples'); + $this->assertNotEmpty($DB->count_records('analytics_predict_samples')); + $this->assertNotEmpty($DB->count_records('analytics_train_samples')); + $this->assertNotEmpty($DB->count_records('analytics_used_analysables')); // Now we delete an analysable, stored predict and training samples should be deleted. $deletedcontext = \context_course::instance($coursepredict1->id); @@ -145,6 +146,7 @@ public function test_deleted_analysable() { $this->assertEmpty($DB->count_records('analytics_predict_samples', array('analysableid' => $coursepredict1->id))); $this->assertEmpty($DB->count_records('analytics_train_samples', array('analysableid' => $coursepredict1->id))); + $this->assertEmpty($DB->count_records('analytics_used_analysables', array('analysableid' => $coursepredict1->id))); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); diff --git a/analytics/tests/model_test.php b/analytics/tests/model_test.php index 18a4faca676fd..7d24b991e6e24 100644 --- a/analytics/tests/model_test.php +++ b/analytics/tests/model_test.php @@ -292,7 +292,7 @@ public function test_model_timelimit() { $this->resetAfterTest(true); - set_config('modeltimelimit', 1, 'analytics'); + set_config('modeltimelimit', 2, 'analytics'); $courses = array(); for ($i = 0; $i < 5; $i++) { @@ -494,6 +494,61 @@ public function test_get_name_and_rename() { $this->assertEquals($data->name['value'], ''); } + /** + * Tests model::get_samples() + * + * @return null + */ + public function test_get_samples() { + $this->resetAfterTest(); + + if (!PHPUNIT_LONGTEST) { + $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); + } + + // 10000 should be enough to make oracle and mssql fail, if we want pgsql to fail we need around 70000 + // users, that is a few minutes just to create the users. + $nusers = 10000; + + $userids = []; + for ($i = 0; $i < $nusers; $i++) { + $user = $this->getDataGenerator()->create_user(); + $userids[] = $user->id; + } + + $upcomingactivities = null; + foreach (\core_analytics\manager::get_all_models() as $model) { + if (get_class($model->get_target()) === 'core_user\\analytics\\target\\upcoming_activities_due') { + $upcomingactivities = $model; + } + } + + list($sampleids, $samplesdata) = $upcomingactivities->get_samples($userids); + $this->assertCount($nusers, $sampleids); + $this->assertCount($nusers, $samplesdata); + + $subset = array_slice($userids, 0, 100); + list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); + $this->assertCount(100, $sampleids); + $this->assertCount(100, $samplesdata); + + $subset = array_slice($userids, 0, 2); + list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); + $this->assertCount(2, $sampleids); + $this->assertCount(2, $samplesdata); + + $subset = array_slice($userids, 0, 1); + list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); + $this->assertCount(1, $sampleids); + $this->assertCount(1, $samplesdata); + + // Unexisting, so nothing returned, but still 2 arrays. + list($sampleids, $samplesdata) = $upcomingactivities->get_samples([1231231231231231]); + $this->assertEmpty($sampleids); + $this->assertEmpty($samplesdata); + + } + /** * Generates a model log record. */ diff --git a/analytics/tests/prediction_test.php b/analytics/tests/prediction_test.php index 1382c9789c9e9..7fbbdc631304f 100644 --- a/analytics/tests/prediction_test.php +++ b/analytics/tests/prediction_test.php @@ -436,6 +436,7 @@ public function provider_ml_classifiers_return() { /** * Basic test to check that prediction processors work as expected. * + * @coversNothing * @dataProvider provider_ml_test_evaluation_configuration * @param string $modelquality * @param int $ncourses @@ -477,6 +478,13 @@ public function test_ml_evaluation_configuration($modelquality, $ncourses, $expe $message = 'The returned status code ' . $result->status . ' should include ' . $expected[$timesplitting]; $filtered = $result->status & $expected[$timesplitting]; $this->assertEquals($expected[$timesplitting], $filtered, $message); + + $options = ['evaluation' => true, 'reuseprevanalysed' => true]; + $result = new \core_analytics\local\analysis\result_file($model->get_id(), true, $options); + $timesplittingobj = \core_analytics\manager::get_time_splitting($timesplitting); + $analysable = new \core_analytics\site(); + $cachedanalysis = $result->retrieve_cached_result($timesplittingobj, $analysable); + $this->assertInstanceOf(\stored_file::class, $cachedanalysis); } set_config('enabled_stores', '', 'tool_log'); @@ -486,6 +494,7 @@ public function test_ml_evaluation_configuration($modelquality, $ncourses, $expe /** * Tests the evaluation of already trained models. * + * @coversNothing * @dataProvider provider_ml_processors * @param string $predictionsprocessorclass * @return null diff --git a/auth/cas/auth.php b/auth/cas/auth.php index 56e5a7de9fd70..c29c9205e8127 100644 --- a/auth/cas/auth.php +++ b/auth/cas/auth.php @@ -365,13 +365,17 @@ public function loginpage_idp_list($wantsurl) { return []; } - $iconurl = moodle_url::make_pluginfile_url( - context_system::instance()->id, - 'auth_cas', - 'logo', - null, - '/', - $this->config->auth_logo); + if ($this->config->auth_logo) { + $iconurl = moodle_url::make_pluginfile_url( + context_system::instance()->id, + 'auth_cas', + 'logo', + null, + null, + $this->config->auth_logo); + } else { + $iconurl = null; + } return [ [ diff --git a/auth/cas/db/upgrade.php b/auth/cas/db/upgrade.php index 4df297c456f49..3d05e54e1e9df 100644 --- a/auth/cas/db/upgrade.php +++ b/auth/cas/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_cas_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/classes/output/login.php b/auth/classes/output/login.php index 7034e978f5178..c98048b932747 100644 --- a/auth/classes/output/login.php +++ b/auth/classes/output/login.php @@ -72,6 +72,8 @@ class login implements renderable, templatable { public $username; /** @var string The csrf token to limit login to requests that come from the login form. */ public $logintoken; + /** @var string Maintenance message, if Maintenance is enabled. */ + public $maintenance; /** * Constructor. @@ -109,6 +111,10 @@ public function __construct(array $authsequence, $username = '') { $this->instructions = get_string('loginsteps', 'core', 'signup.php'); } + if ($CFG->maintenance_enabled == true && !empty($CFG->maintenance_message)) { + $this->maintenance = $CFG->maintenance_message; + } + // Identity providers. $this->identityproviders = \auth_plugin_base::get_identity_providers($authsequence); $this->logintoken = \core\session\manager::get_login_token(); @@ -145,6 +151,7 @@ public function export_for_template(renderer_base $output) { $data->signupurl = $this->signupurl->out(false); $data->username = $this->username; $data->logintoken = $this->logintoken; + $data->maintenance = format_text($this->maintenance, FORMAT_MOODLE); return $data; } diff --git a/auth/db/db/upgrade.php b/auth/db/db/upgrade.php index f860220094323..ec2c80e2018b6 100644 --- a/auth/db/db/upgrade.php +++ b/auth/db/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_db_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/email/db/upgrade.php b/auth/email/db/upgrade.php index f636d6ff99fa2..d1b60496c3c4f 100644 --- a/auth/email/db/upgrade.php +++ b/auth/email/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_email_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/email/lang/en/auth_email.php b/auth/email/lang/en/auth_email.php index 596b4f7c82224..5920676109aee 100644 --- a/auth/email/lang/en/auth_email.php +++ b/auth/email/lang/en/auth_email.php @@ -24,7 +24,7 @@ $string['auth_emaildescription'] = '

    Email-based self-registration enables a user to create their own account via a \'Create new account\' button on the login page. The user then receives an email containing a secure link to a page where they can confirm their account. Future logins just check the username and password against the stored values in the Moodle database.

    Note: In addition to enabling the plugin, email-based self-registration must also be selected from the self registration drop-down menu on the \'Manage authentication\' page.

    '; $string['auth_emailnoemail'] = 'Tried to send you an email but failed!'; -$string['auth_emailrecaptcha'] = 'Adds a visual/audio confirmation form element to the sign-up page for email self-registering users. This protects your site against spammers and contributes to a worthwhile cause. See http://www.google.com/recaptcha for more details.'; +$string['auth_emailrecaptcha'] = 'Adds a visual/audio confirmation form element to the sign-up page for email self-registering users. This protects your site against spammers and contributes to a worthwhile cause. See https://www.google.com/recaptcha for more details.'; $string['auth_emailrecaptcha_key'] = 'Enable reCAPTCHA element'; $string['auth_emailsettings'] = 'Settings'; $string['pluginname'] = 'Email-based self-registration'; diff --git a/auth/ldap/db/upgrade.php b/auth/ldap/db/upgrade.php index 6c5725574d824..cf73d4c2675a1 100644 --- a/auth/ldap/db/upgrade.php +++ b/auth/ldap/db/upgrade.php @@ -69,5 +69,8 @@ function xmldb_auth_ldap_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/ldap/lang/en/auth_ldap.php b/auth/ldap/lang/en/auth_ldap.php index 6c84752555ce9..7a063ab171f97 100644 --- a/auth/ldap/lang/en/auth_ldap.php +++ b/auth/ldap/lang/en/auth_ldap.php @@ -94,7 +94,7 @@ $string['auth_ldap_version'] = 'The version of the LDAP protocol your server is using.'; $string['auth_ldap_version_key'] = 'Version'; $string['auth_ntlmsso'] = 'NTLM SSO'; -$string['auth_ntlmsso_enabled'] = 'Set to yes to attempt Single Sign On with the NTLM domain. Note: this requires additional setup on the webserver to work, see http://docs.moodle.org/en/NTLM_authentication'; +$string['auth_ntlmsso_enabled'] = 'Set to yes to attempt Single Sign On with the NTLM domain. Note that this requires additional setup on the server to work. For further details, see the documentation NTLM authentication.'; $string['auth_ntlmsso_enabled_key'] = 'Enable'; $string['auth_ntlmsso_ie_fastpath'] = 'Set to enable the NTLM SSO fast path (bypasses certain steps if the client\'s browser is MS Internet Explorer).'; $string['auth_ntlmsso_ie_fastpath_key'] = 'MS IE fast path?'; diff --git a/auth/manual/db/upgrade.php b/auth/manual/db/upgrade.php index 9209e9dcffdab..9f18bc79d3b07 100644 --- a/auth/manual/db/upgrade.php +++ b/auth/manual/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_manual_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/mnet/db/upgrade.php b/auth/mnet/db/upgrade.php index be29bcd6c88dc..e8eb8499fdf02 100644 --- a/auth/mnet/db/upgrade.php +++ b/auth/mnet/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_mnet_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/none/db/upgrade.php b/auth/none/db/upgrade.php index 2d5fc97003034..a051a323b464c 100644 --- a/auth/none/db/upgrade.php +++ b/auth/none/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_none_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/oauth2/classes/api.php b/auth/oauth2/classes/api.php index 1b152b58bb74c..e5b0789b5769a 100644 --- a/auth/oauth2/classes/api.php +++ b/auth/oauth2/classes/api.php @@ -402,7 +402,6 @@ public static function user_deleted(\core\event\user_deleted $event) { * @return bool */ public static function is_enabled() { - $plugininfo = \core_plugin_manager::instance()->get_plugin_info('auth_oauth2'); - return $plugininfo->is_enabled(); + return is_enabled_auth('oauth2'); } } diff --git a/auth/oauth2/classes/auth.php b/auth/oauth2/classes/auth.php index 448c00ea6c7e1..1d1ff6fc14c1f 100644 --- a/auth/oauth2/classes/auth.php +++ b/auth/oauth2/classes/auth.php @@ -36,6 +36,7 @@ require_once($CFG->libdir.'/authlib.php'); require_once($CFG->dirroot.'/user/lib.php'); +require_once($CFG->dirroot.'/user/profile/lib.php'); /** * Plugin for oauth2 authentication. diff --git a/auth/oauth2/db/upgrade.php b/auth/oauth2/db/upgrade.php index 7582cc9ce9d83..e68c4cded7453 100644 --- a/auth/oauth2/db/upgrade.php +++ b/auth/oauth2/db/upgrade.php @@ -47,5 +47,42 @@ function xmldb_auth_oauth2_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + + if ($oldversion < 2019052001) { + // Fetch Facebook, Google, and Microsoft issuers. We use the URL field to determine the issuer type as it's the only + // field that contains the keyword that can somewhat let us reliably determine the issuer type. + $likefacebook = $DB->sql_like('oe.url', ':facebook'); + $likegoogle = $DB->sql_like('oe.url', ':google'); + $likemicrosoft = $DB->sql_like('oe.url', ':microsoft'); + + $params = [ + 'facebook' => '%facebook%', + 'google' => '%google%', + 'microsoft' => '%microsoft%', + ]; + + // We're querying from the oauth2_endpoint table because the base URLs of FB and Microsoft can be empty in the issuer table. + $subsql = " + SELECT DISTINCT oe.issuerid + FROM {oauth2_endpoint} oe + WHERE $likefacebook + OR $likegoogle + OR $likemicrosoft"; + + // Update non-Facebook/Google/Microsoft issuers and set requireconfirmation to 1. + $updatesql = " + UPDATE {oauth2_issuer} + SET requireconfirmation = 1 + WHERE id NOT IN ({$subsql})"; + $DB->execute($updatesql, $params); + + // Delete linked logins for non-Facebook/Google/Microsoft issuers. They can easily re-link their logins anyway. + $DB->delete_records_select('auth_oauth2_linked_login', "issuerid NOT IN ($subsql)", $params); + + upgrade_plugin_savepoint(true, 2019052001, 'auth', 'oauth2'); + } + return true; } diff --git a/auth/oauth2/lang/en/auth_oauth2.php b/auth/oauth2/lang/en/auth_oauth2.php index b6fe4b6431a8a..c3a4246bc9bb3 100644 --- a/auth/oauth2/lang/en/auth_oauth2.php +++ b/auth/oauth2/lang/en/auth_oauth2.php @@ -40,7 +40,10 @@ line at the top of your web browser window. If you need help, please contact the site administrator, -{$a->admin}'; +{$a->admin} + +If you did not do this, someone else could be trying to compromise your account. +Please contact the site administrator immediately.'; $string['confirmaccountemailsubject'] = '{$a}: account confirmation'; $string['confirmationinvalid'] = 'The confirmation link is either invalid, or has expired. Please start the login process again to generate a new confirmation email.'; $string['confirmationpending'] = 'This account is pending email confirmation.'; @@ -60,7 +63,10 @@ line at the top of your web browser window. If you need help, please contact the site administrator, -{$a->admin}'; +{$a->admin} + +If you did not do this, someone else could be trying to compromise your account. +Please contact the site administrator immediately.'; $string['confirmlinkedloginemailsubject'] = '{$a}: linked login confirmation'; $string['createaccountswarning'] = 'This authentication plugin allows users to create accounts on your site. You may want to enable the setting "authpreventaccountcreation" if you use this plugin.'; $string['createnewlinkedlogin'] = 'Link a new account ({$a})'; diff --git a/auth/oauth2/tests/api_test.php b/auth/oauth2/tests/api_test.php index 83bf1a6c71adc..d2fc8e1fac0db 100644 --- a/auth/oauth2/tests/api_test.php +++ b/auth/oauth2/tests/api_test.php @@ -140,4 +140,23 @@ public function test_linked_logins() { $this->assertEquals($newuser->id, $match->get('userid')); } + /** + * Test that is_enabled correctly identifies when the plugin is enabled. + */ + public function test_is_enabled() { + $this->resetAfterTest(); + + set_config('auth', 'manual,oauth2'); + $this->assertTrue(\auth_oauth2\api::is_enabled()); + } + + /** + * Test that is_enabled correctly identifies when the plugin is disabled. + */ + public function test_is_enabled_disabled() { + $this->resetAfterTest(); + + set_config('auth', 'manual'); + $this->assertFalse(\auth_oauth2\api::is_enabled()); + } } diff --git a/auth/oauth2/version.php b/auth/oauth2/version.php index 7b6b11be872c7..5aea7174c11e4 100644 --- a/auth/oauth2/version.php +++ b/auth/oauth2/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019052000; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2019052001; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2019051100; // Requires this Moodle version. $plugin->component = 'auth_oauth2'; // Full name of the plugin (used for diagnostics). diff --git a/auth/shibboleth/auth.php b/auth/shibboleth/auth.php index c52ad129135b9..8bedda9d34cc8 100644 --- a/auth/shibboleth/auth.php +++ b/auth/shibboleth/auth.php @@ -263,7 +263,8 @@ public function test_settings() { global $OUTPUT; if (!isset($this->config->user_attribute) || empty($this->config->user_attribute)) { - echo $OUTPUT->notification(get_string("shib_not_set_up_error", "auth_shibboleth"), 'notifyproblem'); + echo $OUTPUT->notification(get_string("shib_not_set_up_error", "auth_shibboleth", + (new moodle_url('/auth/shibboleth/README.txt'))->out()), 'notifyproblem'); return; } if ($this->config->convert_data and $this->config->convert_data != '' and !is_readable($this->config->convert_data)) { @@ -294,12 +295,19 @@ public function loginpage_idp_list($wantsurl) { } $url = new moodle_url('/auth/shibboleth/index.php'); - $iconurl = moodle_url::make_pluginfile_url(context_system::instance()->id, - 'auth_shibboleth', - 'logo', - null, - '/', - $config->auth_logo); + + if ($config->auth_logo) { + $iconurl = moodle_url::make_pluginfile_url( + context_system::instance()->id, + 'auth_shibboleth', + 'logo', + null, + null, + $config->auth_logo); + } else { + $iconurl = null; + } + $result[] = ['url' => $url, 'iconurl' => $iconurl, 'name' => $config->login_name]; return $result; } diff --git a/auth/shibboleth/db/upgrade.php b/auth/shibboleth/db/upgrade.php index 640a02350ce06..d47ddad5f322a 100644 --- a/auth/shibboleth/db/upgrade.php +++ b/auth/shibboleth/db/upgrade.php @@ -51,5 +51,8 @@ function xmldb_auth_shibboleth_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/auth/shibboleth/index.php b/auth/shibboleth/index.php index b177f7c9b6233..0e0752c12cca9 100644 --- a/auth/shibboleth/index.php +++ b/auth/shibboleth/index.php @@ -32,8 +32,9 @@ $shibbolethauth = get_auth_plugin('shibboleth'); // Check whether Shibboleth is configured properly + $readmeurl = (new moodle_url('/auth/shibboleth/README.txt'))->out(); if (empty($pluginconfig->user_attribute)) { - print_error('shib_not_set_up_error', 'auth_shibboleth'); + print_error('shib_not_set_up_error', 'auth_shibboleth', '', $readmeurl); } /// If we can find the Shibboleth attribute, save it in session and return to main login page @@ -91,7 +92,7 @@ elseif (!empty($_SERVER['HTTP_SHIB_APPLICATION_ID']) || !empty($_SERVER['Shib-Application-ID'])) { print_error('shib_no_attributes_error', 'auth_shibboleth' , '', '\''.$pluginconfig->user_attribute.'\', \''.$pluginconfig->field_map_firstname.'\', \''.$pluginconfig->field_map_lastname.'\' and \''.$pluginconfig->field_map_email.'\''); } else { - print_error('shib_not_set_up_error', 'auth_shibboleth'); + print_error('shib_not_set_up_error', 'auth_shibboleth', '', $readmeurl); } diff --git a/auth/shibboleth/lang/en/auth_shibboleth.php b/auth/shibboleth/lang/en/auth_shibboleth.php index 77d6ec4b4e4cb..ca9b65b81ad5e 100644 --- a/auth/shibboleth/lang/en/auth_shibboleth.php +++ b/auth/shibboleth/lang/en/auth_shibboleth.php @@ -28,7 +28,7 @@ $string['auth_shib_auth_logo'] = 'Authentication method logo'; $string['auth_shib_auth_logo_description'] = 'Provide a logo for the Shibboleth authentication method that is familiar to your users. This could be the logo of your Shibboleth federation, e.g. SWITCHaai Login or InCommon Login or similar.'; $string['auth_shib_contact_administrator'] = 'In case you are not associated with the given organizations and you need access to a course on this server, please contact the Moodle Administrator.'; -$string['auth_shibbolethdescription'] = 'Using this method users are created and authenticated using Shibboleth.
    Be sure to read the README for Shibboleth on how to set up your Moodle with Shibboleth'; +$string['auth_shibbolethdescription'] = 'Using this method users are created and authenticated using Shibboleth. For set-up details, see the Shibboleth README.'; $string['auth_shibboleth_errormsg'] = 'Please select the organization you are member of!'; $string['auth_shibboleth_login'] = 'Shibboleth login'; $string['auth_shibboleth_login_long'] = 'Login to Moodle via Shibboleth'; @@ -36,7 +36,7 @@ $string['auth_shibboleth_select_member'] = 'I\'m a member of ...'; $string['auth_shibboleth_select_organization'] = 'For authentication via Shibboleth, please select your organisation from the drop-down menu:'; $string['auth_shib_convert_data'] = 'Data modification API'; -$string['auth_shib_convert_data_description'] = 'You can use this API to further modify the data provided by Shibboleth. Read the README for further instructions.'; +$string['auth_shib_convert_data_description'] = 'You can use this API to further modify the data provided by Shibboleth. Read the README for further instructions.'; $string['auth_shib_convert_data_warning'] = 'The file does not exist or is not readable by the webserver process!'; $string['auth_shib_changepasswordurl'] = 'Password-change URL'; $string['auth_shib_idp_list'] = 'Identity providers'; @@ -57,6 +57,6 @@ $string['shib_invalid_account_error'] = 'You seem to be Shibboleth authenticated but Moodle has no valid account for your username. Your account may not exist or it may have been suspended.'; $string['shib_no_attributes_error'] = 'You seem to be Shibboleth authenticated but Moodle didn\'t receive any user attributes. Please check that your Identity Provider releases the necessary attributes ({$a}) to the Service Provider Moodle is running on or inform the webmaster of this server.'; $string['shib_not_all_attributes_error'] = 'Moodle needs certain Shibboleth attributes which are not present in your case. The attributes are: {$a}
    Please contact the webmaster of this server or your Identity Provider.'; -$string['shib_not_set_up_error'] = 'Shibboleth authentication doesn\'t seem to be set up correctly because no Shibboleth environment variables are present for this page. Please consult the README for further instructions on how to set up Shibboleth authentication or contact the webmaster of this Moodle installation.'; +$string['shib_not_set_up_error'] = 'Shibboleth authentication doesn\'t seem to be set up correctly because no Shibboleth environment variables are present for this page. Please consult the README for further instructions on how to set up Shibboleth authentication or contact the webmaster of this Moodle installation.'; $string['pluginname'] = 'Shibboleth'; $string['privacy:metadata'] = 'The Shibboleth authentication plugin does not store any personal data.'; diff --git a/auth/shibboleth/settings.php b/auth/shibboleth/settings.php index e4b4c3a9f860a..86dce35a92793 100644 --- a/auth/shibboleth/settings.php +++ b/auth/shibboleth/settings.php @@ -30,8 +30,9 @@ require_once($CFG->dirroot.'/auth/shibboleth/classes/admin_setting_special_idp_configtextarea.php'); // Introductory explanation. + $readmeurl = (new moodle_url('/auth/shibboleth/README.txt'))->out(); $settings->add(new admin_setting_heading('auth_shibboleth/pluginname', '', - new lang_string('auth_shibbolethdescription', 'auth_shibboleth'))); + new lang_string('auth_shibbolethdescription', 'auth_shibboleth', $readmeurl))); // Username. $settings->add(new admin_setting_configtext('auth_shibboleth/user_attribute', get_string('username'), @@ -40,7 +41,7 @@ // COnvert Data configuration file. $settings->add(new admin_setting_configfile('auth_shibboleth/convert_data', get_string('auth_shib_convert_data', 'auth_shibboleth'), - get_string('auth_shib_convert_data_description', 'auth_shibboleth'), '')); + get_string('auth_shib_convert_data_description', 'auth_shibboleth', $readmeurl), '')); // WAYF. $settings->add(new auth_shibboleth_admin_setting_special_wayf_select()); diff --git a/auth/tests/behat/behat_auth.php b/auth/tests/behat/behat_auth.php index 30d1691a99e95..e7b65e581ff37 100644 --- a/auth/tests/behat/behat_auth.php +++ b/auth/tests/behat/behat_auth.php @@ -42,16 +42,23 @@ class behat_auth extends behat_base { * Logs in the user. There should exist a user with the same value as username and password. * * @Given /^I log in as "(?P(?:[^"]|\\")*)"$/ + * @param string $username the user to log in as. + * @param moodle_url|null $wantsurl optional, URL to go to after logging in. */ - public function i_log_in_as($username) { - // In the mobile app the required tasks are different. + public function i_log_in_as(string $username, moodle_url $wantsurl = null) { + // In the mobile app the required tasks are different (does not support $wantsurl). if ($this->is_in_app()) { $this->execute('behat_app::login', [$username]); return; } + $loginurl = new moodle_url('/login/index.php'); + if ($wantsurl !== null) { + $loginurl->param('wantsurl', $wantsurl->out_as_local_url()); + } + // Visit login page. - $this->getSession()->visit($this->locate_path('login/index.php')); + $this->getSession()->visit($this->locate_path($loginurl->out_as_local_url())); // Enter username and password. $this->execute('behat_forms::i_set_the_field_to', array('Username', $this->escape($username))); diff --git a/availability/condition/grouping/tests/behat/availability_grouping.feature b/availability/condition/grouping/tests/behat/availability_grouping.feature index 91f4d045d579d..2e242309b9ca5 100644 --- a/availability/condition/grouping/tests/behat/availability_grouping.feature +++ b/availability/condition/grouping/tests/behat/availability_grouping.feature @@ -92,3 +92,25 @@ Feature: availability_grouping # P1 should show but not B2. Then I should see "P1" in the "region-main" "region" And I should not see "P2" in the "region-main" "region" + + @javascript + Scenario: Check grouping access restriction message on course homepage + Given the following "groupings" exist: + | name | course | idnumber | + | Grouping A | C1 | GA | + And the following "grouping groups" exist: + | grouping | group | + | GA | GI1 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | groupmode | grouping | + | assign | Test assign | Assign description | C1 | assign1 | 1 | GA | + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I turn editing mode on + And I open "Test assign" actions menu + And I choose "Edit settings" in the open action menu + And I expand all fieldsets + And the field "groupingid" matches value "Grouping A" + And I press "Add group/grouping access restriction" + When I press "Save and return to course" + Then I should see "Not available unless: You belong to a group in Grouping A" diff --git a/backup/backup.php b/backup/backup.php index e0606d1b38460..7da76aa1ea8cb 100644 --- a/backup/backup.php +++ b/backup/backup.php @@ -123,6 +123,9 @@ if (!($bc = backup_ui::load_controller($backupid))) { $bc = new backup_controller($type, $id, backup::FORMAT_MOODLE, backup::INTERACTIVE_YES, $backupmode, $USER->id); + // The backup id did not relate to a valid controller so we made a new controller. + // Now we need to reset the backup id to match the new controller. + $backupid = $bc->get_backupid(); } // Prepare a progress bar which can display optionally during long-running diff --git a/backup/controller/restore_controller.class.php b/backup/controller/restore_controller.class.php index b709f7840ec8a..1f63e577cc3ee 100644 --- a/backup/controller/restore_controller.class.php +++ b/backup/controller/restore_controller.class.php @@ -332,6 +332,21 @@ public function get_executiontime() { public function get_plan() { return $this->plan; } + /** + * Gets the value for the requested setting + * + * @param string $name + * @param bool $default + * @return mixed + */ + public function get_setting_value($name, $default = false) { + try { + return $this->get_plan()->get_setting($name)->get_value(); + } catch (Exception $e) { + debugging('Failed to find the setting: '.$name, DEBUG_DEVELOPER); + return $default; + } + } public function get_info() { return $this->info; @@ -341,6 +356,14 @@ public function execute_plan() { // Basic/initial prevention against time/memory limits core_php_time_limit::raise(1 * 60 * 60); // 1 hour for 1 course initially granted raise_memory_limit(MEMORY_EXTRA); + + // Do course cleanup precheck, if required. This was originally in restore_ui. Moved to handle async backup/restore. + if ($this->get_target() == backup::TARGET_CURRENT_DELETING || $this->get_target() == backup::TARGET_EXISTING_DELETING) { + $options = array(); + $options['keep_roles_and_enrolments'] = $this->get_setting_value('keep_roles_and_enrolments'); + $options['keep_groups_and_groupings'] = $this->get_setting_value('keep_groups_and_groupings'); + restore_dbops::delete_course_content($this->get_courseid(), $options); + } // If this is not a course restore or single activity restore (e.g. duplicate), inform the plan we are not // including all the activities for sure. This will affect any // task/step executed conditionally to stop processing information diff --git a/backup/externallib.php b/backup/externallib.php index 98119ecaa4ee4..dd7091025a379 100644 --- a/backup/externallib.php +++ b/backup/externallib.php @@ -71,6 +71,9 @@ public static function get_async_backup_progress($backupids, $contextid) { require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + // Release session lock. + \core\session\manager::write_close(); + // Parameter validation. self::validate_parameters( self::get_async_backup_progress_parameters(), @@ -88,7 +91,6 @@ public static function get_async_backup_progress($backupids, $contextid) { require_capability('moodle/backup:backupactivity', $context); } else { require_capability('moodle/backup:backupcourse', $context); - $instanceid = $course->id; } $results = array(); @@ -142,6 +144,9 @@ public static function get_async_backup_links_backup_parameters() { * @since Moodle 3.7 */ public static function get_async_backup_links_backup($filename, $contextid) { + // Release session lock. + \core\session\manager::write_close(); + // Parameter validation. self::validate_parameters( self::get_async_backup_links_backup_parameters(), @@ -206,6 +211,9 @@ public static function get_async_backup_links_restore_parameters() { * @since Moodle 3.7 */ public static function get_async_backup_links_restore($backupid, $contextid) { + // Release session lock. + \core\session\manager::write_close(); + // Parameter validation. self::validate_parameters( self::get_async_backup_links_restore_parameters(), diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index fe699b3f6603d..683e90225bee5 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -1980,6 +1980,12 @@ protected function after_execute() { } $capability = 'mod/' . $modname . ':addinstance'; + + if (!get_capability_info($capability)) { + $this->log("Capability '{$capability}' was not found!", backup::LOG_WARNING); + continue; + } + foreach ($roleids as $roleid) { assign_capability($capability, CAP_PREVENT, $roleid, $context); } @@ -2107,9 +2113,12 @@ public function process_override($data) { $newroleid = $this->get_mappingid('role', $data->roleid); // If newroleid and context are valid assign it via API (it handles dupes and so on) if ($newroleid && $this->task->get_contextid()) { - // TODO: assign_capability() needs one userid param to be able to specify our restore userid - // TODO: it seems that assign_capability() doesn't check for valid capabilities at all ??? - assign_capability($data->capability, $data->permission, $newroleid, $this->task->get_contextid()); + if (!get_capability_info($data->capability)) { + $this->log("Capability '{$data->capability}' was not found!", backup::LOG_WARNING); + } else { + // TODO: assign_capability() needs one userid param to be able to specify our restore userid. + assign_capability($data->capability, $data->permission, $newroleid, $this->task->get_contextid()); + } } } } diff --git a/backup/restore.php b/backup/restore.php index 4ee134e0093ca..750a39883800b 100644 --- a/backup/restore.php +++ b/backup/restore.php @@ -55,9 +55,11 @@ 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 @@ \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 @@ 'restoreurl' => $restoreurl->out() ); echo $renderer->render_from_template('core/async_backup_status', $progresssetup); - } $restore->destroy(); diff --git a/backup/util/checks/backup_check.class.php b/backup/util/checks/backup_check.class.php index 164da0b38569a..7cd4c14af5455 100644 --- a/backup/util/checks/backup_check.class.php +++ b/backup/util/checks/backup_check.class.php @@ -128,15 +128,6 @@ public static function check_security($backup_controller, $apply) { // Now, if backup mode is hub or import, check userid has permissions for those modes // other modes will perform common checks only (backupxxxx capabilities in $typecapstocheck) switch ($mode) { - case backup::MODE_HUB: - if (!has_capability('moodle/backup:backuptargethub', $coursectx, $userid)) { - $a = new stdclass(); - $a->userid = $userid; - $a->courseid = $courseid; - $a->capability = 'moodle/backup:backuptargethub'; - throw new backup_controller_exception('backup_user_missing_capability', $a); - } - break; case backup::MODE_IMPORT: if (!has_capability('moodle/backup:backuptargetimport', $coursectx, $userid)) { $a = new stdclass(); diff --git a/backup/util/checks/restore_check.class.php b/backup/util/checks/restore_check.class.php index ee2b6cdbc9824..35fe2d9bf9b85 100644 --- a/backup/util/checks/restore_check.class.php +++ b/backup/util/checks/restore_check.class.php @@ -90,15 +90,6 @@ public static function check_security($restore_controller, $apply) { // Now, if restore mode is hub or import, check userid has permissions for those modes // other modes will perform common checks only (restorexxxx capabilities in $typecapstocheck) switch ($mode) { - case backup::MODE_HUB: - if (!has_capability('moodle/restore:restoretargethub', $coursectx, $userid)) { - $a = new stdclass(); - $a->userid = $userid; - $a->courseid = $courseid; - $a->capability = 'moodle/restore:restoretargethub'; - throw new restore_controller_exception('restore_user_missing_capability', $a); - } - break; case backup::MODE_IMPORT: if (!has_capability('moodle/restore:restoretargetimport', $coursectx, $userid)) { $a = new stdclass(); diff --git a/backup/util/ui/amd/build/async_backup.min.js b/backup/util/ui/amd/build/async_backup.min.js index 41384ff51353a..d999f4ac57a13 100644 --- a/backup/util/ui/amd/build/async_backup.min.js +++ b/backup/util/ui/amd/build/async_backup.min.js @@ -1 +1 @@ -define(["jquery","core/ajax","core/str","core/notification","core/templates"],function(a,b,c,d,e){function f(b,c){var d=Math.round(c)+"%",e=a("#"+b+"_bar"),f=c.toFixed(2)+"%";e.attr("aria-valuenow",d),e.css("width",d),e.text(f)}function g(c){var f=a("#"+c+"_bar").parent().parent(),g=f.parent(),h=f.siblings(),i=h[1],j=a(i).text(),k=h[0],l=a(k).text();b.call([{methodname:"core_backup_get_async_backup_links_backup",args:{filename:l,contextid:n}}])[0].done(function(a){var b={filename:l,time:j,size:a.filesize,fileurl:a.fileurl,restoreurl:a.restoreurl};e.render("core/async_backup_progress_row",b).then(function(a,b){e.replaceNodeContents(g,a,b)}).fail(function(){d.exception(new Error("Failed to load table row"))})})}function h(c){var f=a("#"+c+"_bar").parent().parent(),g=f.parent(),h=f.siblings(),i=h[0],j=h[1],k=a(j).text();b.call([{methodname:"core_backup_get_async_backup_links_restore",args:{backupid:c,contextid:n}}])[0].done(function(b){var c=a(i).text(),f={resourcename:c,restoreurl:b.restoreurl,time:k};e.render("core/async_restore_progress_row",f).then(function(a,b){e.replaceNodeContents(g,a,b)}).fail(function(){d.exception(new Error("Failed to load table row"))})})}function i(e){var g,h=100*e.progress,i=a("#"+m+"_bar"),j=a("#"+m+"_status"),k=a("#"+m+"_detail"),l=a("#"+m+"_button");if(e.status==s){i.addClass("bg-success"),f(m,h);var r="async"+p+"processing";c.get_string(r,"backup").then(function(a){return j.text(a),a})["catch"](function(){d.exception(new Error("Failed to load string: backup "+r))})}else if(e.status==t){i.addClass("bg-danger"),i.removeClass("bg-success"),f(m,100);var v="async"+p+"error",w="async"+p+"errordetail";g=[{key:v,component:"backup"},{key:w,component:"backup"}],c.get_strings(g).then(function(a){return j.text(a[0]),k.text(a[1]),a})["catch"](function(){d.exception(new Error("Failed to load string"))}),a(".backup_progress").children("span").removeClass("backup_stage_current"),a(".backup_progress").children("span").last().addClass("backup_stage_current"),clearInterval(q)}else if(e.status==u){i.addClass("bg-success"),f(m,100);var x="async"+p+"complete";if(c.get_string(x,"backup").then(function(a){return j.text(a),a})["catch"](function(){d.exception(new Error("Failed to load string: backup "+x))}),"restore"==p)b.call([{methodname:"core_backup_get_async_backup_links_restore",args:{backupid:m,contextid:n}}])[0].done(function(a){var b="async"+p+"completedetail",e="async"+p+"completebutton",f=[{key:b,component:"backup",param:a.restoreurl},{key:e,component:"backup"}];c.get_strings(f).then(function(b){return k.html(b[0]),l.text(b[1]),l.attr("href",a.restoreurl),b})["catch"](function(){d.exception(new Error("Failed to load string"))})});else{var y="async"+p+"completedetail",z="async"+p+"completebutton";g=[{key:y,component:"backup",param:o},{key:z,component:"backup"}],c.get_strings(g).then(function(a){return k.html(a[0]),l.text(a[1]),l.attr("href",o),a})["catch"](function(){d.exception(new Error("Failed to load string"))})}a(".backup_progress").children("span").removeClass("backup_stage_current"),a(".backup_progress").children("span").last().addClass("backup_stage_current"),clearInterval(q)}}function j(b){b.forEach(function(b){var c=100*b.progress,d=b.backupid,e=a("#"+d+"_bar"),i=b.operation;b.status==s?(e.addClass("bg-success"),f(d,c)):b.status==t?(e.addClass("bg-danger"),e.addClass("complete"),a("#"+d+"_bar").removeClass("bg-success"),f(d,100)):b.status==u&&(e.addClass("bg-success"),e.addClass("complete"),f(d,100),"backup"==i?g(d):h(d))})}function k(){b.call([{methodname:"core_backup_get_async_backup_progress",args:{backupids:[m],contextid:n}}])[0].done(function(a){i(a[0])})}function l(){var c=[],d=a(".progress").find(".progress-bar").not(".complete");d.each(function(){c.push(this.id.substring(0,32))}),c.length>0?b.call([{methodname:"core_backup_get_async_backup_progress",args:{backupids:c,contextid:n}}])[0].done(function(a){j(a)}):clearInterval(r)}var m,n,o,p,q,r,s=800,t=900,u=1e3,v={},w=5e3;return v.asyncBackupAllStatus=function(a){n=a,r=setInterval(l,w)},v.asyncBackupStatus=function(b,c,d,e){m=b,n=c,o=d,p="backup"==e?"backup":"restore",a(".backup_progress").children("a").removeAttr("href"),q=setInterval(k,w)},v}); \ No newline at end of file +define(["jquery","core/ajax","core/str","core/notification","core/templates"],function(a,b,c,d,e){function f(b,c){var d=Math.round(c)+"%",e=a("#"+b+"_bar"),f=c.toFixed(2)+"%";e.attr("aria-valuenow",d),e.css("width",d),e.text(f)}function g(c){var f=a("#"+c+"_bar").parent().parent(),g=f.parent(),h=f.siblings(),i=h[1],j=a(i).text(),k=h[0],l=a(k).text();b.call([{methodname:"core_backup_get_async_backup_links_backup",args:{filename:l,contextid:n}}])[0].done(function(a){var b={filename:l,time:j,size:a.filesize,fileurl:a.fileurl,restoreurl:a.restoreurl};e.render("core/async_backup_progress_row",b).then(function(a,b){e.replaceNodeContents(g,a,b)}).fail(function(){d.exception(new Error("Failed to load table row"))})})}function h(c){var f=a("#"+c+"_bar").parent().parent(),g=f.parent(),h=f.siblings(),i=h[0],j=h[1],k=a(j).text();b.call([{methodname:"core_backup_get_async_backup_links_restore",args:{backupid:c,contextid:n}}])[0].done(function(b){var c=a(i).text(),f={resourcename:c,restoreurl:b.restoreurl,time:k};e.render("core/async_restore_progress_row",f).then(function(a,b){e.replaceNodeContents(g,a,b)}).fail(function(){d.exception(new Error("Failed to load table row"))})})}function i(e){var g,h=100*e.progress,i=a("#"+m+"_bar"),j=a("#"+m+"_status"),k=a("#"+m+"_detail"),l=a("#"+m+"_button");if(e.status==s){i.addClass("bg-success"),f(m,h);var r="async"+p+"processing";c.get_string(r,"backup").then(function(a){return j.text(a),a})["catch"](function(){d.exception(new Error("Failed to load string: backup "+r))})}else if(e.status==t){i.addClass("bg-danger"),i.removeClass("bg-success"),f(m,100);var v="async"+p+"error",w="async"+p+"errordetail";g=[{key:v,component:"backup"},{key:w,component:"backup"}],c.get_strings(g).then(function(a){return j.text(a[0]),k.text(a[1]),a})["catch"](function(){d.exception(new Error("Failed to load string"))}),a(".backup_progress").children("span").removeClass("backup_stage_current"),a(".backup_progress").children("span").last().addClass("backup_stage_current"),clearInterval(q)}else if(e.status==u){i.addClass("bg-success"),f(m,100);var x="async"+p+"complete";if(c.get_string(x,"backup").then(function(a){return j.text(a),a})["catch"](function(){d.exception(new Error("Failed to load string: backup "+x))}),"restore"==p)b.call([{methodname:"core_backup_get_async_backup_links_restore",args:{backupid:m,contextid:n}}])[0].done(function(a){var b="async"+p+"completedetail",e="async"+p+"completebutton",f=[{key:b,component:"backup",param:a.restoreurl},{key:e,component:"backup"}];c.get_strings(f).then(function(b){return k.html(b[0]),l.text(b[1]),l.attr("href",a.restoreurl),b})["catch"](function(){d.exception(new Error("Failed to load string"))})});else{var y="async"+p+"completedetail",z="async"+p+"completebutton";g=[{key:y,component:"backup",param:o},{key:z,component:"backup"}],c.get_strings(g).then(function(a){return k.html(a[0]),l.text(a[1]),l.attr("href",o),a})["catch"](function(){d.exception(new Error("Failed to load string"))})}a(".backup_progress").children("span").removeClass("backup_stage_current"),a(".backup_progress").children("span").last().addClass("backup_stage_current"),clearInterval(q)}}function j(b){b.forEach(function(b){var c=100*b.progress,d=b.backupid,e=a("#"+d+"_bar"),i=b.operation;b.status==s?(e.addClass("bg-success"),f(d,c)):b.status==t?(e.addClass("bg-danger"),e.addClass("complete"),a("#"+d+"_bar").removeClass("bg-success"),f(d,100)):b.status==u&&(e.addClass("bg-success"),e.addClass("complete"),f(d,100),"backup"==i?g(d):h(d))})}function k(){b.call([{methodname:"core_backup_get_async_backup_progress",args:{backupids:[m],contextid:n}}])[0].done(function(a){i(a[0])})}function l(){var c=[],d=a(".progress").find(".progress-bar").not(".complete");d.each(function(){c.push(this.id.substring(0,32))}),c.length>0?b.call([{methodname:"core_backup_get_async_backup_progress",args:{backupids:c,contextid:n}}])[0].done(function(a){j(a)}):clearInterval(r)}var m,n,o,p,q,r,s=800,t=900,u=1e3,v={},w=15e3;return v.asyncBackupAllStatus=function(a){n=a,r=setInterval(l,w)},v.asyncBackupStatus=function(b,c,d,e){m=b,n=c,o=d,p="backup"==e?"backup":"restore",a(".backup_progress").children("a").removeAttr("href"),q=setInterval(k,w)},v}); \ No newline at end of file diff --git a/backup/util/ui/amd/src/async_backup.js b/backup/util/ui/amd/src/async_backup.js index 0563fa8239d63..ff5214a35b8e9 100644 --- a/backup/util/ui/amd/src/async_backup.js +++ b/backup/util/ui/amd/src/async_backup.js @@ -39,7 +39,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates' * Module level variables. */ var Asyncbackup = {}; - var checkdelay = 5000; // How often we check for progress updates. + var checkdelay = 15000; // How often we check for progress updates. var backupid; // The backup id to get the progress for. var contextid; // The course this backup progress is for. var restoreurl; // The URL to view course restores. diff --git a/backup/util/ui/renderer.php b/backup/util/ui/renderer.php index 4ee83e966f5da..af6beefc3a3e4 100644 --- a/backup/util/ui/renderer.php +++ b/backup/util/ui/renderer.php @@ -381,8 +381,10 @@ public function import_course_selector(moodle_url $nextstageurl, import_course_s $html .= $this->output->heading(get_string('importdatafrom'), 2, array('class' => 'header')); $html .= $this->backup_detail_pair(get_string('selectacourse', 'backup'), $this->render($courses)); $attrs = array('type' => 'submit', 'value' => get_string('continue'), 'class' => 'btn btn-primary'); + $html .= html_writer::start_tag('div', array('class' => 'mt-3')); $html .= $this->backup_detail_pair('', html_writer::empty_tag('input', $attrs)); $html .= html_writer::end_tag('div'); + $html .= html_writer::end_tag('div'); $html .= html_writer::end_tag('form'); $html .= html_writer::end_tag('div'); return $html; @@ -764,7 +766,7 @@ public function render_import_course_search(import_course_search $component) { if ($component->get_count() === 0) { $output .= $this->output->notification(get_string('nomatchingcourses', 'backup')); - $output .= html_writer::start_tag('div', array('class' => 'ics-search')); + $output .= html_writer::start_tag('div', array('class' => 'ics-search form-inline')); $attrs = array( 'type' => 'text', 'name' => restore_course_search::$VAR_SEARCH, @@ -776,7 +778,7 @@ public function render_import_course_search(import_course_search $component) { 'type' => 'submit', 'name' => 'searchcourses', 'value' => get_string('search'), - 'class' => 'btn btn-secondary' + 'class' => 'btn btn-secondary ml-1' ); $output .= html_writer::empty_tag('input', $attrs); $output .= html_writer::end_tag('div'); @@ -822,7 +824,7 @@ public function render_import_course_search(import_course_search $component) { $output .= html_writer::table($table); $output .= html_writer::end_tag('div'); - $output .= html_writer::start_tag('div', array('class' => 'ics-search')); + $output .= html_writer::start_tag('div', array('class' => 'ics-search form-inline')); $attrs = array( 'type' => 'text', 'name' => restore_course_search::$VAR_SEARCH, @@ -833,7 +835,7 @@ public function render_import_course_search(import_course_search $component) { 'type' => 'submit', 'name' => 'searchcourses', 'value' => get_string('search'), - 'class' => 'btn btn-secondary' + 'class' => 'btn btn-secondary ml-1' ); $output .= html_writer::empty_tag('input', $attrs); $output .= html_writer::end_tag('div'); diff --git a/backup/util/ui/restore_ui.class.php b/backup/util/ui/restore_ui.class.php index 7c0de1bc27bf2..98a105704a385 100644 --- a/backup/util/ui/restore_ui.class.php +++ b/backup/util/ui/restore_ui.class.php @@ -215,12 +215,7 @@ public function execute() { if ($this->stage->get_stage() < self::STAGE_PROCESS) { throw new restore_ui_exception('restoreuifinalisedbeforeexecute'); } - if ($this->controller->get_target() == backup::TARGET_CURRENT_DELETING || $this->controller->get_target() == backup::TARGET_EXISTING_DELETING) { - $options = array(); - $options['keep_roles_and_enrolments'] = $this->get_setting_value('keep_roles_and_enrolments'); - $options['keep_groups_and_groupings'] = $this->get_setting_value('keep_groups_and_groupings'); - restore_dbops::delete_course_content($this->controller->get_courseid(), $options); - } + $this->controller->execute_plan(); $this->progress = self::PROGRESS_EXECUTED; $this->stage = new restore_ui_stage_complete($this, $this->stage->get_params(), $this->controller->get_results()); diff --git a/badges/assertion.php b/badges/assertion.php index ec08d0faf5fb4..0c41062851e2c 100644 --- a/badges/assertion.php +++ b/badges/assertion.php @@ -36,7 +36,8 @@ $hash = required_param('b', PARAM_ALPHANUM); // Issued badge unique hash for badge assertion. $action = optional_param('action', null, PARAM_BOOL); // Generates badge class if true. -$obversion = optional_param('obversion', OPEN_BADGES_V1, PARAM_INT); // For control format OB specification version. +// OB specification version. If it's not defined, the site will be used as default. +$obversion = optional_param('obversion', badges_open_badges_backpack_api(), PARAM_INT); $assertion = new core_badges_assertion($hash, $obversion); diff --git a/badges/classes/assertion.php b/badges/classes/assertion.php index 9525019849870..872495eab84ca 100644 --- a/badges/classes/assertion.php +++ b/badges/classes/assertion.php @@ -127,7 +127,12 @@ public function get_badge_assertion($issued = true, $usesalt = true) { $hash = $this->_data->uniquehash; $email = empty($this->_data->backpackemail) ? $this->_data->email : $this->_data->backpackemail; $assertionurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'obversion' => $this->_obversion)); - $classurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'action' => 1)); + + if ($this->_obversion == OPEN_BADGES_V2) { + $classurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id())); + } else { + $classurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'action' => 1)); + } // Required. $assertion['uid'] = $hash; @@ -193,7 +198,11 @@ public function get_badge_class($issued = true) { $class['image'] = 'data:image/png;base64,' . $imagedata; $class['criteria'] = $this->_url->out(false); // Currently issued badge URL. if ($issued) { - $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0)); + if ($this->_obversion == OPEN_BADGES_V2) { + $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0)); + } else { + $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0)); + } $class['issuer'] = $issuerurl->out(false); } $this->embed_data_badge_version2($class, OPEN_BADGES_V2_TYPE_BADGE); @@ -329,10 +338,10 @@ protected function embed_data_badge_version2 (&$json, $type = OPEN_BADGES_V2_TYP $hash = $this->_data->uniquehash; $assertionsurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'obversion' => $this->_obversion)); $classurl = new moodle_url( - '/badges/assertion.php', - array('b' => $hash, 'action' => 1, 'obversion' => $this->_obversion) + '/badges/badge_json.php', + array('id' => $this->get_badge_id()) ); - $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0, + $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0, 'obversion' => $this->_obversion)); // For assertion. if ($type == OPEN_BADGES_V2_TYPE_ASSERTION) { diff --git a/badges/classes/form/badge.php b/badges/classes/form/badge.php index f6d5e09d501df..faa64a651e93f 100644 --- a/badges/classes/form/badge.php +++ b/badges/classes/form/badge.php @@ -49,7 +49,6 @@ public function definition() { $mform = $this->_form; $badge = (isset($this->_customdata['badge'])) ? $this->_customdata['badge'] : false; $action = $this->_customdata['action']; - $languages = get_string_manager()->get_list_of_languages(); $mform->addElement('header', 'badgedetails', get_string('badgedetails', 'badges')); $mform->addElement('text', 'name', get_string('name'), array('size' => '70')); @@ -61,6 +60,8 @@ public function definition() { $mform->addElement('text', 'version', get_string('version', 'badges'), array('size' => '70')); $mform->setType('version', PARAM_TEXT); $mform->addHelpButton('version', 'version', 'badges'); + + $languages = get_string_manager()->get_list_of_languages(); $mform->addElement('select', 'language', get_string('language'), $languages); $mform->addHelpButton('language', 'language', 'badges'); @@ -157,7 +158,16 @@ public function definition() { $mform->setType('action', PARAM_TEXT); if ($action == 'new') { - $mform->setDefault('language', $CFG->lang); + // Try to set default badge language to that of current language, or it's parent. + $language = current_language(); + if (isset($languages[$language])) { + $defaultlanguage = $language; + } else { + // Calling get_parent_language returns an empty string instead of 'en'. + $defaultlanguage = get_parent_language($language) ?: 'en'; + } + + $mform->setDefault('language', $defaultlanguage); $this->add_action_buttons(true, get_string('createbutton', 'badges')); } else { // Add hidden fields. diff --git a/badges/classes/form/collections.php b/badges/classes/form/collections.php index 6f221b79e0adb..5a57ba2b426d1 100644 --- a/badges/classes/form/collections.php +++ b/badges/classes/form/collections.php @@ -79,24 +79,25 @@ public function definition() { $hasgroups = false; if (!empty($groups)) { foreach ($groups as $group) { - // Assertions or badges. $count = 0; - + // Handle attributes based on backpack's supported version. if ($sitebackpack->apiversion == OPEN_BADGES_V2) { + // OpenBadges v2 data attributes. if (empty($group->published)) { // Only public collections. continue; } - } - if (!empty($group->assertions)) { + + // Get the number of badges associated with this collection from the assertions array returned. $count = count($group->assertions); - } - if (!empty($group->badges)) { - $count = count($group->badges); - } - if (!empty($group->groupId)) { + } else { + // OpenBadges v1 data attributes. $group->entityId = $group->groupId; + + // Get the number of badges associated with this collection. In that case, the number is returned directly. + $count = $group->badges; } + if (!$hasgroups) { $mform->addElement('static', 'selectgroup', '', get_string('selectgroup_start', 'badges')); } diff --git a/badges/classes/output/issued_badge.php b/badges/classes/output/issued_badge.php index b669a19415dd9..bc70d3bbbf92d 100644 --- a/badges/classes/output/issued_badge.php +++ b/badges/classes/output/issued_badge.php @@ -54,6 +54,9 @@ class issued_badge implements renderable { /** @var badge class */ public $badgeid = 0; + /** @var unique hash identifying the issued badge */ + public $hash; + /** * Initializes the badge to display * @@ -62,7 +65,8 @@ class issued_badge implements renderable { public function __construct($hash) { global $DB; - $assertion = new \core_badges_assertion($hash); + $this->hash = $hash; + $assertion = new \core_badges_assertion($hash, badges_open_badges_backpack_api()); $this->issued = $assertion->get_badge_assertion(); $this->badgeclass = $assertion->get_badge_class(); diff --git a/badges/criteria/award_criteria_badge.php b/badges/criteria/award_criteria_badge.php index d9bd7957dc459..ebddecf818026 100644 --- a/badges/criteria/award_criteria_badge.php +++ b/badges/criteria/award_criteria_badge.php @@ -112,7 +112,7 @@ public function get_options(&$mform) { if ($this->id !== 0) { $selected = array_keys($this->params); } - $settings = array('multiple' => 'multiple', 'size' => 20, 'class' => 'selectbadge'); + $settings = array('multiple' => 'multiple', 'size' => 20, 'class' => 'selectbadge', 'required' => 'required'); $mform->addElement('select', 'badge_badges', get_string('addbadge', 'badges'), $select, $settings); $mform->addRule('badge_badges', get_string('requiredbadge', 'badges'), 'required'); $mform->addHelpButton('badge_badges', 'addbadge', 'badges'); @@ -243,7 +243,6 @@ public function get_completed_criteria_sql() { if ($this->method == BADGE_CRITERIA_AGGREGATION_ANY) { // User has received ANY of the required badges. $join = " LEFT JOIN {badge_issued} bi2 ON bi2.userid = u.id"; - $where = "AND ("; $i = 0; foreach ($this->params as $param) { if ($i == 0) { @@ -254,7 +253,10 @@ public function get_completed_criteria_sql() { $params['badgeid'.$i] = $param['badge']; $i++; } - $where .= ") "; + // MDL-66032 Do not create expression if there are no badges in criteria. + if (!empty($where)) { + $where = ' AND (' . $where . ') '; + } return array($join, $where, $params); } else { // User has received ALL of the required badges. diff --git a/badges/lib/bakerlib.php b/badges/lib/bakerlib.php index 9507bfa44c12c..23fbcc8018677 100644 --- a/badges/lib/bakerlib.php +++ b/badges/lib/bakerlib.php @@ -114,23 +114,59 @@ public function check_chunks($type, $check) { * @param string $value Currently an assertion URL that is added to an image metadata. * * @return string $result File content with a new chunk as a string. Can be used in file_put_contents() to write to a file. + * @throws \moodle_exception when unsupported chunk type is defined. */ public function add_chunks($type, $key, $value) { if (strlen($key) > 79) { debugging('Key is too big'); } - // tEXt Textual data. - // Keyword: 1-79 bytes (character string) - // Null separator: 1 byte - // Text: n bytes (character string) - $data = $key . "\0" . $value; + $dataparts = []; + if ($type === 'iTXt') { + // International textual data (iTXt). + // Keyword: 1-79 bytes (character string). + $dataparts[] = $key; + // Null separator: 1 byte. + $dataparts[] = "\x00"; + // Compression flag: 1 byte + // A value of 0 means no compression. + $dataparts[] = "\x00"; + // Compression method: 1 byte + // If compression is disabled, the method should also be 0. + $dataparts[] = "\x00"; + // Language tag: 0 or more bytes (character string) + // When there is no language specified leave empty. + + // Null separator: 1 byte. + $dataparts[] = "\x00"; + // Translated keyword: 0 or more bytes + // When there is no translation specified, leave empty. + + // Null separator: 1 byte. + $dataparts[] = "\x00"; + // Text: 0 or more bytes. + $dataparts[] = $value; + } else if ($type === 'tEXt') { + // Textual data (tEXt). + // Keyword: 1-79 bytes (character string). + $dataparts[] = $key; + // Null separator: 1 byte. + $dataparts[] = "\0"; + // Text: n bytes (character string). + $dataparts[] = $value; + } else { + throw new \moodle_exception('Unsupported chunk type: ' . $type); + } + + $data = implode($dataparts); + $crc = pack("N", crc32($type . $data)); $len = pack("N", strlen($data)); // Chunk format: length + type + data + CRC. // CRC is a CRC-32 computed over the chunk type and chunk data. $newchunk = $len . $type . $data . $crc; + $this->_chunks[$type] = $data; $result = substr($this->_contents, 0, $this->_size - 12) . $newchunk diff --git a/badges/renderer.php b/badges/renderer.php index 7e86ba2b9547e..341ef6dbf3421 100644 --- a/badges/renderer.php +++ b/badges/renderer.php @@ -337,13 +337,13 @@ protected function render_issued_badge(\core_badges\output\issued_badge $ibadge) if ($USER->id == $userinfo->id && !empty($CFG->enablebadges)) { $output .= $this->output->single_button( - new moodle_url('/badges/badge.php', array('hash' => $issued['uid'], 'bake' => true)), + new moodle_url('/badges/badge.php', array('hash' => $ibadge->hash, 'bake' => true)), get_string('download'), 'POST'); if (!empty($CFG->badges_allowexternalbackpack) && ($expiration > $now) && badges_user_has_backpack($USER->id)) { if (badges_open_badges_backpack_api() == OPEN_BADGES_V1) { - $assertion = new moodle_url('/badges/assertion.php', array('b' => $issued['uid'])); + $assertion = new moodle_url('/badges/assertion.php', array('b' => $ibadge->hash)); $action = new component_action('click', 'addtobackpack', array('assertion' => $assertion->out(false))); $attributes = array( 'type' => 'button', @@ -354,7 +354,7 @@ protected function render_issued_badge(\core_badges\output\issued_badge $ibadge) $this->output->add_action_handler($action, 'addbutton'); $output .= $tobackpack; } else { - $assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $issued['uid'])); + $assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $ibadge->hash)); $attributes = ['class' => 'btn btn-secondary m-1', 'role' => 'button']; $tobackpack = html_writer::link($assertion, get_string('addtobackpack', 'badges'), $attributes); $output .= $tobackpack; @@ -420,12 +420,14 @@ protected function render_issued_badge(\core_badges\output\issued_badge $ibadge) $output .= $this->output->heading(get_string('issuancedetails', 'badges'), 3); $dl = array(); - $issued['issuedOn'] = !preg_match( '~^[1-9][0-9]*$~', $issued['issuedOn'] ) ? - strtotime($issued['issuedOn']) : $issued['issuedOn']; + if (!is_numeric($issued['issuedOn'])) { + $issued['issuedOn'] = strtotime($issued['issuedOn']); + } $dl[get_string('dateawarded', 'badges')] = userdate($issued['issuedOn']); if (isset($issued['expires'])) { - $issued['expires'] = !preg_match( '~^[1-9][0-9]*$~', $issued['expires'] ) ? - strtotime($issued['expires']) : $issued['expires']; + if (!is_numeric($issued['expires'])) { + $issued['expires'] = strtotime($issued['expires']); + } if ($issued['expires'] < $now) { $dl[get_string('expirydate', 'badges')] = userdate($issued['expires']) . get_string('warnexpired', 'badges'); @@ -508,7 +510,7 @@ protected function render_external_badge(\core_badges\output\external_badge $iba } $output .= html_writer::empty_tag('img', array('src' => $issued->image, 'width' => '100')); if (isset($assertion->expires)) { - $expiration = !strtotime($assertion->expires) ? s($assertion->expires) : strtotime($assertion->expires); + $expiration = is_numeric($assertion->expires) ? $assertion->expires : strtotime($assertion->expires); if ($expiration < $today) { $output .= $this->output->pix_icon('i/expired', get_string('expireddate', 'badges', userdate($expiration)), @@ -564,7 +566,7 @@ protected function render_external_badge(\core_badges\output\external_badge $iba $dl = array(); if (isset($assertion->issued_on)) { - $issuedate = !strtotime($assertion->issued_on) ? s($assertion->issued_on) : strtotime($assertion->issued_on); + $issuedate = is_numeric($assertion->issued_on) ? $assertion->issued_on : strtotime($assertion->issued_on); $dl[get_string('dateawarded', 'badges')] = userdate($issuedate); } if (isset($assertion->expires)) { diff --git a/badges/upgradelib.php b/badges/upgradelib.php new file mode 100644 index 0000000000000..d1acd8ba05b3b --- /dev/null +++ b/badges/upgradelib.php @@ -0,0 +1,65 @@ +. + +/** + * Contains upgrade and install functions for badges. + * + * @package core_badges + * @copyright 2019 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Called on install or upgrade to create default list of backpacks a user can connect to. + * Don't use the global defines from badgeslib because this is for install/upgrade. + * + * @return void + */ +function badges_install_default_backpacks() { + global $DB; + + $record = new stdClass(); + $record->backpackweburl = 'https://backpack.openbadges.org'; + $record->backpackapiurl = 'https://backpack.openbadges.org'; + $record->apiversion = 1; + $record->sortorder = 0; + $record->password = ''; + + if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) { + $bpid = $DB->insert_record('badge_external_backpack', $record); + } else { + $bpid = $bp->id; + } + set_config('badges_site_backpack', $bpid); + + // All existing backpacks default to V1. + $DB->set_field('badge_backpack', 'externalbackpackid', $bpid); + + $record = new stdClass(); + $record->backpackapiurl = 'https://api.badgr.io/v2'; + $record->backpackweburl = 'https://badgr.io'; + $record->apiversion = 2; + $record->sortorder = 1; + $record->password = ''; + + if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) { + $DB->insert_record('badge_external_backpack', $record); + } + +} + diff --git a/blocks/badges/db/upgrade.php b/blocks/badges/db/upgrade.php index 6948646ed2df0..f1f069a661488 100644 --- a/blocks/badges/db/upgrade.php +++ b/blocks/badges/db/upgrade.php @@ -57,5 +57,8 @@ function xmldb_block_badges_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/calendar_month/db/upgrade.php b/blocks/calendar_month/db/upgrade.php index 32051277b3baf..58d654659b249 100644 --- a/blocks/calendar_month/db/upgrade.php +++ b/blocks/calendar_month/db/upgrade.php @@ -57,5 +57,8 @@ function xmldb_block_calendar_month_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/calendar_upcoming/db/upgrade.php b/blocks/calendar_upcoming/db/upgrade.php index 14100c83782ef..69a5774fa02cc 100644 --- a/blocks/calendar_upcoming/db/upgrade.php +++ b/blocks/calendar_upcoming/db/upgrade.php @@ -57,5 +57,8 @@ function xmldb_block_calendar_upcoming_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/community/block_community.php b/blocks/community/block_community.php index fc345caa52115..ab9db50d6b5c0 100644 --- a/blocks/community/block_community.php +++ b/blocks/community/block_community.php @@ -48,54 +48,11 @@ function user_can_edit() { } function get_content() { - global $CFG, $OUTPUT, $USER; - - $coursecontext = context::instance_by_id($this->instance->parentcontextid); - - if (!has_capability('moodle/community:add', $coursecontext) - or $this->content !== NULL) { - return $this->content; - } - $this->content = new stdClass(); $this->content->items = array(); $this->content->icons = array(); $this->content->footer = ''; - - if (!isloggedin()) { - return $this->content; - } - - $icon = $OUTPUT->pix_icon('i/group', get_string('group')); - $addcourseurl = new moodle_url('/blocks/community/communitycourse.php', - array('add' => true, 'courseid' => $this->page->course->id)); - $searchlink = html_writer::tag('a', $icon . get_string('addcourse', 'block_community'), - array('href' => $addcourseurl->out(false))); - $this->content->items[] = $searchlink; - - require_once($CFG->dirroot . '/blocks/community/locallib.php'); - $communitymanager = new block_community_manager(); - $courses = $communitymanager->block_community_get_courses($USER->id); - if ($courses) { - $this->content->items[] = html_writer::empty_tag('hr'); - $this->content->icons[] = ''; - $this->content->items[] = get_string('mycommunities', 'block_community'); - $this->content->icons[] = ''; - foreach ($courses as $course) { - //delete link - $deleteicon = $OUTPUT->pix_icon('t/delete', get_string('removecommunitycourse', 'block_community')); - $deleteurl = new moodle_url('/blocks/community/communitycourse.php', - array('remove' => true, - 'courseid' => $this->page->course->id, - 'communityid' => $course->id, 'sesskey' => sesskey())); - $deleteatag = html_writer::tag('a', $deleteicon, array('href' => $deleteurl)); - - $courselink = html_writer::tag('a', $course->coursename, - array('href' => $course->courseurl)); - $this->content->items[] = $courselink . ' ' . $deleteatag; - $this->content->icons[] = ''; - } - } + $this->content->items[] = get_string('functionalityremoved', 'error'); return $this->content; } diff --git a/blocks/community/db/upgrade.php b/blocks/community/db/upgrade.php index 0bdc51e15f01b..1a593de5c9af3 100644 --- a/blocks/community/db/upgrade.php +++ b/blocks/community/db/upgrade.php @@ -58,5 +58,8 @@ function xmldb_block_community_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/community/lang/en/block_community.php b/blocks/community/lang/en/block_community.php index 73e65d4ca46ad..0f7c64b860331 100644 --- a/blocks/community/lang/en/block_community.php +++ b/blocks/community/lang/en/block_community.php @@ -64,7 +64,7 @@ Others are course templates provided for you to download and use on your own Moodle site.'; $string['enrollable'] = 'courses I can enrol in'; $string['enrollablecourses'] = 'Enrollable courses'; -$string['errorcourselisting'] = 'An error occurred when retrieving the course listing from the selected hub, please try again later. ({$a})'; +$string['errorcourselisting'] = 'Please note: It is no longer possible to search for community courses on moodle.net. Previously shared courses for download are now available on archive.moodle.net. See Sunsetting moodle.net for further details.'; $string['errorhublisting'] = 'An error occurred when retrieving the hub listing from Moodle.org, please try again later. ({$a})'; $string['fileinfo'] = 'Language: {$a->lang} - License: {$a->license} - Time updated: {$a->timeupdated}'; $string['hideall'] = 'Hide hubs'; diff --git a/blocks/completionstatus/db/upgrade.php b/blocks/completionstatus/db/upgrade.php index 8a9ec8966cf84..6d78948074050 100644 --- a/blocks/completionstatus/db/upgrade.php +++ b/blocks/completionstatus/db/upgrade.php @@ -60,5 +60,8 @@ function xmldb_block_completionstatus_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/course_summary/db/upgrade.php b/blocks/course_summary/db/upgrade.php index fb0cbaefb3138..f5732e5fce33c 100644 --- a/blocks/course_summary/db/upgrade.php +++ b/blocks/course_summary/db/upgrade.php @@ -60,5 +60,8 @@ function xmldb_block_course_summary_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/html/db/upgrade.php b/blocks/html/db/upgrade.php index 4949b3f3a17b6..62d89a65971f6 100644 --- a/blocks/html/db/upgrade.php +++ b/blocks/html/db/upgrade.php @@ -45,5 +45,8 @@ function xmldb_block_html_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/myoverview/db/upgrade.php b/blocks/myoverview/db/upgrade.php new file mode 100644 index 0000000000000..702f91c8e4c28 --- /dev/null +++ b/blocks/myoverview/db/upgrade.php @@ -0,0 +1,59 @@ +. + +/** + * This file keeps track of upgrades to the myoverview block + * + * @since 3.7.3 + * @package block_myoverview + * @copyright 2019 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Upgrade code for the MyOverview block. + * + * @param int $oldversion + */ +function xmldb_block_myoverview_upgrade($oldversion) { + global $DB; + + if ($oldversion < 2019052001) { + // Remove orphaned course favourites, which weren't being deleted when the course was deleted. + $sql = 'SELECT f.id + FROM {favourite} f + LEFT JOIN {course} c + ON (c.id = f.itemid) + WHERE f.component = :component + AND f.itemtype = :itemtype + AND c.id IS NULL'; + $params = ['component' => 'core_course', 'itemtype' => 'courses']; + + if ($records = $DB->get_fieldset_sql($sql, $params)) { + $chunks = array_chunk($records, 1000); + foreach ($chunks as $chunk) { + list($insql, $inparams) = $DB->get_in_or_equal($chunk); + $DB->delete_records_select('favourite', "id $insql", $inparams); + } + } + + upgrade_block_savepoint(true, 2019052001, 'myoverview', false); + } + + return true; +} diff --git a/blocks/myoverview/lang/en/block_myoverview.php b/blocks/myoverview/lang/en/block_myoverview.php index 79a0b786dd613..36d7496e9e9c1 100644 --- a/blocks/myoverview/lang/en/block_myoverview.php +++ b/blocks/myoverview/lang/en/block_myoverview.php @@ -37,7 +37,7 @@ $string['aria:favourites'] = 'Show starred courses'; $string['aria:future'] = 'Show future courses'; $string['aria:groupingdropdown'] = 'Grouping drop-down menu'; -$string['aria:inprogress'] = 'Show in courses in progress'; +$string['aria:inprogress'] = 'Show courses in progress'; $string['aria:lastaccessed'] = 'Sort courses by last accessed date'; $string['aria:list'] = 'Switch to list view'; $string['aria:title'] = 'Sort courses by course name'; diff --git a/blocks/myoverview/lib.php b/blocks/myoverview/lib.php index d5bc9e23f197b..933a173b9fc6f 100644 --- a/blocks/myoverview/lib.php +++ b/blocks/myoverview/lib.php @@ -121,3 +121,14 @@ function block_myoverview_user_preferences() { return $preferences; } + +/** + * Pre-delete course hook to cleanup any records with references to the deleted course. + * + * @param stdClass $course The deleted course + */ +function block_myoverview_pre_course_delete(\stdClass $course) { + // Removing any starred courses which have been created for users, for this course. + $service = \core_favourites\service_factory::get_service_for_component('core_course'); + $service->delete_favourites_by_type_and_item('courses', $course->id); +} diff --git a/blocks/myoverview/templates/view-cards.mustache b/blocks/myoverview/templates/view-cards.mustache index b940a1e414af2..d3527fff9461c 100644 --- a/blocks/myoverview/templates/view-cards.mustache +++ b/blocks/myoverview/templates/view-cards.mustache @@ -29,7 +29,8 @@ "fullname": "course 3", "hasprogress": true, "progress": 10, - "coursecategory": "Miscellaneous" + "coursecategory": "Miscellaneous", + "visible": true } ] } @@ -55,7 +56,7 @@ {{#str}}aria:coursecategory, core_course{{/str}} - + {{{coursecategory}}} {{/coursecategory}} diff --git a/blocks/myoverview/templates/view-list.mustache b/blocks/myoverview/templates/view-list.mustache index ab98fcb1e5422..37992f044f55f 100644 --- a/blocks/myoverview/templates/view-list.mustache +++ b/blocks/myoverview/templates/view-list.mustache @@ -29,7 +29,8 @@ "fullname": "course 3", "hasprogress": true, "progress": 10, - "coursecategory": "Miscellaneous" + "coursecategory": "Miscellaneous", + "visible": true } ] } @@ -65,6 +66,11 @@ {{{fullname}}} + {{^visible}} +
    + {{#str}} hiddenfromstudents {{/str}} +
    + {{/visible}}
{{#hasprogress}} diff --git a/blocks/myoverview/templates/view-summary.mustache b/blocks/myoverview/templates/view-summary.mustache index a57c69062517e..26bcdcc07d995 100644 --- a/blocks/myoverview/templates/view-summary.mustache +++ b/blocks/myoverview/templates/view-summary.mustache @@ -30,7 +30,8 @@ "summary": "This course is about assignments", "hasprogress": true, "progress": 10, - "coursecategory": "Miscellaneous" + "coursecategory": "Miscellaneous", + "visible": true } ] } @@ -75,6 +76,11 @@ {{> block_myoverview/course-action-menu }}
+ {{^visible}} +
+ {{#str}} hiddenfromstudents {{/str}} +
+ {{/visible}}
{{#str}}aria:coursesummary, block_myoverview{{/str}} {{{summary}}} diff --git a/blocks/myoverview/version.php b/blocks/myoverview/version.php index bc9f98366748d..728053a0a0ab7 100644 --- a/blocks/myoverview/version.php +++ b/blocks/myoverview/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019052000; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2019052001; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2019051100; // Requires this Moodle version. $plugin->component = 'block_myoverview'; // Full name of the plugin (used for diagnostics). diff --git a/blocks/navigation/db/upgrade.php b/blocks/navigation/db/upgrade.php index 97b106317ca71..1022e847e4c18 100644 --- a/blocks/navigation/db/upgrade.php +++ b/blocks/navigation/db/upgrade.php @@ -67,5 +67,8 @@ function xmldb_block_navigation_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/quiz_results/db/upgrade.php b/blocks/quiz_results/db/upgrade.php index 60b035ecaebaa..055bb61561f52 100644 --- a/blocks/quiz_results/db/upgrade.php +++ b/blocks/quiz_results/db/upgrade.php @@ -57,5 +57,8 @@ function xmldb_block_quiz_results_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/recent_activity/db/upgrade.php b/blocks/recent_activity/db/upgrade.php index 77e6f61c86165..1ad6e1aa1c982 100644 --- a/blocks/recent_activity/db/upgrade.php +++ b/blocks/recent_activity/db/upgrade.php @@ -59,5 +59,8 @@ function xmldb_block_recent_activity_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/recentlyaccessedcourses/templates/course-card.mustache b/blocks/recentlyaccessedcourses/templates/course-card.mustache index 7b98a5eb2034e..064935d87ae59 100644 --- a/blocks/recentlyaccessedcourses/templates/course-card.mustache +++ b/blocks/recentlyaccessedcourses/templates/course-card.mustache @@ -40,5 +40,8 @@ {{{coursecategory}}} {{/coursecategory}} + {{$divider}} +
|
+ {{/divider}} {{$coursename}} {{{fullname}}} {{/coursename}} {{/ core_course/coursecard }} diff --git a/blocks/recentlyaccesseditems/classes/helper.php b/blocks/recentlyaccesseditems/classes/helper.php index b7d2b72af6f63..d1ac05b74baad 100644 --- a/blocks/recentlyaccesseditems/classes/helper.php +++ b/blocks/recentlyaccesseditems/classes/helper.php @@ -53,11 +53,13 @@ public static function get_recent_items(int $limit = 0) { return $recentitems; } - // Determine sort sql clause. - $sort = 'timeaccess DESC'; - $paramsql = array('userid' => $userid); - $records = $DB->get_records('block_recentlyaccesseditems', $paramsql, $sort); + $sql = "SELECT rai.* + FROM {block_recentlyaccesseditems} rai + JOIN {course} c ON c.id = rai.courseid + WHERE userid = :userid + ORDER BY rai.timeaccess DESC"; + $records = $DB->get_records_sql($sql, $paramsql); $order = 0; // Get array of items by course. Use $order index to keep sql sorted results. diff --git a/blocks/recentlyaccesseditems/db/upgrade.php b/blocks/recentlyaccesseditems/db/upgrade.php new file mode 100644 index 0000000000000..8da5ad1eb00c7 --- /dev/null +++ b/blocks/recentlyaccesseditems/db/upgrade.php @@ -0,0 +1,73 @@ +. + +/** + * This file keeps track of upgrades to the recentlyaccesseditems block + * + * Sometimes, changes between versions involve alterations to database structures + * and other major things that may break installations. + * + * The upgrade function in this file will attempt to perform all the necessary + * actions to upgrade your older installation to the current version. + * + * If there's something it cannot do itself, it will tell you what you need to do. + * + * The commands in here will all be database-neutral, using the methods of + * database_manager class + * + * Please do not forget to use upgrade_set_timeout() + * before any action that may take longer time to finish. + * + * @package block_recentlyaccesseditems + * @copyright 2019 Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Upgrade the recentlyaccesseditems db table. + * + * @param $oldversion + * @return bool + */ +function xmldb_block_recentlyaccesseditems_upgrade($oldversion, $block) { + global $DB; + + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + if ($oldversion < 2019052001) { + // Query the items to be deleted as a list of IDs. We cannot delete directly from this as a + // subquery because MySQL does not support delete with subqueries. + $fordeletion = $DB->get_fieldset_sql(" + SELECT rai.id + FROM {block_recentlyaccesseditems} rai + LEFT JOIN {course} c ON c.id = rai.courseid + LEFT JOIN {course_modules} cm ON cm.id = rai.cmid + WHERE c.id IS NULL OR cm.id IS NULL"); + + // Delete the array in chunks of 500 (Oracle does not support more than 1000 parameters, + // let's leave some leeway, there are likely only one chunk anyway). + $chunks = array_chunk($fordeletion, 500); + foreach ($chunks as $chunk) { + $DB->delete_records_list('block_recentlyaccesseditems', 'id', $chunk); + } + + upgrade_block_savepoint(true, 2019052001, 'recentlyaccesseditems', false); + } + + return true; +} diff --git a/blocks/recentlyaccesseditems/lib.php b/blocks/recentlyaccesseditems/lib.php new file mode 100644 index 0000000000000..eedf29d4b26fe --- /dev/null +++ b/blocks/recentlyaccesseditems/lib.php @@ -0,0 +1,47 @@ +. + +/** + * The interface library between the core and the subsystem. + * + * @package block_recentlyaccesseditems + * @copyright 2019 Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Pre-delete course module hook to cleanup any records with references to the deleted module. + * + * @param stdClass $cm The deleted course module + */ +function block_recentlyaccesseditems_pre_course_module_delete($cm) { + global $DB; + + $DB->delete_records('block_recentlyaccesseditems', ['cmid' => $cm->id]); +} + +/** + * Pre-delete course hook to cleanup any records with references to the deleted course. + * + * @param stdClass $course The deleted course + */ +function block_recentlyaccesseditems_pre_course_delete($course) { + global $DB; + + $DB->delete_records('block_recentlyaccesseditems', ['courseid' => $course->id]); +} diff --git a/blocks/recentlyaccesseditems/tests/externallib_test.php b/blocks/recentlyaccesseditems/tests/externallib_test.php index 62de36a2340fb..b6d6b5e94396a 100644 --- a/blocks/recentlyaccesseditems/tests/externallib_test.php +++ b/blocks/recentlyaccesseditems/tests/externallib_test.php @@ -103,5 +103,15 @@ public function test_get_recent_items() { } $this->assertTrue($record->timeaccess < $result[$key - 1]->timeaccess); } + + // Delete a course and confirm it's activities don't get returned. + delete_course($courses[0], false); + $result = \block_recentlyaccesseditems\external::get_recent_items(); + $this->assertCount((count($forum) + count($chat)) - 2, $result); + + // Delete a single course module should still return. + course_delete_module($forum[1]->cmid); + $result = \block_recentlyaccesseditems\external::get_recent_items(); + $this->assertCount((count($forum) + count($chat)) - 3, $result); } } \ No newline at end of file diff --git a/blocks/recentlyaccesseditems/tests/helper_test.php b/blocks/recentlyaccesseditems/tests/helper_test.php new file mode 100644 index 0000000000000..aed88638bb044 --- /dev/null +++ b/blocks/recentlyaccesseditems/tests/helper_test.php @@ -0,0 +1,71 @@ +. + +/** + * Block recentlyaccesseditems helper tests. + * + * @package block_recentlyaccesseditems + * @copyright 2019 University of Nottingham + * @author Neill Magill + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use block_recentlyaccesseditems\helper; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Block Recently accessed helper class tests. + * + * @package block_recentlyaccesseditems + * @copyright 2019 University of Nottingham + * @author Neill Magill + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class block_recentlyaccesseditems_helper_testcase extends advanced_testcase { + /** + * Tests that the get recent items method can handle getting records when courses have been deleted. + */ + public function test_get_recent_items() { + $this->resetAfterTest(); + $course = self::getDataGenerator()->create_course(); + $coursetodelete = self::getDataGenerator()->create_course(); + $user = self::getDataGenerator()->create_and_enrol($course, 'student'); + self::getDataGenerator()->enrol_user($user->id, $coursetodelete->id, 'student'); + + // Add an activity to each course. + $forum = self::getDataGenerator()->create_module('forum', ['course' => $course]); + $glossary = self::getDataGenerator()->create_module('glossary', ['course' => $coursetodelete]); + self::setUser($user); + + // Get the user to visit the activities. + $event1params = ['context' => context_module::instance($forum->cmid), 'objectid' => $forum->id]; + $event1 = \mod_forum\event\course_module_viewed::create($event1params); + $event1->trigger(); + $event2params = ['context' => context_module::instance($glossary->cmid), 'objectid' => $glossary->id]; + $event2 = \mod_glossary\event\course_module_viewed::create($event2params); + $event2->trigger(); + $recent1 = helper::get_recent_items(); + self::assertCount(2, $recent1); + $recentlimited = helper::get_recent_items(1); + self::assertCount(1, $recentlimited); + delete_course($coursetodelete, false); + + // There should be no errors if a course has been deleted. + $recent2 = helper::get_recent_items(); + self::assertCount(1, $recent2); + } +} diff --git a/blocks/recentlyaccesseditems/tests/privacy_test.php b/blocks/recentlyaccesseditems/tests/privacy_test.php index 8c5ab6957e487..92152c5605f2a 100644 --- a/blocks/recentlyaccesseditems/tests/privacy_test.php +++ b/blocks/recentlyaccesseditems/tests/privacy_test.php @@ -207,6 +207,60 @@ public function test_export_user_data() { // Confirm student's data is exported. $writer = \core_privacy\local\request\writer::with_context($studentcontext); $this->assertTrue($writer->has_any_data()); + + delete_course($course, false); + $sc = context_user::instance($student->id); + $approvedlist = new approved_contextlist($student, $component, [$sc->id]); + provider::export_user_data($approvedlist); + $writer = \core_privacy\local\request\writer::with_context($sc); + $this->assertTrue($writer->has_any_data()); + } + + /** + * Test exporting data for an approved contextlist with a deleted course + */ + public function test_export_user_data_with_deleted_course() { + global $DB; + + $this->resetAfterTest(); + $generator = $this->getDataGenerator(); + $component = 'block_recentlyaccesseditems'; + + $student = $generator->create_user(); + $studentcontext = context_user::instance($student->id); + + // Enrol user in course and add course items. + $course = $generator->create_course(); + $generator->enrol_user($student->id, $course->id, 'student'); + $forum = $generator->create_module('forum', ['course' => $course]); + $chat = $generator->create_module('chat', ['course' => $course]); + + // Generate some recent activity. + $this->setUser($student); + $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid), + 'objectid' => $forum->id]); + $event->trigger(); + $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid), + 'objectid' => $chat->id]); + $event->trigger(); + + // Confirm data is present. + $params = [ + 'courseid' => $course->id, + 'userid' => $student->id, + ]; + + $result = $DB->count_records('block_recentlyaccesseditems', $params); + $this->assertEquals(2, $result); + delete_course($course, false); + + // Export data for student. + $approvedlist = new approved_contextlist($student, $component, [$studentcontext->id]); + provider::export_user_data($approvedlist); + + // Confirm student's data is exported. + $writer = \core_privacy\local\request\writer::with_context($studentcontext); + $this->assertFalse($writer->has_any_data()); } /** diff --git a/blocks/recentlyaccesseditems/version.php b/blocks/recentlyaccesseditems/version.php index c96794f089f7d..d60ba3c52febe 100644 --- a/blocks/recentlyaccesseditems/version.php +++ b/blocks/recentlyaccesseditems/version.php @@ -22,6 +22,6 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019052000; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2019052001; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2019051100; // Requires this Moodle version. $plugin->component = 'block_recentlyaccesseditems'; // Full name of the plugin (used for diagnostics). diff --git a/blocks/rss_client/db/upgrade.php b/blocks/rss_client/db/upgrade.php index 878f198c3f947..84dc47033e295 100644 --- a/blocks/rss_client/db/upgrade.php +++ b/blocks/rss_client/db/upgrade.php @@ -45,5 +45,8 @@ function xmldb_block_rss_client_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/rss_client/lang/en/block_rss_client.php b/blocks/rss_client/lang/en/block_rss_client.php index afc0bab07b6c4..06facda0af264 100644 --- a/blocks/rss_client/lang/en/block_rss_client.php +++ b/blocks/rss_client/lang/en/block_rss_client.php @@ -43,7 +43,7 @@ $string['editnewsfeeds'] = 'Edit news feeds'; $string['editrssblock'] = 'Edit RSS headline block'; $string['enableautodiscovery'] = 'Enable auto-discovery of feeds?'; -$string['enableautodiscovery_help'] = 'If enabled, feeds on web pages are found automatically. For example, if http://docs.moodle.org is entered, then http://docs.moodle.org/en/index.php?title=Special:RecentChanges&feed=rss would be found.'; +$string['enableautodiscovery_help'] = 'If enabled, feeds on web pages are found automatically. For example, if https://docs.moodle.org is entered, then https://docs.moodle.org/en/index.php?title=Special:RecentChanges&feed=rss would be found.'; $string['failedfeed'] = 'Feed failed to download - will retry after {$a}'; $string['failedfeeds'] = 'One or more RSS feeds have failed'; $string['feed'] = 'Feed'; diff --git a/blocks/section_links/db/upgrade.php b/blocks/section_links/db/upgrade.php index 0a8e2b0421b9c..41638f4753ba5 100644 --- a/blocks/section_links/db/upgrade.php +++ b/blocks/section_links/db/upgrade.php @@ -61,5 +61,8 @@ function xmldb_block_section_links_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/selfcompletion/db/upgrade.php b/blocks/selfcompletion/db/upgrade.php index 9b8d87bddb055..92b3268f797c0 100644 --- a/blocks/selfcompletion/db/upgrade.php +++ b/blocks/selfcompletion/db/upgrade.php @@ -60,5 +60,8 @@ function xmldb_block_selfcompletion_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/settings/db/upgrade.php b/blocks/settings/db/upgrade.php index 7b8618d76b6ad..f2b732712f1f6 100644 --- a/blocks/settings/db/upgrade.php +++ b/blocks/settings/db/upgrade.php @@ -67,5 +67,8 @@ function xmldb_block_settings_upgrade($oldversion, $block) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php b/blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php index f523116205902..b80713c60c69e 100644 --- a/blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php +++ b/blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php @@ -114,12 +114,8 @@ public function i_click_on_in_the_activity_in_site_main_menu_block($element, $se protected function get_site_menu_activity_element($element, $selectortype, $activityname) { $activitynode = $this->get_site_menu_activity_node($activityname); - // Transforming to Behat selector/locator. - list($selector, $locator) = $this->transform_selector($selectortype, $element); - $exception = new ElementNotFoundException($this->getSession(), '"' . $element . '" "' . - $selectortype . '" in "' . $activityname . '" '); - - return $this->find($selector, $locator, $exception, $activitynode); + $exception = new ElementNotFoundException($this->getSession(), "'{$element}' '{$selectortype}' in '${activityname}'"); + return $this->find($selectortype, $element, $exception, $activitynode); } /** diff --git a/blocks/social_activities/tests/behat/behat_block_social_activities.php b/blocks/social_activities/tests/behat/behat_block_social_activities.php index 2fe5526d473d8..167b24d51a2c0 100644 --- a/blocks/social_activities/tests/behat/behat_block_social_activities.php +++ b/blocks/social_activities/tests/behat/behat_block_social_activities.php @@ -56,7 +56,11 @@ protected function get_social_block_activity_node($activityname) { } /** - * Checks that the specified activity's action menu contains an item. + * Checks that the specified activity in the social activities block should have the specified editing icon. + * + * This includes items in the action menu for the item (does not require it to be open) + * + * You should be in the course page with editing mode turned on. * * @Then /^"(?P(?:[^"]|\\")*)" activity in social activities block should have "(?P(?:[^"]|\\")*)" editing icon$/ * @param string $activityname @@ -71,7 +75,11 @@ public function activity_in_social_activities_block_should_have_editing_icon($ac } /** - * Checks that the specified activity's action menu contains an item. + * Checks that the specified activity in the social activities block should not have the specified editing icon. + * + * This includes items in the action menu for the item (does not require it to be open) + * + * You should be in the course page with editing mode turned on. * * @Then /^"(?P(?:[^"]|\\")*)" activity in social activities block should not have "(?P(?:[^"]|\\")*)" editing icon$/ * @param string $activityname @@ -103,7 +111,7 @@ public function i_click_on_in_the_activity_in_social_activities_block($element, } /** - * Clicks on the specified element inside the activity container. + * Finds the element containing a specific activity in the social activity block. * * @throws ElementNotFoundException * @param string $element @@ -114,16 +122,12 @@ public function i_click_on_in_the_activity_in_social_activities_block($element, protected function get_social_block_activity_element($element, $selectortype, $activityname) { $activitynode = $this->get_social_block_activity_node($activityname); - // Transforming to Behat selector/locator. - list($selector, $locator) = $this->transform_selector($selectortype, $element); - $exception = new ElementNotFoundException($this->getSession(), '"' . $element . '" "' . - $selectortype . '" in "' . $activityname . '" '); - - return $this->find($selector, $locator, $exception, $activitynode); + $exception = new ElementNotFoundException($this->getSession(), "'{$element}' '{$selectortype}' in '${activityname}'"); + return $this->find($selectortype, $element, $exception, $activitynode); } /** - * Checks that the specified activity is hidden. + * Checks that the specified activity is hidden in the social activities block. * * @Then /^"(?P(?:[^"]|\\")*)" activity in social activities block should be hidden$/ * @param string $activityname @@ -133,7 +137,7 @@ public function activity_in_social_activities_block_should_be_hidden($activityna } /** - * Checks that the specified activity is hidden. + * Checks that the specified activity is hidden in the social activities block. * * @Then /^"(?P(?:[^"]|\\")*)" activity in social activities block should be available but hidden from course page$/ * @param string $activityname @@ -143,7 +147,7 @@ public function activity_in_social_activities_block_should_be_available_but_hidd } /** - * Opens an activity actions menu if it is not already opened. + * Opens an activity actions menu in the social activities block if it is not already opened. * * @Given /^I open "(?P(?:[^"]|\\")*)" actions menu in social activities block$/ * @throws DriverException The step is not available when Javascript is disabled diff --git a/blocks/starredcourses/classes/external.php b/blocks/starredcourses/classes/external.php index f5cb1c2c04d17..d1ace2cd0f3fd 100644 --- a/blocks/starredcourses/classes/external.php +++ b/blocks/starredcourses/classes/external.php @@ -88,13 +88,18 @@ public static function get_starred_courses($limit, $offset) { return ($a->timemodified > $b->timemodified) ? -1 : 1; }); - $formattedcourses = array_map(function($favourite) use ($renderer) { + $formattedcourses = array(); + foreach ($favourites as $favourite) { $course = get_course($favourite->itemid); $context = \context_course::instance($favourite->itemid); - - $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]); - return $exporter->export($renderer); - }, $favourites); + $canviewhiddencourses = has_capability('moodle/course:viewhiddencourses', $context); + + if ($course->visible || $canviewhiddencourses) { + $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]); + $formattedcourse = $exporter->export($renderer); + $formattedcourses[] = $formattedcourse; + } + } return $formattedcourses; } diff --git a/blocks/timeline/amd/build/event_list.min.js b/blocks/timeline/amd/build/event_list.min.js index 893a2ac780254..43ae0c6fea054 100644 --- a/blocks/timeline/amd/build/event_list.min.js +++ b/blocks/timeline/amd/build/event_list.min.js @@ -1 +1 @@ -define(["jquery","core/notification","core/templates","core/paged_content_factory","core/str","core/user_date","block_timeline/calendar_events_repository"],function(a,b,c,d,e,f,g){var h=86400,i={EMPTY_MESSAGE:'[data-region="empty-message"]',ROOT:'[data-region="event-list-container"]',EVENT_LIST_CONTENT:'[data-region="event-list-content"]',EVENT_LIST_LOADING_PLACEHOLDER:'[data-region="event-list-loading-placeholder"]'},j={EVENT_LIST_CONTENT:"block_timeline/event-list-content"},k={ignoreControlWhileLoading:!0,controlPlacementBottom:!0,ariaLabels:{itemsperpagecomponents:"ariaeventlistpagelimit, block_timeline"}},l=function(a){a.find(i.EVENT_LIST_CONTENT).addClass("hidden"),a.find(i.EMPTY_MESSAGE).removeClass("hidden")},m=function(a){a.find(i.EVENT_LIST_CONTENT).removeClass("hidden"),a.find(i.EMPTY_MESSAGE).addClass("hidden")},n=function(a){a.find(i.EVENT_LIST_CONTENT).empty()},o=function(a,b){var c={},d={eventsbyday:[]};return a.forEach(function(a){var d=f.getUserMidnightForTimestamp(a.timesort,b);c[d]?c[d].push(a):c[d]=[a]}),Object.keys(c).forEach(function(a){var e=c[a];d.eventsbyday.push({past:ac}return!0}),e=d.length<=k;return e?b.allItemsLoaded(j):d.pop(),d})},s=function(c,f,g,h,i,j,l,m,n){var o={1:0},q=!1,s=a.extend({},k,n);return e.get_string("ariaeventlistpagelimit","block_timeline",a.isArray(c)?c[0].value:c).then(function(a){return s.ariaLabels.itemsperpage=a,s.ariaLabels.paginationnav=m,a}).then(function(){return d.createWithLimit(c,function(c,d){var e=[];return c.forEach(function(a){var c=a.pageNumber,h=r(a,d,g,o,f,i,j,l).then(function(a){if(a.length){q=!0;var b=a[a.length-1].id;return o[c+1]=b,p(a,g)}return a})["catch"](b.exception);e.push(h)}),a.when.apply(a,e).then(function(){h.resolve(q)})["catch"](function(){h.resolve(q)}),e},s)})},t=function(d,e,f,g,h){d=a(d);var j=a.Deferred(),k=d.find(i.EVENT_LIST_CONTENT),o=d.find(i.EVENT_LIST_LOADING_PLACEHOLDER),p=d.attr("data-course-id"),q=parseInt(d.attr("data-days-offset"),10),r=d.attr("data-days-limit"),t=parseInt(d.attr("data-midnight"),10);return n(d),m(d),o.removeClass("hidden"),void 0!=r&&(r=parseInt(r,10)),s(e,f,t,j,p,q,r,g,h).then(function(b,e){return b=a(b),b.addClass("hidden"),c.replaceNodeContents(k,b,e),j.then(function(a){return b.removeClass("hidden"),o.addClass("hidden"),a||l(d),a})["catch"](function(){return!1}),b})["catch"](b.exception)};return{init:t,rootSelector:i.ROOT}}); \ No newline at end of file diff --git a/blocks/timeline/amd/src/event_list.js b/blocks/timeline/amd/src/event_list.js index 9699ed1c68f20..c7c504e107ac0 100644 --- a/blocks/timeline/amd/src/event_list.js +++ b/blocks/timeline/amd/src/event_list.js @@ -265,7 +265,13 @@ function( return []; } - var calendarEvents = result.events; + var calendarEvents = result.events.filter(function(event) { + if (event.eventtype == "open" || event.eventtype == "opensubmission") { + var dayTimestamp = UserDate.getUserMidnightForTimestamp(event.timesort, midnight); + return dayTimestamp > midnight; + } + return true; + }); // We expect to receive limit + 1 events back from the server. // Any less means there are no more events to load. var loadedAll = calendarEvents.length <= limit; diff --git a/blocks/timeline/templates/event-list-content.mustache b/blocks/timeline/templates/event-list-content.mustache index b8df729d6d86a..f138e9c57ca42 100644 --- a/blocks/timeline/templates/event-list-content.mustache +++ b/blocks/timeline/templates/event-list-content.mustache @@ -65,7 +65,7 @@ }}
{{#eventsbyday}} -
{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}} {{/userdate}}
+
{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}} {{/userdate}}
{{> block_timeline/event-list-items }} {{/eventsbyday}}
\ No newline at end of file diff --git a/blocks/timeline/templates/event-list-item.mustache b/blocks/timeline/templates/event-list-item.mustache index b89e8083160d2..dc98508dcf305 100644 --- a/blocks/timeline/templates/event-list-item.mustache +++ b/blocks/timeline/templates/event-list-item.mustache @@ -50,13 +50,16 @@
{{{name}}}
- {{{course.fullnamedisplay}}} + title={{#quote}}{{{name}}}{{/quote}} + aria-label='{{#str}} ariaeventlistitem, block_timeline, { "name": {{#quote}}{{{name}}}{{/quote}}, "course": {{#quote}}{{{course.fullnamedisplay}}}{{/quote}}, "date": "{{#userdate}} {{timesort}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}" } {{/str}}' + >
{{#quote}}{{{name}}}{{/quote}}
+ {{#quote}}{{{course.fullnamedisplay}}}{{/quote}} {{#action.actionable}}
{{{action.name}}} + {{#action.showitemcount}} + {{action.itemcount}} + {{/action.showitemcount}}
{{/action.actionable}}
diff --git a/blog/classes/privacy/provider.php b/blog/classes/privacy/provider.php index 4bbc744a1db4d..8ff19c3f03dee 100644 --- a/blog/classes/privacy/provider.php +++ b/blog/classes/privacy/provider.php @@ -460,8 +460,7 @@ public static function delete_data_for_user(approved_contextlist $contextlist) { $params = array_merge($inparams, ['userid' => $userid]); $associds = $DB->get_fieldset_sql($sql, $params); - list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true); - $DB->delete_records_select('blog_association', "id $insql", $inparams); + $DB->delete_records_list('blog_association', 'id', $associds); } } diff --git a/blog/rsslib.php b/blog/rsslib.php index d671d1b52f9ba..37d1968e7026d 100644 --- a/blog/rsslib.php +++ b/blog/rsslib.php @@ -83,7 +83,7 @@ function blog_rss_print_link($context, $filtertype, $filterselect = 0, $tagid = $url = blog_rss_get_url($context->id, $userid, $filtertype, $filterselect, $tagid); $rsspix = $OUTPUT->pix_icon('i/rss', get_string('rss'), 'core', array('title' => $tooltiptext)); - print ''; + print ''; } /** diff --git a/blog/tests/privacy_test.php b/blog/tests/privacy_test.php index 8db5bdd6bd454..700255291da39 100644 --- a/blog/tests/privacy_test.php +++ b/blog/tests/privacy_test.php @@ -370,6 +370,37 @@ public function test_delete_data_for_user() { $this->assertTrue($DB->record_exists('post', ['courseid' => $c1->id, 'userid' => $u1->id, 'module' => 'notes'])); } + /** + * Test provider delete_data_for_user with a context that contains no entries + * + * @return void + */ + public function test_delete_data_for_user_empty_context() { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + // Create a blog entry for user, associated with course. + $entry = new blog_entry($this->create_post(['userid' => $user->id, 'courseid' => $course->id])->id); + $entry->add_association($context->id); + + // Generate list of contexts for user. + $contexts = provider::get_contexts_for_userid($user->id); + $this->assertContains($context->id, $contexts->get_contextids()); + + // Now delete the blog entry. + $entry->delete(); + + // Try to delete user data using contexts obtained prior to entry deletion. + $contextlist = new approved_contextlist($user, 'core_blog', $contexts->get_contextids()); + provider::delete_data_for_user($contextlist); + + // Sanity check to ensure blog_associations is really empty. + $this->assertEmpty($DB->get_records('blog_association', ['contextid' => $context->id])); + } + public function test_delete_data_for_all_users_in_context() { global $DB; diff --git a/cache/stores/mongodb/MongoDB/LICENSE b/cache/stores/mongodb/MongoDB/LICENSE new file mode 100644 index 0000000000000..d645695673349 --- /dev/null +++ b/cache/stores/mongodb/MongoDB/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cache/stores/mongodb/MongoDB/readme_moodle.txt b/cache/stores/mongodb/MongoDB/readme_moodle.txt index 456d3fde16492..da6ad07859014 100644 --- a/cache/stores/mongodb/MongoDB/readme_moodle.txt +++ b/cache/stores/mongodb/MongoDB/readme_moodle.txt @@ -1,14 +1,15 @@ MongoDB PHP ----------- - -Downloaded from https://github.com/mongodb/mongo-php-library - -Last commit on download: aac8e54009196f6544e50baf9b63dcf0eab3bbdf - -This version (1.4) requires PHP mongodb extension >= 1.5 +Download from https://github.com/mongodb/mongo-php-library Import procedure: - Copy all the files and folders from the folder mongodb/src in this directory. - Copy the license file from the project root. +- Update thirdpartylibs.xml with the latest version. + +2019/03/14 +---------- +Last commit on download: aac8e54009196f6544e50baf9b63dcf0eab3bbdf +This version (1.4.2) requires PHP mongodb extension >= 1.5 diff --git a/cache/stores/mongodb/thirdpartylibs.xml b/cache/stores/mongodb/thirdpartylibs.xml index bd22d79e9353d..afc8de0e7c483 100644 --- a/cache/stores/mongodb/thirdpartylibs.xml +++ b/cache/stores/mongodb/thirdpartylibs.xml @@ -4,7 +4,7 @@ MongoDB MongoDB PHP Library Apache - 1.4 + 1.4.2 2.0 \ No newline at end of file diff --git a/cache/stores/redis/lib.php b/cache/stores/redis/lib.php index 83a58892e5bfd..35962002b95ca 100644 --- a/cache/stores/redis/lib.php +++ b/cache/stores/redis/lib.php @@ -150,7 +150,8 @@ public function __construct($name, array $configuration = array()) { */ protected function new_redis($server, $prefix = '', $password = '') { $redis = new Redis(); - $port = null; + // Check if it isn't a Unix socket to set default port. + $port = ($server[0] === '/') ? null : 6379; if (strpos($server, ':')) { $serverconf = explode(':', $server); $server = $serverconf[0]; diff --git a/calendar/amd/build/calendar_filter.min.js b/calendar/amd/build/calendar_filter.min.js index ddf2010dcef1d..9ce9df0c1b6ab 100644 --- a/calendar/amd/build/calendar_filter.min.js +++ b/calendar/amd/build/calendar_filter.min.js @@ -1 +1 @@ -define(["jquery","core_calendar/selectors","core_calendar/events","core/str","core/templates"],function(a,b,c,d,e){var f=function(d){d.on("click",b.eventFilterItem,function(b){var c=a(b.currentTarget);g(c),b.preventDefault()}),a("body").on(c.viewUpdated,function(){var c=d.find(b.eventFilterItem);c.each(function(b,c){if(c=a(c),c.data("eventtype-hidden")){var d=i(c);h(d)}})})},g=function(a){var b=i(a);return b.hidden=!b.hidden,d.get_string("eventtype"+b.eventtype,"calendar").then(function(a){return b.name=a,b}).then(function(a){return e.render("core_calendar/event_filter_key",a)}).then(function(b,c){return e.replaceNode(a,b,c)}).then(function(){h(b)})},h=function(b){M.util.js_pending("month-mini-filterChanged"),a("body").trigger(c.filterChanged,{type:b.eventtype,hidden:b.hidden}),M.util.js_complete("month-mini-filterChanged")},i=function(a){return{eventtype:a.data("eventtype"),hidden:a.data("eventtype-hidden")}};return{init:function(b){b=a(b),f(b)}}}); \ No newline at end of file +define(["jquery","core_calendar/selectors","core_calendar/events","core/str","core/templates"],function(a,b,c,d,e){var f=function(d){d.on("click",b.eventFilterItem,function(b){var c=a(b.currentTarget);g(c),b.preventDefault()}),a("body").on(c.viewUpdated,function(){var c=d.find(b.eventFilterItem);c.each(function(b,c){if(c=a(c),c.data("eventtype-hidden")){var d=i(c);h(d)}})})},g=function(a){var b=i(a);return b.hidden=!b.hidden,d.get_string("eventtype"+b.eventtype,"calendar").then(function(a){return b.name=a,b.icon=!0,b.key="i/"+b.eventtype+"event",b.component="core",b}).then(function(a){return e.render("core_calendar/event_filter_key",a)}).then(function(b,c){return e.replaceNode(a,b,c)}).then(function(){h(b)})},h=function(b){M.util.js_pending("month-mini-filterChanged"),a("body").trigger(c.filterChanged,{type:b.eventtype,hidden:b.hidden}),M.util.js_complete("month-mini-filterChanged")},i=function(a){return{eventtype:a.data("eventtype"),hidden:a.data("eventtype-hidden")}};return{init:function(b){b=a(b),f(b)}}}); \ No newline at end of file diff --git a/calendar/amd/src/calendar_filter.js b/calendar/amd/src/calendar_filter.js index 622dafd890c79..41dd4488e8b65 100644 --- a/calendar/amd/src/calendar_filter.js +++ b/calendar/amd/src/calendar_filter.js @@ -67,6 +67,9 @@ function( return Str.get_string('eventtype' + data.eventtype, 'calendar') .then(function(nameStr) { data.name = nameStr; + data.icon = true; + data.key = 'i/' + data.eventtype + 'event'; + data.component = 'core'; return data; }) diff --git a/calendar/classes/external/event_exporter_base.php b/calendar/classes/external/event_exporter_base.php index 08d14acf101b2..f761f705bcdc6 100644 --- a/calendar/classes/external/event_exporter_base.php +++ b/calendar/classes/external/event_exporter_base.php @@ -275,7 +275,9 @@ protected function get_other_values(renderer_base $output) { } $timesort = $event->get_times()->get_sort_time()->getTimestamp(); $iconexporter = new event_icon_exporter($event, ['context' => $context]); - $values['normalisedeventtypetext'] = get_string('type' . $values['normalisedeventtype'], 'calendar'); + $identifier = 'type' . $values['normalisedeventtype']; + $stringexists = get_string_manager()->string_exists($identifier, 'calendar'); + $values['normalisedeventtypetext'] = $stringexists ? get_string($identifier, 'calendar') : ''; $values['icon'] = $iconexporter->export($output); diff --git a/calendar/classes/external/footer_options_exporter.php b/calendar/classes/external/footer_options_exporter.php index 5d7f198fecbfe..da7b69de58538 100644 --- a/calendar/classes/external/footer_options_exporter.php +++ b/calendar/classes/external/footer_options_exporter.php @@ -100,8 +100,6 @@ protected function get_link_params() { $params['course'] = $this->calendar->course->id; } else if (null !== $this->calendar->categoryid && $this->calendar->categoryid > 0) { $params['category'] = $this->calendar->categoryid; - } else { - $params['course'] = SITEID; } return $params; diff --git a/calendar/classes/local/event/data_access/event_vault.php b/calendar/classes/local/event/data_access/event_vault.php index 3d592088d58b1..8a8c20d2f3bb7 100644 --- a/calendar/classes/local/event/data_access/event_vault.php +++ b/calendar/classes/local/event/data_access/event_vault.php @@ -100,9 +100,6 @@ public function get_events( $ignorehidden = true, callable $filter = null ) { - if ($limitnum < 1 || $limitnum > 200) { - throw new limit_invalid_parameter_exception("Limit must be between 1 and 200 (inclusive)"); - } $fromquery = function($field, $timefrom, $lastseenmethod, $afterevent, $withduration) { if (!$timefrom) { @@ -186,7 +183,11 @@ public function get_events( } } - $offset += $limitnum; + if (!$limitnum) { + break; + } else { + $offset += $limitnum; + } } return $events; diff --git a/calendar/classes/local/event/factories/event_abstract_factory.php b/calendar/classes/local/event/factories/event_abstract_factory.php index ff697dd581794..b810ff8b5f747 100644 --- a/calendar/classes/local/event/factories/event_abstract_factory.php +++ b/calendar/classes/local/event/factories/event_abstract_factory.php @@ -138,7 +138,9 @@ public function create_instance(\stdClass $dbrow) { $module = new cm_info_proxy($dbrow->modulename, $dbrow->instance, $dbrow->courseid); } - $category = new coursecat_proxy($dbrow->categoryid); + if ($dbrow->categoryid) { + $category = new coursecat_proxy($dbrow->categoryid); + } $course = new std_proxy($dbrow->courseid, function($id) { return calendar_get_course_cached($this->coursecachereference, $id); diff --git a/calendar/classes/local/event/forms/eventtype.php b/calendar/classes/local/event/forms/eventtype.php index b3d582694f12b..d615a50804ba7 100644 --- a/calendar/classes/local/event/forms/eventtype.php +++ b/calendar/classes/local/event/forms/eventtype.php @@ -98,7 +98,7 @@ protected function add_event_type_elements($mform, $eventtypes) { $mform->hideIf('categoryid', 'eventtype', 'noteq', 'category'); } - $showall = $CFG->calendar_adminseesall && !has_capability('moodle/calendar:manageentries', \context_system::instance()); + $showall = is_siteadmin() && !empty($CFG->calendar_adminseesall); if (!empty($eventtypes['course'])) { $mform->addElement('course', 'courseid', get_string('course'), ['limittoenrolled' => !$showall]); $mform->hideIf('courseid', 'eventtype', 'noteq', 'course'); diff --git a/calendar/classes/local/event/mappers/create_update_form_mapper.php b/calendar/classes/local/event/mappers/create_update_form_mapper.php index 032d83930b107..e4ef1e80c771a 100644 --- a/calendar/classes/local/event/mappers/create_update_form_mapper.php +++ b/calendar/classes/local/event/mappers/create_update_form_mapper.php @@ -70,11 +70,13 @@ public function from_legacy_event_to_data(\calendar_event $legacyevent) { 'format' => $data->format ]; - // We don't want to return the context because it's not a - // form value and breaks the validation. + // Don't return context or subscription because they're not form values and break validation. if (isset($data->context)) { unset($data->context); } + if (isset($data->subscription)) { + unset($data->subscription); + } return $data; } diff --git a/calendar/classes/local/event/mappers/event_mapper.php b/calendar/classes/local/event/mappers/event_mapper.php index 5600ec76bcaf7..dc2ef2cb41696 100644 --- a/calendar/classes/local/event/mappers/event_mapper.php +++ b/calendar/classes/local/event/mappers/event_mapper.php @@ -95,6 +95,7 @@ public function from_event_to_legacy_event(event_interface $event) { // Normalise for the legacy event because it wants zero rather than null. $properties->courseid = empty($properties->courseid) ? 0 : $properties->courseid; + $properties->categoryid = empty($properties->categoryid) ? 0 : $properties->categoryid; $properties->groupid = empty($properties->groupid) ? 0 : $properties->groupid; $properties->userid = empty($properties->userid) ? 0 : $properties->userid; $properties->modulename = empty($properties->modulename) ? 0 : $properties->modulename; diff --git a/calendar/export_execute.php b/calendar/export_execute.php index f87e439e632ff..17b55010e8e6a 100644 --- a/calendar/export_execute.php +++ b/calendar/export_execute.php @@ -186,9 +186,9 @@ die(); } } - +$limitnum = 0; $events = calendar_get_legacy_events($timestart, $timeend, $users, $groups, array_keys($paramcourses), false, true, - $paramcategory); + $paramcategory, $limitnum); $ical = new iCalendar; $ical->add_property('method', 'PUBLISH'); diff --git a/calendar/externallib.php b/calendar/externallib.php index 8d4a3a9398427..c61202a7aec85 100644 --- a/calendar/externallib.php +++ b/calendar/externallib.php @@ -676,7 +676,7 @@ public static function create_calendar_events_parameters() { } /** - * Delete Calendar events. + * Create calendar events. * * @param array $events A list of events to create. * @return array array of events created. @@ -870,6 +870,11 @@ public static function submit_create_update_form($formdata) { self::validate_context($context); parse_str($params['formdata'], $data); + if (WS_SERVER) { + // Request via WS, ignore sesskey checks in form library. + $USER->ignoresesskey = true; + } + $eventtype = isset($data['eventtype']) ? $data['eventtype'] : null; $coursekey = ($eventtype == 'group') ? 'groupcourseid' : 'courseid'; $courseid = (!empty($data[$coursekey])) ? $data[$coursekey] : null; @@ -1021,8 +1026,8 @@ public static function get_calendar_monthly_view($year, $month, $courseid, $cate public static function get_calendar_monthly_view_parameters() { return new external_function_parameters( [ - 'year' => new external_value(PARAM_INT, 'Month to be viewed', VALUE_REQUIRED), - 'month' => new external_value(PARAM_INT, 'Year to be viewed', VALUE_REQUIRED), + 'year' => new external_value(PARAM_INT, 'Year to be viewed', VALUE_REQUIRED), + 'month' => new external_value(PARAM_INT, 'Month to be viewed', VALUE_REQUIRED), 'courseid' => new external_value(PARAM_INT, 'Course being viewed', VALUE_DEFAULT, SITEID, NULL_ALLOWED), 'categoryid' => new external_value(PARAM_INT, 'Category being viewed', VALUE_DEFAULT, null, NULL_ALLOWED), 'includenavigation' => new external_value( diff --git a/calendar/lib.php b/calendar/lib.php index 02c2d971392f5..a9df414b51d15 100644 --- a/calendar/lib.php +++ b/calendar/lib.php @@ -104,6 +104,11 @@ */ define('CALENDAR_IMPORT_FROM_URL', 1); +/** + * CALENDAR_IMPORT_EVENT_UPDATED_SKIPPED - imported event was skipped + */ +define('CALENDAR_IMPORT_EVENT_SKIPPED', -1); + /** * CALENDAR_IMPORT_EVENT_UPDATED - imported event was updated */ @@ -2198,8 +2203,8 @@ function calendar_view_event_allowed(calendar_event $event) { if (has_capability('moodle/calendar:manageentries', $event->context)) { return true; } - $mycourses = enrol_get_my_courses('id'); - return isset($mycourses[$event->courseid]); + + return can_access_course(get_course($event->courseid)); } else if ($event->userid) { if ($event->userid != $USER->id) { // No-one can ever see another users events. @@ -2865,11 +2870,23 @@ function calendar_add_icalendar_event($event, $unused = null, $subscriptionid, $ if ($updaterecord = $DB->get_record('event', array('uuid' => $eventrecord->uuid, 'subscriptionid' => $eventrecord->subscriptionid))) { - $eventrecord->id = $updaterecord->id; - $return = CALENDAR_IMPORT_EVENT_UPDATED; // Update. + + // Compare iCal event data against the moodle event to see if something has changed. + $result = array_diff((array) $eventrecord, (array) $updaterecord); + + // Unset timemodified field because it's always going to be different. + unset($result['timemodified']); + + if (count($result)) { + $eventrecord->id = $updaterecord->id; + $return = CALENDAR_IMPORT_EVENT_UPDATED; // Update. + } else { + return CALENDAR_IMPORT_EVENT_SKIPPED; + } } else { $return = CALENDAR_IMPORT_EVENT_INSERTED; // Insert. } + if ($createdevent = \calendar_event::create($eventrecord, false)) { if (!empty($event->properties['RRULE'])) { // Repeating events. @@ -2993,7 +3010,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. */ @@ -3003,18 +3020,13 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid $return = ''; $eventcount = 0; $updatecount = 0; + $skippedcount = 0; // Large calendars take a while... if (!CLI_SCRIPT) { \core_php_time_limit::raise(300); } - // Mark all events in a subscription with a zero timestamp. - if (!empty($subscriptionid)) { - $sql = "UPDATE {event} SET timemodified = :time WHERE subscriptionid = :id"; - $DB->execute($sql, array('time' => 0, 'id' => $subscriptionid)); - } - // Grab the timezone from the iCalendar file to be used later. if (isset($ical->properties['X-WR-TIMEZONE'][0]->value)) { $timezone = $ical->properties['X-WR-TIMEZONE'][0]->value; @@ -3022,8 +3034,9 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid $timezone = 'UTC'; } - $return = ''; + $icaluuids = []; foreach ($ical->components['VEVENT'] as $event) { + $icaluuids[] = $event->properties['UID'][0]->value; $res = calendar_add_icalendar_event($event, null, $subscriptionid, $timezone); switch ($res) { case CALENDAR_IMPORT_EVENT_UPDATED: @@ -3032,6 +3045,9 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid case CALENDAR_IMPORT_EVENT_INSERTED: $eventcount++; break; + case CALENDAR_IMPORT_EVENT_SKIPPED: + $skippedcount++; + break; case 0: $return .= '

' . get_string('erroraddingevent', 'calendar') . ': '; if (empty($event->properties['SUMMARY'])) { @@ -3044,18 +3060,28 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid } } - $return .= "

" . get_string('eventsimported', 'calendar', $eventcount) . "

"; - $return .= "

" . get_string('eventsupdated', 'calendar', $updatecount) . "

"; + $existing = $DB->get_field('event_subscriptions', 'lastupdated', ['id' => $subscriptionid]); + if (!empty($existing)) { + $eventsuuids = $DB->get_records_menu('event', ['subscriptionid' => $subscriptionid], '', 'id, uuid'); - // Delete remaining zero-marked events since they're not in remote calendar. - if (!empty($subscriptionid)) { - $deletecount = $DB->count_records('event', array('timemodified' => 0, 'subscriptionid' => $subscriptionid)); - if (!empty($deletecount)) { - $DB->delete_records('event', array('timemodified' => 0, 'subscriptionid' => $subscriptionid)); - $return .= "

" . get_string('eventsdeleted', 'calendar') . ": {$deletecount}

\n"; + $icaleventscount = count($icaluuids); + $tobedeleted = []; + if (count($eventsuuids) > $icaleventscount) { + foreach ($eventsuuids as $eventid => $eventuuid) { + if (!in_array($eventuuid, $icaluuids)) { + $tobedeleted[] = $eventid; + } + } + if (!empty($tobedeleted)) { + $DB->delete_records_list('event', 'id', $tobedeleted); + $return .= "

" . get_string('eventsdeleted', 'calendar') . ": " . count($tobedeleted) . "

"; + } } } + $return .= "

" . get_string('eventsimported', 'calendar', $eventcount) . "

"; + $return .= "

" . get_string('eventsskipped', 'calendar', $skippedcount) . "

"; + $return .= "

" . get_string('eventsupdated', 'calendar', $updatecount) . "

"; return $return; } @@ -3237,10 +3263,12 @@ function core_calendar_user_preferences() { * or events in progress/already started selected as well * @param boolean $ignorehidden whether to select only visible events or all events * @param array $categories array of category ids and/or objects. + * @param int $limitnum Number of events to fetch or zero to fetch all. + * * @return array $events of selected events or an empty array if there aren't any (or there was an error) */ function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, - $withduration = true, $ignorehidden = true, $categories = []) { + $withduration = true, $ignorehidden = true, $categories = [], $limitnum = 0) { // Normalise the users, groups and courses parameters so that they are compliant with \core_calendar\local\api::get_events(). // Existing functions that were using the old calendar_get_events() were passing a mixture of array, int, boolean for these // parameters, but with the new API method, only null and arrays are accepted. @@ -3277,7 +3305,7 @@ function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, null, null, null, - 40, + $limitnum, null, $userparam, $groupparam, @@ -3314,7 +3342,7 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig $calendardate = $type->timestamp_to_date_array($calendar->time); $date = new \DateTime('now', core_date::get_user_timezone_object(99)); - $eventlimit = 200; + $eventlimit = 0; if ($view === 'day') { $tstart = $type->convert_to_timestamp($calendardate['year'], $calendardate['mon'], $calendardate['mday']); @@ -3500,7 +3528,7 @@ function calendar_output_fragment_event_form($args) { $eventtypes = calendar_get_allowed_event_types($courseid); // If the user is on course context and is allowed to add course events set the event type default to course. - if ($courseid != SITEID && !empty($eventtypes['course'])) { + if (!empty($courseid) && !empty($eventtypes['course'])) { $data['eventtype'] = 'course'; $data['courseid'] = $courseid; $data['groupcourseid'] = $courseid; @@ -3641,6 +3669,9 @@ function calendar_get_filter_types() { return [ 'eventtype' => $type, 'name' => get_string("eventtype{$type}", "calendar"), + 'icon' => true, + 'key' => 'i/'.$type.'event', + 'component' => 'core' ]; }, $types); } @@ -3682,23 +3713,8 @@ function calendar_get_allowed_event_types(int $courseid = null) { if (!empty($courseid) && $courseid != SITEID) { $context = \context_course::instance($courseid); - $groups = groups_get_all_groups($courseid); - $types['user'] = has_capability('moodle/calendar:manageownentries', $context); - - if (has_capability('moodle/calendar:manageentries', $context) || !empty($CFG->calendar_adminseesall)) { - $types['course'] = true; - - $types['group'] = (!empty($groups) && has_capability('moodle/site:accessallgroups', $context)) - || array_filter($groups, function($group) use ($USER) { - return groups_is_member($group->id); - }); - } else if (has_capability('moodle/calendar:managegroupentries', $context)) { - $types['group'] = (!empty($groups) && has_capability('moodle/site:accessallgroups', $context)) - || array_filter($groups, function($group) use ($USER) { - return groups_is_member($group->id); - }); - } + calendar_internal_update_course_and_group_permission($courseid, $context, $types); } if (has_capability('moodle/calendar:manageentries', \context_course::instance(SITEID))) { @@ -3783,23 +3799,7 @@ function calendar_get_allowed_event_types(int $courseid = null) { context_helper::preload_from_record($coursewithgroup); $context = context_course::instance($coursewithgroup->id); - if (has_capability('moodle/calendar:manageentries', $context)) { - // The user has access to manage calendar entries for the whole course. - // This includes groups if they have the accessallgroups capability. - $types['course'] = true; - if (has_capability('moodle/site:accessallgroups', $context)) { - // The user also has access to all groups so they can add calendar entries to any group. - // The manageentries capability overrides the managegroupentries capability. - $types['group'] = true; - break; - } - - if (empty($types['group']) && has_capability('moodle/calendar:managegroupentries', $context)) { - // The user has the managegroupentries capability. - // If they have access to _any_ group, then they can create calendar entries within that group. - $types['group'] = !empty(groups_get_all_groups($coursewithgroup->id, $USER->id)); - } - } + calendar_internal_update_course_and_group_permission($coursewithgroup->id, $context, $types); // Okay, course and group event types are allowed, no need to keep the loop iteration. if ($types['course'] == true && $types['group'] == true) { @@ -3833,3 +3833,43 @@ function calendar_get_allowed_event_types(int $courseid = null) { return $types; } + +/** + * Given a course id, and context, updates the permission types array to add the 'course' or 'group' + * permission if it is relevant for that course. + * + * For efficiency, if they already have 'course' or 'group' then it skips checks. + * + * Do not call this function directly, it is only for use by calendar_get_allowed_event_types(). + * + * @param int $courseid Course id + * @param context $context Context for that course + * @param array $types Current permissions + */ +function calendar_internal_update_course_and_group_permission(int $courseid, context $context, array &$types) { + if (!$types['course']) { + // If they have manageentries permission on the course, then they can update this course. + if (has_capability('moodle/calendar:manageentries', $context)) { + $types['course'] = true; + } + } + // To update group events they must have EITHER manageentries OR managegroupentries. + if (!$types['group'] && (has_capability('moodle/calendar:manageentries', $context) || + has_capability('moodle/calendar:managegroupentries', $context))) { + // And they also need for a group to exist on the course. + $groups = groups_get_all_groups($courseid); + if (!empty($groups)) { + // And either accessallgroups, or belong to one of the groups. + if (has_capability('moodle/site:accessallgroups', $context)) { + $types['group'] = true; + } else { + foreach ($groups as $group) { + if (groups_is_member($group->id)) { + $types['group'] = true; + break; + } + } + } + } + } +} diff --git a/calendar/managesubscriptions.php b/calendar/managesubscriptions.php index 4837733629e37..9c6af0adacc47 100644 --- a/calendar/managesubscriptions.php +++ b/calendar/managesubscriptions.php @@ -127,8 +127,13 @@ $params = []; $usedefaultfilters = true; -if (!empty($courseid) && $courseid == SITEID && !empty($types['site'])) { + +if (!empty($types['site'])) { $searches[] = "(eventtype = 'site')"; + $usedefaultfilters = false; +} + +if (!empty($types['user'])) { $searches[] = "(eventtype = 'user' AND userid = :userid)"; $params['userid'] = $USER->id; $usedefaultfilters = false; @@ -140,9 +145,14 @@ $usedefaultfilters = false; } -if (!empty($categoryid) && !empty($types['category'])) { - $searches[] = "(eventtype = 'category' AND categoryid = :categoryid)"; - $params += ['categoryid' => $categoryid]; +if (!empty($types['category'])) { + if (!empty($categoryid)) { + $searches[] = "(eventtype = 'category' AND categoryid = :categoryid)"; + $params += ['categoryid' => $categoryid]; + } else { + $searches[] = "(eventtype = 'category')"; + } + $usedefaultfilters = false; } diff --git a/calendar/renderer.php b/calendar/renderer.php index a71b5fa5b520c..c84c8fcef0a62 100644 --- a/calendar/renderer.php +++ b/calendar/renderer.php @@ -161,7 +161,7 @@ public function event(calendar_event $event, $showactions=true) { $deletelink = null; } - $commands = html_writer::start_tag('div', array('class' => 'commands pull-xs-right')); + $commands = html_writer::start_tag('div', array('class' => 'commands float-sm-right')); $commands .= html_writer::start_tag('a', array('href' => $editlink)); $str = get_string('tt_editevent', 'calendar'); $commands .= $this->output->pix_icon('t/edit', $str); @@ -205,9 +205,9 @@ public function event(calendar_event $event, $showactions=true) { $output .= html_writer::tag('div', $event->courselink); } if (!empty($event->time)) { - $output .= html_writer::tag('span', $event->time, array('class' => 'date pull-xs-right mr-1')); + $output .= html_writer::tag('span', $event->time, array('class' => 'date float-sm-right mr-1')); } else { - $attrs = array('class' => 'date pull-xs-right mr-1'); + $attrs = array('class' => 'date float-sm-right mr-1'); $output .= html_writer::tag('span', calendar_time_representation($event->timestart), $attrs); } diff --git a/calendar/templates/day_detailed.mustache b/calendar/templates/day_detailed.mustache index fde6b2f44170d..2cb495040c33e 100644 --- a/calendar/templates/day_detailed.mustache +++ b/calendar/templates/day_detailed.mustache @@ -46,5 +46,9 @@ {{> core_calendar/header}} {{> core_calendar/day_navigation }} {{> core/overlay_loading}} - {{> core_calendar/event_list }} + {{< core_calendar/event_list }} + {{$noeventsmessage}} + {{#str}} daywithnoevents, core_calendar {{/str}} + {{/noeventsmessage}} + {{/core_calendar/event_list}}
diff --git a/calendar/templates/event_details.mustache b/calendar/templates/event_details.mustache index 86fcda4cc9b53..1a1516fef7d37 100644 --- a/calendar/templates/event_details.mustache +++ b/calendar/templates/event_details.mustache @@ -59,10 +59,12 @@
{{#pix}} i/calendareventtime, core, {{#str}} when, core_calendar {{/str}} {{/pix}}
{{{formattedtime}}}
-
-
{{#pix}} i/calendar, core, {{#str}} eventtype, core_calendar {{/str}} {{/pix}}
-
{{normalisedeventtypetext}}
-
+{{#normalisedeventtypetext}} +
+
{{#pix}} i/calendar, core, {{#str}} eventtype, core_calendar {{/str}} {{/pix}}
+
{{normalisedeventtypetext}}
+
+{{/normalisedeventtypetext}} {{#description}}
{{#pix}} i/calendareventdescription, core, {{#str}} description {{/str}} {{/pix}}
diff --git a/calendar/templates/event_icon.mustache b/calendar/templates/event_icon.mustache index fb88bd6c6eab1..ea61b32cf9326 100644 --- a/calendar/templates/event_icon.mustache +++ b/calendar/templates/event_icon.mustache @@ -37,5 +37,5 @@ {{#pix}} icon, {{modulename}} {{/pix}} {{/modulename}} {{^modulename}} - {{#pix}} i/{{eventtype}}event, core {{/pix}} + {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} {{/modulename}} diff --git a/calendar/templates/event_list.mustache b/calendar/templates/event_list.mustache index ae276ae20478a..63e4ca91709c6 100644 --- a/calendar/templates/event_list.mustache +++ b/calendar/templates/event_list.mustache @@ -37,7 +37,7 @@ {{/events}} {{^events}} - {{#str}}daywithnoevents, calendar{{/str}} + {{$noeventsmessage}}{{/noeventsmessage}} {{/events}}
\ No newline at end of file diff --git a/calendar/templates/month_mini.mustache b/calendar/templates/month_mini.mustache index b1aa06f1b1409..cb3e22974c0c3 100644 --- a/calendar/templates/month_mini.mustache +++ b/calendar/templates/month_mini.mustache @@ -136,12 +136,7 @@ {{$content}} {{#events}}
- {{#modulename}} - {{#pix}} icon, {{modulename}} {{/pix}} - {{/modulename}} - {{^modulename}} - {{#pix}} i/{{eventtype}}event, core {{/pix}} - {{/modulename}} + {{> core_calendar/event_icon}} {{{popupname}}}
{{/events}} diff --git a/calendar/templates/upcoming_detailed.mustache b/calendar/templates/upcoming_detailed.mustache index a1b070b790810..af33d99e4cbbb 100644 --- a/calendar/templates/upcoming_detailed.mustache +++ b/calendar/templates/upcoming_detailed.mustache @@ -34,5 +34,9 @@
{{> core_calendar/header}} {{> core/overlay_loading}} - {{> core_calendar/event_list }} + {{< core_calendar/event_list }} + {{$noeventsmessage}} + {{#str}} noupcomingevents, core_calendar {{/str}} + {{/noeventsmessage}} + {{/core_calendar/event_list}}
diff --git a/calendar/tests/behat/behat_calendar.php b/calendar/tests/behat/behat_calendar.php index 0bd835cc66606..1c6cb4ccb3066 100644 --- a/calendar/tests/behat/behat_calendar.php +++ b/calendar/tests/behat/behat_calendar.php @@ -124,4 +124,16 @@ public function i_view_the_calendar_for($month, $year) { $this->getSession()->visit($this->locate_path('/calendar/view.php?view=month&course=1&time='.$time)); } + + /** + * Navigate to site calendar. + * + * @Given /^I am viewing site calendar$/ + * @throws coding_exception + * @return void + */ + public function i_am_viewing_site_calendar() { + $url = new moodle_url('/calendar/view.php', ['view' => 'month']); + $this->getSession()->visit($this->locate_path($url->out_as_local_url(false))); + } } diff --git a/calendar/tests/behat/calendar.feature b/calendar/tests/behat/calendar.feature index cb681d0d53245..5f1ffaf50438f 100644 --- a/calendar/tests/behat/calendar.feature +++ b/calendar/tests/behat/calendar.feature @@ -14,6 +14,8 @@ Feature: Perform basic calendar functionality And the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | + | Course 2 | C2 | topics | + | Course 3 | C3 | topics | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | @@ -177,3 +179,62 @@ Feature: Perform basic calendar functionality And I set the field "Type of event" to "Course" When I click on "Save" "button" And I should see "Select a course" in the "Course" "form_row" + + @javascript + Scenario: Default event type selection in the event form + Given I log in as "teacher1" + When I am viewing site calendar + And I click on "New event" "button" + Then the field "Type of event" matches value "User" + And I am on "Course 1" course homepage + And I follow "This month" + When I click on "New event" "button" + Then the field "Type of event" matches value "Course" + + @javascript + Scenario: Admin can only see all courses if calendar_adminseesall setting is enabled. + Given I log in as "admin" + And I am on "Course 1" course homepage + And I enrol "admin" user as "Teacher" + And I am viewing site calendar + And I click on "New event" "button" + And I set the field "Type of event" to "Course" + When I open the autocomplete suggestions list + Then I should see "Course 1" in the ".form-autocomplete-suggestions" "css_element" + And I should not see "Course 2" in the ".form-autocomplete-suggestions" "css_element" + And I should not see "Course 3" in the ".form-autocomplete-suggestions" "css_element" + And I click on "Close" "button" + And I am on site homepage + And I navigate to "Appearance > Calendar" in site administration + And I set the field "Admins see all" to "1" + And I press "Save changes" + And I am viewing site calendar + And I click on "New event" "button" + And I set the field "Type of event" to "Course" + When I open the autocomplete suggestions list + Then I should see "Course 1" in the ".form-autocomplete-suggestions" "css_element" + And I should see "Course 2" in the ".form-autocomplete-suggestions" "css_element" + And I should see "Course 3" in the ".form-autocomplete-suggestions" "css_element" + + @javascript + Scenario: Students can only see user event type by default. + Given I log in as "student1" + And I am viewing site calendar + When I click on "New event" "button" + Then I should see "User" in the "div#fitem_id_staticeventtype" "css_element" + And I am on "Course 1" course homepage + And I follow "This month" + When I click on "New event" "button" + Then I should see "User" in the "div#fitem_id_staticeventtype" "css_element" + And I click on "Close" "button" + And I log out + Given I log in as "admin" + And I navigate to "Appearance > Calendar" in site administration + And I set the field "Admins see all" to "1" + And I press "Save changes" + And I log out + Given I log in as "student1" + And I am on "Course 1" course homepage + And I follow "This month" + When I click on "New event" "button" + Then I should see "User" in the "div#fitem_id_staticeventtype" "css_element" diff --git a/calendar/tests/behat/calendar_import.feature b/calendar/tests/behat/calendar_import.feature index a2c47cd5af2b8..2ad027a7b7b9b 100644 --- a/calendar/tests/behat/calendar_import.feature +++ b/calendar/tests/behat/calendar_import.feature @@ -47,3 +47,30 @@ Feature: Import and edit calendar events And I view the calendar for "2" "2017" And I should not see "Event on 2-25-2017" And I should not see "Event on 2-20-2017" + + Scenario: Import events using different event types. + Given I log in as "admin" + And I view the calendar for "1" "2016" + And I press "Manage subscriptions" + And I set the following fields to these values: + | Calendar name | Test Import | + | Import from | Calendar file (.ics) | + | Type of event | User | + And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager + And I press "Add" + And I should see "User events" + And I set the following fields to these values: + | Calendar name | Test Import | + | Import from | Calendar file (.ics) | + | Type of event | Category | + | Category | Miscellaneous | + And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager + And I press "Add" + And I should see "Category events" + And I set the following fields to these values: + | Calendar name | Test Import | + | Import from | Calendar file (.ics) | + | Type of event | Site | + And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager + And I press "Add" + And I should see "Site events" diff --git a/calendar/tests/lib_test.php b/calendar/tests/lib_test.php index 35809ec18b7c5..94a3cb4c9f4d0 100644 --- a/calendar/tests/lib_test.php +++ b/calendar/tests/lib_test.php @@ -227,6 +227,24 @@ public function test_add_subscription() { 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); } /** @@ -546,6 +564,10 @@ public function test_calendar_get_allowed_event_types_course() { $this->setUser($user); + // In general for all courses, they don't have the ability to add course events yet. + $types = calendar_get_allowed_event_types(); + $this->assertFalse($types['course']); + assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context1, true); assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context2, true); @@ -554,12 +576,20 @@ public function test_calendar_get_allowed_event_types_course() { $types = calendar_get_allowed_event_types($course1->id); $this->assertTrue($types['course']); + // If calling function without specified course, there is still a course where they have it. + $types = calendar_get_allowed_event_types(); + $this->assertTrue($types['course']); + assign_capability('moodle/calendar:manageentries', CAP_PROHIBIT, $roleid, $context1, true); // The user only now has the correct capability in both course 1 and 2 so we // expect both to be in the results. $types = calendar_get_allowed_event_types($course3->id); $this->assertFalse($types['course']); + + // They now do not have permission in any course. + $types = calendar_get_allowed_event_types(); + $this->assertFalse($types['course']); } public function test_calendar_get_allowed_event_types_group_no_acces_to_diff_groups() { @@ -582,6 +612,11 @@ public function test_calendar_get_allowed_event_types_group_no_acces_to_diff_gro $types = calendar_get_allowed_event_types($course->id); $this->assertTrue($types['course']); $this->assertFalse($types['group']); + + // Same result applies when not providing a specific course as they are only on one course. + $types = calendar_get_allowed_event_types(); + $this->assertTrue($types['course']); + $this->assertFalse($types['group']); } public function test_calendar_get_allowed_event_types_group_no_groups() { @@ -598,6 +633,12 @@ public function test_calendar_get_allowed_event_types_group_no_groups() { // no groups so we shouldn't see a group type. $types = calendar_get_allowed_event_types($course->id); $this->assertTrue($types['course']); + $this->assertFalse($types['group']); + + // Same result applies when not providing a specific course as they are only on one course. + $types = calendar_get_allowed_event_types(); + $this->assertTrue($types['course']); + $this->assertFalse($types['group']); } public function test_calendar_get_allowed_event_types_group_access_all_groups() { @@ -623,7 +664,12 @@ public function test_calendar_get_allowed_event_types_group_access_all_groups() // the accessallgroups capability. $types = calendar_get_allowed_event_types($course1->id); $this->assertTrue($types['group']); + + // Same result applies when not providing a specific course as they are only on one course. + $types = calendar_get_allowed_event_types(); + $this->assertTrue($types['group']); } + public function test_calendar_get_allowed_event_types_group_no_access_all_groups() { $generator = $this->getDataGenerator(); $user = $generator->create_user(); @@ -642,10 +688,87 @@ public function test_calendar_get_allowed_event_types_group_no_access_all_groups // groups that they are not a member of. $types = calendar_get_allowed_event_types($course->id); $this->assertFalse($types['group']); + + // Same result applies when not providing a specific course as they are only on one course. + $types = calendar_get_allowed_event_types(); + $this->assertFalse($types['group']); + assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true); assign_capability('moodle/site:accessallgroups', CAP_ALLOW, $roleid, $context, true); $types = calendar_get_allowed_event_types($course->id); $this->assertTrue($types['group']); + + // Same result applies when not providing a specific course as they are only on one course. + $types = calendar_get_allowed_event_types(); + $this->assertTrue($types['group']); + } + + public function test_calendar_get_allowed_event_types_group_cap_no_groups() { + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $course = $generator->create_course(); + $context = context_course::instance($course->id); + $roleid = $generator->create_role(); + $group = $generator->create_group(['courseid' => $course->id]); + $generator->enrol_user($user->id, $course->id, 'student'); + $generator->role_assign($roleid, $user->id, $context->id); + assign_capability('moodle/calendar:managegroupentries', CAP_ALLOW, $roleid, $context, true); + + $this->setUser($user); + $types = calendar_get_allowed_event_types($course->id); + $this->assertFalse($types['course']); + $this->assertFalse($types['group']); + + // Check without specifying a course (same result as user only has one course). + $types = calendar_get_allowed_event_types(); + $this->assertFalse($types['course']); + $this->assertFalse($types['group']); + } + + public function test_calendar_get_allowed_event_types_group_cap_has_group() { + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $course = $generator->create_course(); + $context = context_course::instance($course->id); + $roleid = $generator->create_role(); + $group = $generator->create_group(['courseid' => $course->id]); + $generator->enrol_user($user->id, $course->id, 'student'); + $generator->role_assign($roleid, $user->id, $context->id); + groups_add_member($group, $user); + assign_capability('moodle/calendar:managegroupentries', CAP_ALLOW, $roleid, $context, true); + + $this->setUser($user); + $types = calendar_get_allowed_event_types($course->id); + $this->assertFalse($types['course']); + $this->assertTrue($types['group']); + + // Check without specifying a course (same result as user only has one course). + $types = calendar_get_allowed_event_types(); + $this->assertFalse($types['course']); + $this->assertTrue($types['group']); + } + + public function test_calendar_get_allowed_event_types_group_cap_access_all_groups() { + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $course = $generator->create_course(); + $context = context_course::instance($course->id); + $roleid = $generator->create_role(); + $group = $generator->create_group(['courseid' => $course->id]); + $generator->enrol_user($user->id, $course->id, 'student'); + $generator->role_assign($roleid, $user->id, $context->id); + assign_capability('moodle/calendar:managegroupentries', CAP_ALLOW, $roleid, $context, true); + assign_capability('moodle/site:accessallgroups', CAP_ALLOW, $roleid, $context, true); + + $this->setUser($user); + $types = calendar_get_allowed_event_types($course->id); + $this->assertFalse($types['course']); + $this->assertTrue($types['group']); + + // Check without specifying a course (same result as user only has one course). + $types = calendar_get_allowed_event_types(); + $this->assertFalse($types['course']); + $this->assertTrue($types['group']); } /** @@ -772,4 +895,72 @@ public function test_calendar_set_filters_logged_in_another_user() { $this->assertEquals(array($coursegroups[$courses[0]->id][1]->id), $groupids); $this->assertEquals($users[1]->id, $userid); } + + /** + * Test for calendar_view_event_allowed for course event types. + */ + public function test_calendar_view_event_allowed_course_event() { + global $USER; + + $this->setAdminUser(); + + $generator = $this->getDataGenerator(); + + // A student in a course. + $student = $generator->create_user(); + // Some user not enrolled in any course. + $someuser = $generator->create_user(); + + // A course with manual enrolments. + $manualcourse = $generator->create_course(); + + // Enrol the student to the manual enrolment course. + $generator->enrol_user($student->id, $manualcourse->id); + + // A course that allows guest access. + $guestcourse = $generator->create_course( + (object)[ + 'shortname' => 'guestcourse', + 'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED, + 'enrol_guest_password_0' => '' + ]); + + $manualevent = (object)[ + 'name' => 'Manual course event', + 'description' => '', + 'format' => 1, + 'categoryid' => 0, + 'courseid' => $manualcourse->id, + 'groupid' => 0, + 'userid' => $USER->id, + 'modulename' => 0, + 'instance' => 0, + 'eventtype' => 'course', + 'timestart' => time(), + 'timeduration' => 86400, + 'visible' => 1 + ]; + $caleventmanual = calendar_event::create($manualevent, false); + + // Create a course event for the course with guest access. + $guestevent = clone $manualevent; + $guestevent->name = 'Guest course event'; + $guestevent->courseid = $guestcourse->id; + $caleventguest = calendar_event::create($guestevent, false); + + // Viewing as admin. + $this->assertTrue(calendar_view_event_allowed($caleventmanual)); + $this->assertTrue(calendar_view_event_allowed($caleventguest)); + + // Viewing as someone enrolled in a course. + $this->setUser($student); + $this->assertTrue(calendar_view_event_allowed($caleventmanual)); + + // Viewing as someone not enrolled in any course. + $this->setUser($someuser); + // Viewing as someone not enrolled in a course without guest access on. + $this->assertFalse(calendar_view_event_allowed($caleventmanual)); + // Viewing as someone not enrolled in a course with guest access on. + $this->assertTrue(calendar_view_event_allowed($caleventguest)); + } } diff --git a/calendar/view.php b/calendar/view.php index 96f39f649e928..dea7508ae5d07 100644 --- a/calendar/view.php +++ b/calendar/view.php @@ -52,11 +52,23 @@ $categoryid = optional_param('category', null, PARAM_INT); $courseid = optional_param('course', SITEID, PARAM_INT); $view = optional_param('view', 'upcoming', PARAM_ALPHA); +$day = optional_param('cal_d', 0, PARAM_INT); +$mon = optional_param('cal_m', 0, PARAM_INT); +$year = optional_param('cal_y', 0, PARAM_INT); $time = optional_param('time', 0, PARAM_INT); $lookahead = optional_param('lookahead', null, PARAM_INT); $url = new moodle_url('/calendar/view.php'); +// If a day, month and year were passed then convert it to a timestamp. If these were passed +// then we can assume the day, month and year are passed as Gregorian, as no where in core +// should we be passing these values rather than the time. This is done for BC. +if (!empty($day) && !empty($mon) && !empty($year)) { + if (checkdate($mon, $day, $year)) { + $time = make_timestamp($year, $mon, $day); + } +} + if (empty($time)) { $time = time(); } diff --git a/comment/lib.php b/comment/lib.php index f8e9f23e4bd18..94c2e92bb1dde 100644 --- a/comment/lib.php +++ b/comment/lib.php @@ -564,7 +564,7 @@ public function get_comments($page = '') { c.commentarea = :commentarea AND c.itemid = :itemid AND $componentwhere - ORDER BY c.timecreated DESC"; + ORDER BY c.timecreated DESC, c.id DESC"; $params['contextid'] = $this->contextid; $params['commentarea'] = $this->commentarea; $params['itemid'] = $this->itemid; diff --git a/comment/tests/externallib_test.php b/comment/tests/externallib_test.php index c6a7433812052..4d81801722964 100644 --- a/comment/tests/externallib_test.php +++ b/comment/tests/externallib_test.php @@ -96,17 +96,18 @@ public function test_get_comments() { // We need to add the comments manually, the comment API uses the global OUTPUT and this is going to make the WS to fail. $newcmt = new stdClass; + $timecreated = time(); $newcmt->contextid = $context->id; $newcmt->commentarea = 'database_entry'; $newcmt->itemid = $recordid; $newcmt->content = 'New comment'; $newcmt->format = 0; $newcmt->userid = $user->id; - $newcmt->timecreated = time(); + $newcmt->timecreated = $timecreated; $cmtid1 = $DB->insert_record('comments', $newcmt); $newcmt->content = 'New comment 2'; - $newcmt->timecreated = time() + 1; + $newcmt->timecreated = $timecreated; $cmtid2 = $DB->insert_record('comments', $newcmt); $contextlevel = 'module'; diff --git a/competency/classes/api.php b/competency/classes/api.php index ffcda6b7fedb8..f231958d4454b 100644 --- a/competency/classes/api.php +++ b/competency/classes/api.php @@ -1231,7 +1231,7 @@ public static function list_course_module_competencies($cmorid) { $result = array(); // TODO We could improve the performance of this into one single query. - $coursemodulecompetencies = course_competency::list_course_module_competencies($cm->id); + $coursemodulecompetencies = course_module_competency::list_course_module_competencies($cm->id); $competencies = course_module_competency::list_competencies($cm->id); // Build the return values. @@ -4777,6 +4777,40 @@ public static function hook_cohort_deleted(\stdClass $cohort) { $DB->delete_records(template_cohort::TABLE, array('cohortid' => $cohort->id)); } + /** + * Action to perform when a user is deleted. + * + * @param int $userid The user id. + */ + public static function hook_user_deleted($userid) { + global $DB; + + $usercompetencies = $DB->get_records(user_competency::TABLE, ['userid' => $userid], '', 'id'); + foreach ($usercompetencies as $usercomp) { + $DB->delete_records(evidence::TABLE, ['usercompetencyid' => $usercomp->id]); + } + + $DB->delete_records(user_competency::TABLE, ['userid' => $userid]); + $DB->delete_records(user_competency_course::TABLE, ['userid' => $userid]); + $DB->delete_records(user_competency_plan::TABLE, ['userid' => $userid]); + + // Delete any associated files. + $fs = get_file_storage(); + $context = context_user::instance($userid); + $userevidences = $DB->get_records(user_evidence::TABLE, ['userid' => $userid], '', 'id'); + foreach ($userevidences as $userevidence) { + $DB->delete_records(user_evidence_competency::TABLE, ['userevidenceid' => $userevidence->id]); + $DB->delete_records(user_evidence::TABLE, ['id' => $userevidence->id]); + $fs->delete_area_files($context->id, 'core_competency', 'userevidence', $userevidence->id); + } + + $userplans = $DB->get_records(plan::TABLE, ['userid' => $userid], '', 'id'); + foreach ($userplans as $userplan) { + $DB->delete_records(plan_competency::TABLE, ['planid' => $userplan->id]); + $DB->delete_records(plan::TABLE, ['id' => $userplan->id]); + } + } + /** * Manually grade a user competency. * diff --git a/competency/tests/api_test.php b/competency/tests/api_test.php index 3ecf3e9942daf..ffe15e344a23a 100644 --- a/competency/tests/api_test.php +++ b/competency/tests/api_test.php @@ -2757,6 +2757,16 @@ public function test_list_course_modules_using_competency() { $result = api::list_course_module_competencies_in_course_module($cm->id); $this->assertEquals($result[0]->get('competencyid'), $c->get('id')); $this->assertEquals($result[1]->get('competencyid'), $c2->get('id')); + + // Now get the course competency and coursemodule competency together. + $result = api::list_course_module_competencies($cm->id); + // Now we should have an array and each element of the array should have a competency and + // a coursemodulecompetency. + foreach ($result as $instance) { + $cmc = $instance['coursemodulecompetency']; + $c = $instance['competency']; + $this->assertEquals($cmc->get('competencyid'), $c->get('id')); + } } /** diff --git a/competency/tests/hooks_test.php b/competency/tests/hooks_test.php index e8be7c41a9a12..c1890eac6f04c 100644 --- a/competency/tests/hooks_test.php +++ b/competency/tests/hooks_test.php @@ -207,4 +207,47 @@ public function test_hook_cohort_deleted() { $this->assertEquals(1, \core_competency\template_cohort::count_records(array('templateid' => $t1->get('id')))); $this->assertEquals(0, \core_competency\template_cohort::count_records(array('templateid' => $t2->get('id')))); } + + public function test_hook_user_deleted() { + $this->resetAfterTest(); + $dg = $this->getDataGenerator(); + $ccg = $dg->get_plugin_generator('core_competency'); + + $u1 = $dg->create_user(); + + $framework = $ccg->create_framework(); + $comp1 = $ccg->create_competency(['competencyframeworkid' => $framework->get('id')]); + $comp2 = $ccg->create_competency(['competencyframeworkid' => $framework->get('id')]); + + $c1 = $dg->create_course(); + $cc1a = $ccg->create_course_competency(['competencyid' => $comp1->get('id'), 'courseid' => $c1->id]); + $cc1b = $ccg->create_course_competency(['competencyid' => $comp2->get('id'), 'courseid' => $c1->id]); + $assign1a = $dg->create_module('assign', ['course' => $c1]); + $assign1b = $dg->create_module('assign', ['course' => $c1]); + $cmc1a = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign1a->cmid]); + $cmc1b = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign1b->cmid]); + $ucc1a = $ccg->create_user_competency_course(['competencyid' => $comp1->get('id'), 'courseid' => $c1->id, + 'userid' => $u1->id]); + $ucc1b = $ccg->create_user_competency_course(['competencyid' => $comp2->get('id'), 'courseid' => $c1->id, + 'userid' => $u1->id]); + + $c2 = $dg->create_course(); + $cc2a = $ccg->create_course_competency(['competencyid' => $comp1->get('id'), 'courseid' => $c2->id]); + $cc2b = $ccg->create_course_competency(['competencyid' => $comp2->get('id'), 'courseid' => $c2->id]); + $assign2a = $dg->create_module('assign', ['course' => $c2]); + $assign2b = $dg->create_module('assign', ['course' => $c2]); + $cmc2a = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign2a->cmid]); + $cmc2b = $ccg->create_course_module_competency(['competencyid' => $comp1->get('id'), 'cmid' => $assign2b->cmid]); + $ucc2a = $ccg->create_user_competency_course(['competencyid' => $comp1->get('id'), 'courseid' => $c2->id, + 'userid' => $u1->id]); + $ucc2b = $ccg->create_user_competency_course(['competencyid' => $comp2->get('id'), 'courseid' => $c2->id, + 'userid' => $u1->id]); + + reset_course_userdata((object) ['id' => $c1->id, 'reset_competency_ratings' => true]); + + delete_user($u1); + + // Assert the records don't exist anymore. + $this->assertEquals(0, user_competency_course::count_records(['courseid' => $c1->id, 'userid' => $u1->id])); + } } diff --git a/composer.json b/composer.json index 6dc963d3ddce5..a42f7e63e8c2a 100644 --- a/composer.json +++ b/composer.json @@ -4,10 +4,17 @@ "description": "Moodle - the world's open source learning platform", "type": "project", "homepage": "https://moodle.org", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/moodlehq/php-webdriver.git" + } + ], "require-dev": { "phpunit/phpunit": "7.5.*", "phpunit/dbunit": "4.0.*", - "moodlehq/behat-extension": "3.37.0", - "mikey179/vfsstream": "^1.6" + "moodlehq/behat-extension": "3.37.3", + "mikey179/vfsstream": "^1.6", + "instaclick/php-webdriver": "dev-local as 1.x-dev" } } diff --git a/composer.lock b/composer.lock index 948ff212bcddb..b36087ddb2b9c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,46 +4,42 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3517a4473544055cd8523bb076cad8f6", + "content-hash": "ee640e103837d18775219772bd0bfb92", "packages": [], "packages-dev": [ { "name": "behat/behat", - "version": "v3.3.1", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/Behat/Behat.git", - "reference": "44a58c1480d6144b2dc2c2bf02b9cef73c83840d" + "reference": "e4bce688be0c2029dc1700e46058d86428c63cab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Behat/zipball/44a58c1480d6144b2dc2c2bf02b9cef73c83840d", - "reference": "44a58c1480d6144b2dc2c2bf02b9cef73c83840d", + "url": "https://api.github.com/repos/Behat/Behat/zipball/e4bce688be0c2029dc1700e46058d86428c63cab", + "reference": "e4bce688be0c2029dc1700e46058d86428c63cab", "shasum": "" }, "require": { - "behat/gherkin": "^4.4.4", + "behat/gherkin": "^4.5.1", "behat/transliterator": "^1.2", - "container-interop/container-interop": "^1.1", + "container-interop/container-interop": "^1.2", "ext-mbstring": "*", "php": ">=5.3.3", + "psr/container": "^1.0", "symfony/class-loader": "~2.1||~3.0", - "symfony/config": "~2.3||~3.0", - "symfony/console": "~2.5||~3.0", - "symfony/dependency-injection": "~2.1||~3.0", - "symfony/event-dispatcher": "~2.1||~3.0", - "symfony/translation": "~2.3||~3.0", - "symfony/yaml": "~2.1||~3.0" + "symfony/config": "~2.3||~3.0||~4.0", + "symfony/console": "~2.7.40||^2.8.33||~3.3.15||^3.4.3||^4.0.3", + "symfony/dependency-injection": "~2.1||~3.0||~4.0", + "symfony/event-dispatcher": "~2.1||~3.0||~4.0", + "symfony/translation": "~2.3||~3.0||~4.0", + "symfony/yaml": "~2.1||~3.0||~4.0" }, "require-dev": { "herrera-io/box": "~1.6.1", - "phpunit/phpunit": "~4.5", - "symfony/process": "~2.5|~3.0" - }, - "suggest": { - "behat/mink-extension": "for integration with Mink testing framework", - "behat/symfony2-extension": "for integration with Symfony2 web framework", - "behat/yii-extension": "for integration with Yii web framework" + "phpunit/phpunit": "^4.8.36|^6.3", + "symfony/process": "~2.5|~3.0|~4.0" }, "bin": [ "bin/behat" @@ -51,7 +47,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2.x-dev" + "dev-master": "3.5.x-dev" } }, "autoload": { @@ -87,7 +83,7 @@ "symfony", "testing" ], - "time": "2017-05-15T16:49:16+00:00" + "time": "2018-08-10T18:56:51+00:00" }, { "name": "behat/gherkin", @@ -625,27 +621,28 @@ }, { "name": "guzzlehttp/guzzle", - "version": "6.3.3", + "version": "6.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba" + "reference": "0895c932405407fd3a7368b6910c09a24d26db11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/0895c932405407fd3a7368b6910c09a24d26db11", + "reference": "0895c932405407fd3a7368b6910c09a24d26db11", "shasum": "" }, "require": { + "ext-json": "*", "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.4", + "guzzlehttp/psr7": "^1.6.1", "php": ">=5.5" }, "require-dev": { "ext-curl": "*", "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", - "psr/log": "^1.0" + "psr/log": "^1.1" }, "suggest": { "psr/log": "Required for using the Log middleware" @@ -657,12 +654,12 @@ } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" - } + }, + "files": [ + "src/functions_include.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -686,7 +683,7 @@ "rest", "web service" ], - "time": "2018-04-22T15:46:56+00:00" + "time": "2019-10-23T15:58:00+00:00" }, { "name": "guzzlehttp/promises", @@ -741,33 +738,37 @@ }, { "name": "guzzlehttp/psr7", - "version": "1.5.2", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "9f83dded91781a01c63574e387eaa769be769115" + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/9f83dded91781a01c63574e387eaa769be769115", - "reference": "9f83dded91781a01c63574e387eaa769be769115", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", "shasum": "" }, "require": { "php": ">=5.4.0", "psr/http-message": "~1.0", - "ralouphie/getallheaders": "^2.0.5" + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" }, "provide": { "psr/http-message-implementation": "1.0" }, "require-dev": { + "ext-zlib": "*", "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -804,20 +805,20 @@ "uri", "url" ], - "time": "2018-12-04T20:46:45+00:00" + "time": "2019-07-01T23:21:34+00:00" }, { "name": "instaclick/php-webdriver", - "version": "1.4.5", + "version": "dev-local", "source": { "type": "git", - "url": "https://github.com/instaclick/php-webdriver.git", - "reference": "6fa959452e774dcaed543faad3a9d1a37d803327" + "url": "https://github.com/moodlehq/php-webdriver.git", + "reference": "3df827208ec104a9716aa8c30741e330da620c1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/instaclick/php-webdriver/zipball/6fa959452e774dcaed543faad3a9d1a37d803327", - "reference": "6fa959452e774dcaed543faad3a9d1a37d803327", + "url": "https://api.github.com/repos/moodlehq/php-webdriver/zipball/3df827208ec104a9716aa8c30741e330da620c1e", + "reference": "3df827208ec104a9716aa8c30741e330da620c1e", "shasum": "" }, "require": { @@ -839,7 +840,6 @@ "WebDriver": "lib/" } }, - "notification-url": "https://packagist.org/downloads/", "license": [ "Apache-2.0" ], @@ -863,27 +863,30 @@ "webdriver", "webtest" ], - "time": "2017-06-30T04:02:48+00:00" + "support": { + "source": "https://github.com/moodlehq/php-webdriver/tree/local" + }, + "time": "2019-08-14T02:10:24+00:00" }, { - "name": "mikey179/vfsStream", - "version": "v1.6.6", + "name": "mikey179/vfsstream", + "version": "v1.6.7", "source": { "type": "git", "url": "https://github.com/bovigo/vfsStream.git", - "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d" + "reference": "2b544ac3a21bcc4dde5d90c4ae8d06f4319055fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/095238a0711c974ae5b4ebf4c4534a23f3f6c99d", - "reference": "095238a0711c974ae5b4ebf4c4534a23f3f6c99d", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/2b544ac3a21bcc4dde5d90c4ae8d06f4319055fb", + "reference": "2b544ac3a21bcc4dde5d90c4ae8d06f4319055fb", "shasum": "" }, "require": { "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "~4.5" + "phpunit/phpunit": "^4.5|^5.0" }, "type": "library", "extra": { @@ -909,30 +912,29 @@ ], "description": "Virtual file system to mock the real file system in unit tests.", "homepage": "http://vfs.bovigo.org/", - "time": "2019-04-08T13:54:32+00:00" + "time": "2019-08-01T01:38:37+00:00" }, { "name": "moodlehq/behat-extension", - "version": "v3.37.0", + "version": "v3.37.3", "source": { "type": "git", "url": "https://github.com/moodlehq/moodle-behat-extension.git", - "reference": "ba8c4b8b323e05f7af128604f3f3dc60c953135a" + "reference": "c4b69596142fa297ba049e13f04f3b158dcc94e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/ba8c4b8b323e05f7af128604f3f3dc60c953135a", - "reference": "ba8c4b8b323e05f7af128604f3f3dc60c953135a", + "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/c4b69596142fa297ba049e13f04f3b158dcc94e6", + "reference": "c4b69596142fa297ba049e13f04f3b158dcc94e6", "shasum": "" }, "require": { - "behat/behat": "3.3.*", + "behat/behat": "3.5.*", "behat/mink": "~1.7", "behat/mink-extension": "~2.2", "behat/mink-goutte-driver": "~1.2", "behat/mink-selenium2-driver": "~1.3", - "guzzlehttp/guzzle": "^6.3", - "php": ">=5.4.4", + "php": ">=7.1.0", "symfony/process": "2.8.*" }, "type": "library", @@ -959,20 +961,20 @@ "Behat", "moodle" ], - "time": "2018-02-04T18:04:02+00:00" + "time": "2019-10-28T11:41:43+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.9.1", + "version": "1.9.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72" + "reference": "007c053ae6f31bba39dfa19a7726f56e9763bbea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", - "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/007c053ae6f31bba39dfa19a7726f56e9763bbea", + "reference": "007c053ae6f31bba39dfa19a7726f56e9763bbea", "shasum": "" }, "require": { @@ -1007,7 +1009,7 @@ "object", "object graph" ], - "time": "2019-04-07T13:18:21+00:00" + "time": "2019-08-09T12:45:53+00:00" }, { "name": "phar-io/manifest", @@ -1113,35 +1115,33 @@ }, { "name": "phpdocumentor/reflection-common", - "version": "1.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6" + "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", - "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a", + "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a", "shasum": "" }, "require": { - "php": ">=5.5" + "php": ">=7.1" }, "require-dev": { - "phpunit/phpunit": "^4.6" + "phpunit/phpunit": "~6" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src" - ] + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1163,30 +1163,30 @@ "reflection", "static analysis" ], - "time": "2017-09-11T18:02:19+00:00" + "time": "2018-08-07T13:53:10+00:00" }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.1", + "version": "4.3.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c" + "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", - "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/b83ff7cfcfee7827e1e78b637a5904fe6a96698e", + "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e", "shasum": "" }, "require": { "php": "^7.0", - "phpdocumentor/reflection-common": "^1.0.0", - "phpdocumentor/type-resolver": "^0.4.0", + "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0", + "phpdocumentor/type-resolver": "~0.4 || ^1.0.0", "webmozart/assert": "^1.0" }, "require-dev": { - "doctrine/instantiator": "~1.0.5", + "doctrine/instantiator": "^1.0.5", "mockery/mockery": "^1.0", "phpunit/phpunit": "^6.4" }, @@ -1214,41 +1214,40 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2019-04-30T17:48:53+00:00" + "time": "2019-09-12T14:27:41+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "0.4.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7" + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7", - "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", "shasum": "" }, "require": { - "php": "^5.5 || ^7.0", - "phpdocumentor/reflection-common": "^1.0" + "php": "^7.1", + "phpdocumentor/reflection-common": "^2.0" }, "require-dev": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^5.2||^4.8.24" + "ext-tokenizer": "^7.1", + "mockery/mockery": "~1", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1261,26 +1260,27 @@ "email": "me@mikevanriel.com" } ], - "time": "2017-07-14T14:27:02+00:00" + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "time": "2019-08-22T18:11:29+00:00" }, { "name": "phpspec/prophecy", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/f6811d96d97bdf400077a0cc100ae56aa32b9203", + "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", "sebastian/comparator": "^1.1|^2.0|^3.0", "sebastian/recursion-context": "^1.0|^2.0|^3.0" }, @@ -1295,8 +1295,8 @@ } }, "autoload": { - "psr-0": { - "Prophecy\\": "src/" + "psr-4": { + "Prophecy\\": "src/Prophecy" } }, "notification-url": "https://packagist.org/downloads/", @@ -1324,7 +1324,7 @@ "spy", "stub" ], - "time": "2018-08-05T17:53:17+00:00" + "time": "2019-10-03T11:07:50+00:00" }, { "name": "phpunit/dbunit", @@ -1535,16 +1535,16 @@ }, { "name": "phpunit/php-timer", - "version": "2.1.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059" + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b389aebe1b8b0578430bda0c7c95a829608e059", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", "shasum": "" }, "require": { @@ -1580,20 +1580,20 @@ "keywords": [ "timer" ], - "time": "2019-02-20T10:12:59+00:00" + "time": "2019-06-07T04:22:29+00:00" }, { "name": "phpunit/php-token-stream", - "version": "3.0.1", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" + "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff", + "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff", "shasum": "" }, "require": { @@ -1606,7 +1606,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -1629,20 +1629,20 @@ "keywords": [ "tokenizer" ], - "time": "2018-10-30T05:52:18+00:00" + "time": "2019-09-17T06:23:10+00:00" }, { "name": "phpunit/phpunit", - "version": "7.5.9", + "version": "7.5.17", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "134669cf0eeac3f79bc7f0c793efbc158bffc160" + "reference": "4c92a15296e58191a4cd74cff3b34fc8e374174a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/134669cf0eeac3f79bc7f0c793efbc158bffc160", - "reference": "134669cf0eeac3f79bc7f0c793efbc158bffc160", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4c92a15296e58191a4cd74cff3b34fc8e374174a", + "reference": "4c92a15296e58191a4cd74cff3b34fc8e374174a", "shasum": "" }, "require": { @@ -1713,7 +1713,7 @@ "testing", "xunit" ], - "time": "2019-04-19T15:50:46+00:00" + "time": "2019-10-28T10:37:36+00:00" }, { "name": "psr/container", @@ -1816,16 +1816,16 @@ }, { "name": "psr/log", - "version": "1.1.0", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" + "reference": "bf73deb2b3b896a9d9c75f3f0d88185d2faa27e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "url": "https://api.github.com/repos/php-fig/log/zipball/bf73deb2b3b896a9d9c75f3f0d88185d2faa27e2", + "reference": "bf73deb2b3b896a9d9c75f3f0d88185d2faa27e2", "shasum": "" }, "require": { @@ -1834,7 +1834,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -1859,28 +1859,28 @@ "psr", "psr-3" ], - "time": "2018-11-20T15:27:04+00:00" + "time": "2019-10-25T08:06:51+00:00" }, { "name": "ralouphie/getallheaders", - "version": "2.0.5", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa" + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/5601c8a83fbba7ef674a7369456d12f1e0d0eafa", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "~3.7.0", - "satooshi/php-coveralls": ">=1.0" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", "autoload": { @@ -1899,7 +1899,7 @@ } ], "description": "A polyfill for getallheaders.", - "time": "2016-02-11T07:05:27+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -2121,16 +2121,16 @@ }, { "name": "sebastian/exporter", - "version": "3.1.0", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "234199f4528de6d12aaa58b612e98f7d36adb937" + "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/234199f4528de6d12aaa58b612e98f7d36adb937", - "reference": "234199f4528de6d12aaa58b612e98f7d36adb937", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e", + "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e", "shasum": "" }, "require": { @@ -2157,6 +2157,10 @@ "BSD-3-Clause" ], "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" @@ -2165,17 +2169,13 @@ "name": "Volker Dusch", "email": "github@wallbash.com" }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, { "name": "Adam Harvey", "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], "description": "Provides the functionality to export PHP variables for visualization", @@ -2184,7 +2184,7 @@ "export", "exporter" ], - "time": "2017-04-03T13:19:02+00:00" + "time": "2019-09-14T09:02:43+00:00" }, { "name": "sebastian/global-state", @@ -2469,16 +2469,16 @@ }, { "name": "symfony/browser-kit", - "version": "v4.2.8", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "c09c18cca96d7067152f78956faf55346c338283" + "reference": "78b7611c45039e8ce81698be319851529bf040b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/c09c18cca96d7067152f78956faf55346c338283", - "reference": "c09c18cca96d7067152f78956faf55346c338283", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/78b7611c45039e8ce81698be319851529bf040b1", + "reference": "78b7611c45039e8ce81698be319851529bf040b1", "shasum": "" }, "require": { @@ -2487,6 +2487,8 @@ }, "require-dev": { "symfony/css-selector": "~3.4|~4.0", + "symfony/http-client": "^4.3", + "symfony/mime": "^4.3", "symfony/process": "~3.4|~4.0" }, "suggest": { @@ -2495,7 +2497,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -2522,20 +2524,20 @@ ], "description": "Symfony BrowserKit Component", "homepage": "https://symfony.com", - "time": "2019-04-07T09:56:43+00:00" + "time": "2019-09-10T11:25:17+00:00" }, { "name": "symfony/class-loader", - "version": "v3.4.27", + "version": "v3.4.32", "source": { "type": "git", "url": "https://github.com/symfony/class-loader.git", - "reference": "4459eef5298dedfb69f771186a580062b8516497" + "reference": "e212b06996819a2bce026a63da03b7182d05a690" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/class-loader/zipball/4459eef5298dedfb69f771186a580062b8516497", - "reference": "4459eef5298dedfb69f771186a580062b8516497", + "url": "https://api.github.com/repos/symfony/class-loader/zipball/e212b06996819a2bce026a63da03b7182d05a690", + "reference": "e212b06996819a2bce026a63da03b7182d05a690", "shasum": "" }, "require": { @@ -2578,36 +2580,36 @@ ], "description": "Symfony ClassLoader Component", "homepage": "https://symfony.com", - "time": "2019-01-16T09:39:14+00:00" + "time": "2019-08-20T13:31:17+00:00" }, { "name": "symfony/config", - "version": "v3.4.27", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "177a276c01575253c95cefe0866e3d1b57637fe0" + "reference": "0acb26407a9e1a64a275142f0ae5e36436342720" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/177a276c01575253c95cefe0866e3d1b57637fe0", - "reference": "177a276c01575253c95cefe0866e3d1b57637fe0", + "url": "https://api.github.com/repos/symfony/config/zipball/0acb26407a9e1a64a275142f0ae5e36436342720", + "reference": "0acb26407a9e1a64a275142f0ae5e36436342720", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/filesystem": "~2.8|~3.0|~4.0", + "php": "^7.1.3", + "symfony/filesystem": "~3.4|~4.0", "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "symfony/dependency-injection": "<3.3", - "symfony/finder": "<3.3" + "symfony/finder": "<3.4" }, "require-dev": { - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/event-dispatcher": "~3.3|~4.0", - "symfony/finder": "~3.3|~4.0", - "symfony/yaml": "~3.0|~4.0" + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/finder": "~3.4|~4.0", + "symfony/messenger": "~4.1", + "symfony/yaml": "~3.4|~4.0" }, "suggest": { "symfony/yaml": "To use the yaml reference dumper" @@ -2615,7 +2617,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -2642,7 +2644,7 @@ ], "description": "Symfony Config Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:06:07+00:00" + "time": "2019-09-19T15:51:53+00:00" }, { "name": "symfony/console", @@ -2714,16 +2716,16 @@ }, { "name": "symfony/css-selector", - "version": "v3.4.27", + "version": "v3.4.32", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "8ca29297c29b64fb3a1a135e71cb25f67f9fdccf" + "reference": "f819f71ae3ba6f396b4c015bd5895de7d2f1f85f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/8ca29297c29b64fb3a1a135e71cb25f67f9fdccf", - "reference": "8ca29297c29b64fb3a1a135e71cb25f67f9fdccf", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/f819f71ae3ba6f396b4c015bd5895de7d2f1f85f", + "reference": "f819f71ae3ba6f396b4c015bd5895de7d2f1f85f", "shasum": "" }, "require": { @@ -2748,14 +2750,14 @@ "MIT" ], "authors": [ - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" @@ -2763,20 +2765,20 @@ ], "description": "Symfony CssSelector Component", "homepage": "https://symfony.com", - "time": "2019-01-16T09:39:14+00:00" + "time": "2019-10-01T11:57:37+00:00" }, { "name": "symfony/debug", - "version": "v3.4.27", + "version": "v3.4.32", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "681afbb26488903c5ac15e63734f1d8ac430c9b9" + "reference": "b3e7ce815d82196435d16dc458023f8fb6b36ceb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/681afbb26488903c5ac15e63734f1d8ac430c9b9", - "reference": "681afbb26488903c5ac15e63734f1d8ac430c9b9", + "url": "https://api.github.com/repos/symfony/debug/zipball/b3e7ce815d82196435d16dc458023f8fb6b36ceb", + "reference": "b3e7ce815d82196435d16dc458023f8fb6b36ceb", "shasum": "" }, "require": { @@ -2819,7 +2821,7 @@ ], "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "time": "2019-04-11T09:48:14+00:00" + "time": "2019-09-19T15:32:51+00:00" }, { "name": "symfony/dependency-injection", @@ -2893,16 +2895,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v4.2.8", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "53c97769814c80a84a8403efcf3ae7ae966d53bb" + "reference": "e9f7b4d19d69b133bd638eeddcdc757723b4211f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/53c97769814c80a84a8403efcf3ae7ae966d53bb", - "reference": "53c97769814c80a84a8403efcf3ae7ae966d53bb", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/e9f7b4d19d69b133bd638eeddcdc757723b4211f", + "reference": "e9f7b4d19d69b133bd638eeddcdc757723b4211f", "shasum": "" }, "require": { @@ -2910,7 +2912,11 @@ "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0" }, + "conflict": { + "masterminds/html5": "<2.6" + }, "require-dev": { + "masterminds/html5": "^2.6", "symfony/css-selector": "~3.4|~4.0" }, "suggest": { @@ -2919,7 +2925,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -2946,20 +2952,20 @@ ], "description": "Symfony DomCrawler Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-09-28T21:25:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.27", + "version": "v3.4.32", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff" + "reference": "3e922c4c3430b9de624e8a285dada5e61e230959" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a088aafcefb4eef2520a290ed82e4374092a6dff", - "reference": "a088aafcefb4eef2520a290ed82e4374092a6dff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3e922c4c3430b9de624e8a285dada5e61e230959", + "reference": "3e922c4c3430b9de624e8a285dada5e61e230959", "shasum": "" }, "require": { @@ -3009,20 +3015,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-04-02T08:51:52+00:00" + "time": "2019-08-23T08:05:57+00:00" }, { "name": "symfony/filesystem", - "version": "v4.2.8", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601" + "reference": "9abbb7ef96a51f4d7e69627bc6f63307994e4263" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e16b9e471703b2c60b95f14d31c1239f68f11601", - "reference": "e16b9e471703b2c60b95f14d31c1239f68f11601", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9abbb7ef96a51f4d7e69627bc6f63307994e4263", + "reference": "9abbb7ef96a51f4d7e69627bc6f63307994e4263", "shasum": "" }, "require": { @@ -3032,7 +3038,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -3059,20 +3065,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-02-07T11:40:08+00:00" + "time": "2019-08-20T14:07:54+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "82ebae02209c21113908c229e9883c419720738a" + "reference": "550ebaac289296ce228a706d0867afc34687e3f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", - "reference": "82ebae02209c21113908c229e9883c419720738a", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/550ebaac289296ce228a706d0867afc34687e3f4", + "reference": "550ebaac289296ce228a706d0867afc34687e3f4", "shasum": "" }, "require": { @@ -3084,7 +3090,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -3100,13 +3106,13 @@ "MIT" ], "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, { "name": "Gert de Pagter", "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], "description": "Symfony polyfill for ctype functions", @@ -3117,20 +3123,20 @@ "polyfill", "portable" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", - "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/b42a2f66e8f1b15ccf25652c3424265923eb4f17", + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17", "shasum": "" }, "require": { @@ -3142,7 +3148,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -3176,7 +3182,7 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/process", @@ -3349,16 +3355,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.1.2", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "1c42705be2b6c1de5904f8afacef5895cab44bf8" + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/1c42705be2b6c1de5904f8afacef5895cab44bf8", - "reference": "1c42705be2b6c1de5904f8afacef5895cab44bf8", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", "shasum": "" }, "require": { @@ -3385,20 +3391,20 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "time": "2019-04-04T09:56:43+00:00" + "time": "2019-06-13T22:48:21+00:00" }, { "name": "webmozart/assert", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9" + "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/83e253c8e0be5b0257b881e1827274667c5c17a9", - "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9", + "url": "https://api.github.com/repos/webmozart/assert/zipball/88e6d84706d09a236046d686bbea96f07b3a34f4", + "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4", "shasum": "" }, "require": { @@ -3406,8 +3412,7 @@ "symfony/polyfill-ctype": "^1.8" }, "require-dev": { - "phpunit/phpunit": "^4.6", - "sebastian/version": "^1.0.1" + "phpunit/phpunit": "^4.8.36 || ^7.5.13" }, "type": "library", "extra": { @@ -3436,12 +3441,21 @@ "check", "validate" ], - "time": "2018-12-25T11:19:39+00:00" + "time": "2019-08-24T08:43:50+00:00" + } + ], + "aliases": [ + { + "alias": "1.x-dev", + "alias_normalized": "1.9999999.9999999.9999999-dev", + "version": "dev-local", + "package": "instaclick/php-webdriver" } ], - "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "instaclick/php-webdriver": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": [], diff --git a/config-dist.php b/config-dist.php index 5f4258a3b1644..5a9df8d92b32c 100644 --- a/config-dist.php +++ b/config-dist.php @@ -543,7 +543,7 @@ // // Location for lock files used by the File locking factory. This must exist // on a shared file system that supports locking. -// $CFG->lock_file_root = $CFG->dataroot . '/lock'; +// $CFG->file_lock_root = $CFG->dataroot . '/lock'; // // // Alternative task logging. @@ -834,6 +834,10 @@ // Example: // $CFG->behat_faildump_path = '/my/path/to/save/failure/dumps'; // +// You can make behat pause upon failure to help you diagnose and debug problems with your tests. +// +// $CFG->behat_pause_on_fail = true; +// // You can specify db, selenium wd_host etc. for behat parallel run by setting following variable. // Example: // $CFG->behat_parallel_run = array ( diff --git a/course/amd/build/repository.min.js b/course/amd/build/repository.min.js index 5ea972f7cb2e7..f4d899c121371 100644 --- a/course/amd/build/repository.min.js +++ b/course/amd/build/repository.min.js @@ -1 +1 @@ -define(["jquery","core/ajax"],function(a,b){var c=function(a,c,d,e){var f={classification:a};"undefined"!=typeof c&&(f.limit=c),"undefined"!=typeof d&&(f.offset=d),"undefined"!=typeof e&&(f.sort=e);var g={methodname:"core_course_get_enrolled_courses_by_timeline_classification",args:f};return b.call([g])[0]},d=function(a,c,d,e){var f={};"undefined"!=typeof a&&(f.limit=c),"undefined"!=typeof c&&(f.limit=c),"undefined"!=typeof d&&(f.offset=d),"undefined"!=typeof e&&(f.sort=e);var g={methodname:"core_course_get_recent_courses",args:f};return b.call([g])[0]};return{getEnrolledCoursesByTimelineClassification:c,getLastAccessedCourses:d}}); \ No newline at end of file +define(["jquery","core/ajax"],function(a,b){var c=function(a,c,d,e){var f={classification:a};"undefined"!=typeof c&&(f.limit=c),"undefined"!=typeof d&&(f.offset=d),"undefined"!=typeof e&&(f.sort=e);var g={methodname:"core_course_get_enrolled_courses_by_timeline_classification",args:f};return b.call([g])[0]},d=function(a,c,d,e){var f={};"undefined"!=typeof a&&(f.userid=a),"undefined"!=typeof c&&(f.limit=c),"undefined"!=typeof d&&(f.offset=d),"undefined"!=typeof e&&(f.sort=e);var g={methodname:"core_course_get_recent_courses",args:f};return b.call([g])[0]};return{getEnrolledCoursesByTimelineClassification:c,getLastAccessedCourses:d}}); \ No newline at end of file diff --git a/course/amd/src/repository.js b/course/amd/src/repository.js index 98b67d5a24df1..6340eab31b125 100644 --- a/course/amd/src/repository.js +++ b/course/amd/src/repository.js @@ -71,7 +71,7 @@ define(['jquery', 'core/ajax'], function($, Ajax) { var args = {}; if (typeof userid !== 'undefined') { - args.limit = limit; + args.userid = userid; } if (typeof limit !== 'undefined') { diff --git a/course/classes/analytics/indicator/activities_due.php b/course/classes/analytics/indicator/activities_due.php index 425785173bce5..b87da32734f71 100644 --- a/course/classes/analytics/indicator/activities_due.php +++ b/course/classes/analytics/indicator/activities_due.php @@ -68,8 +68,10 @@ public static function required_sample_data() { */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { + $user = $this->retrieve('user', $sampleid); + $actionevents = \core_calendar_external::get_calendar_action_events_by_timesort($starttime, $endtime, 0, 1, - true, $sampleid); + true, $user->id); if ($actionevents->events) { diff --git a/course/classes/analytics/target/course_enrolments.php b/course/classes/analytics/target/course_enrolments.php index e97112a4172bc..f5321928e5b83 100644 --- a/course/classes/analytics/target/course_enrolments.php +++ b/course/classes/analytics/target/course_enrolments.php @@ -74,6 +74,10 @@ public function is_valid_analysable(\core_analytics\analysable $course, $fortrai return get_string('coursenotyetstarted', 'course'); } + if (!$fortraining && !$course->get_course_data()->visible) { + return get_string('hiddenfromstudents'); + } + if (!$this->students = $course->get_students()) { return get_string('nocoursestudents', 'course'); } @@ -122,6 +126,8 @@ public function is_valid_analysable(\core_analytics\analysable $course, $fortrai */ public function is_valid_sample($sampleid, \core_analytics\analysable $course, $fortraining = true) { + $now = time(); + $userenrol = $this->retrieve('user_enrolments', $sampleid); if ($userenrol->timeend && $course->get_start() > $userenrol->timeend) { // Discard enrolments which time end is prior to the course start. This should get rid of @@ -139,9 +145,22 @@ public function is_valid_sample($sampleid, \core_analytics\analysable $course, $ return false; } - if (($userenrol->timestart && $userenrol->timestart > $course->get_end()) || - (!$userenrol->timestart && $userenrol->timecreated > $course->get_end())) { - // Discard user enrolments that starts after the analysable official end. + if ($course->get_end()) { + if (($userenrol->timestart && $userenrol->timestart > $course->get_end()) || + (!$userenrol->timestart && $userenrol->timecreated > $course->get_end())) { + // Discard user enrolments that starts after the analysable official end. + return false; + } + + } + + if ($now < $userenrol->timestart && $userenrol->timestart) { + // Discard enrolments whose start date is after now (no need to check timecreated > $now :P). + return false; + } + + if (!$fortraining && $userenrol->timeend && $userenrol->timeend < $now) { + // We don't want to generate predictions for finished enrolments. return false; } diff --git a/course/classes/category.php b/course/classes/category.php index 251c460823ef1..b6aef77814532 100644 --- a/course/classes/category.php +++ b/course/classes/category.php @@ -717,13 +717,49 @@ public function get_db_record() { * @return mixed */ protected static function get_tree($id) { - global $DB; $coursecattreecache = cache::make('core', 'coursecattree'); $rv = $coursecattreecache->get($id); if ($rv !== false) { return $rv; } + // Might need to rebuild the tree. Put a lock in place to ensure other requests don't try and do this in parallel. + $lockfactory = \core\lock\lock_config::get_lock_factory('core_coursecattree'); + $lock = $lockfactory->get_lock('core_coursecattree_cache', + course_modinfo::COURSE_CACHE_LOCK_WAIT, course_modinfo::COURSE_CACHE_LOCK_EXPIRY); + if ($lock === false) { + // Couldn't get a lock to rebuild the tree. + return []; + } + $rv = $coursecattreecache->get($id); + if ($rv !== false) { + // Tree was built while we were waiting for the lock. + $lock->release(); + return $rv; + } // Re-build the tree. + try { + $all = self::rebuild_coursecattree_cache_contents(); + $coursecattreecache->set_many($all); + } finally { + $lock->release(); + } + if (array_key_exists($id, $all)) { + return $all[$id]; + } + // Requested non-existing category. + return array(); + } + + /** + * Rebuild the course category tree as an array, including an extra "countall" field. + * + * @return array + * @throws coding_exception + * @throws dml_exception + * @throws moodle_exception + */ + private static function rebuild_coursecattree_cache_contents() : array { + global $DB; $sql = "SELECT cc.id, cc.parent, cc.visible FROM {course_categories} cc ORDER BY cc.sortorder"; @@ -760,12 +796,7 @@ protected static function get_tree($id) { } // We must add countall to all in case it was the requested ID. $all['countall'] = $count; - $coursecattreecache->set_many($all); - if (array_key_exists($id, $all)) { - return $all[$id]; - } - // Requested non-existing category. - return array(); + return $all; } /** diff --git a/course/classes/customfield/course_handler.php b/course/classes/customfield/course_handler.php index d7badc85bf768..dede44dcbb960 100644 --- a/course/classes/customfield/course_handler.php +++ b/course/classes/customfield/course_handler.php @@ -68,6 +68,17 @@ public static function create(int $itemid = 0) : \core_customfield\handler { return self::$singleton; } + /** + * Run reset code after unit tests to reset the singleton usage. + */ + public static function reset_caches(): void { + if (!PHPUNIT_TEST) { + throw new \coding_exception('This feature is only intended for use in unit tests'); + } + + static::$singleton = null; + } + /** * The current user can configure custom fields on this component. * diff --git a/course/classes/external/course_summary_exporter.php b/course/classes/external/course_summary_exporter.php index 566af235f793e..65471f7298d34 100644 --- a/course/classes/external/course_summary_exporter.php +++ b/course/classes/external/course_summary_exporter.php @@ -105,6 +105,9 @@ public static function define_properties() { ), 'enddate' => array( 'type' => PARAM_INT, + ), + 'visible' => array( + 'type' => PARAM_BOOL, ) ); } diff --git a/course/edit.php b/course/edit.php index 81626d8f87ae3..87c277a161a75 100644 --- a/course/edit.php +++ b/course/edit.php @@ -166,6 +166,8 @@ if (!empty($CFG->creatornewroleid) and !is_viewing($context, NULL, 'moodle/role:assign') and !is_enrolled($context, NULL, 'moodle/role:assign')) { // Deal with course creators - enrol them internally with default role. + // Note: This does not respect capabilities, the creator will be assigned the default role. + // This is an expected behaviour. See MDL-66683 for further details. enrol_try_internal_enrol($course->id, $USER->id, $CFG->creatornewroleid); } diff --git a/course/editsection.php b/course/editsection.php index b04440bf8fe11..04ece14bf12b5 100644 --- a/course/editsection.php +++ b/course/editsection.php @@ -84,7 +84,14 @@ } } -$editoroptions = array('context'=>$context ,'maxfiles' => EDITOR_UNLIMITED_FILES, 'maxbytes'=>$CFG->maxbytes, 'trusttext'=>false, 'noclean'=>true); +$editoroptions = array( + 'context' => $context, + 'maxfiles' => EDITOR_UNLIMITED_FILES, + 'maxbytes' => $CFG->maxbytes, + 'trusttext' => false, + 'noclean' => true, + 'subdirs' => true +); $courseformat = course_get_format($course); $defaultsectionname = $courseformat->get_default_section_name($section); diff --git a/course/externallib.php b/course/externallib.php index bd4426082a516..23123acf9b470 100644 --- a/course/externallib.php +++ b/course/externallib.php @@ -3716,7 +3716,7 @@ public static function get_module($id, $sectionreturn = null) { } /** - * Return structure for edit_module() + * Return structure for get_module() * * @since Moodle 3.3 * @return external_description diff --git a/course/format/renderer.php b/course/format/renderer.php index 2f94041f73484..b033633a73f56 100644 --- a/course/format/renderer.php +++ b/course/format/renderer.php @@ -488,11 +488,6 @@ protected function section_activity_summary($section, $course, $mods) { foreach ($modinfo->sections[$section->section] as $cmid) { $thismod = $modinfo->cms[$cmid]; - if ($thismod->modname == 'label') { - // Labels are special (not interesting for students)! - continue; - } - if ($thismod->uservisible) { if (isset($sectionmods[$thismod->modname])) { $sectionmods[$thismod->modname]['name'] = $thismod->modplural; @@ -519,7 +514,7 @@ protected function section_activity_summary($section, $course, $mods) { // Output section activities summary: $o = ''; - $o.= html_writer::start_tag('div', array('class' => 'section-summary-activities mdl-right')); + $o.= html_writer::start_tag('div', array('class' => 'section-summary-activities pr-2 mdl-right')); foreach ($sectionmods as $mod) { $o.= html_writer::start_tag('span', array('class' => 'activity-count')); $o.= $mod['name'].': '.$mod['count']; @@ -533,7 +528,7 @@ protected function section_activity_summary($section, $course, $mods) { $a->complete = $complete; $a->total = $total; - $o.= html_writer::start_tag('div', array('class' => 'section-summary-activities mdl-right')); + $o.= html_writer::start_tag('div', array('class' => 'section-summary-activities pr-2 mdl-right')); $o.= html_writer::tag('span', get_string('progresstotal', 'completion', $a), array('class' => 'activity-count')); $o.= html_writer::end_tag('div'); } diff --git a/course/format/singleactivity/lib.php b/course/format/singleactivity/lib.php index 50fa91a7f20a9..5db9dea89ea63 100644 --- a/course/format/singleactivity/lib.php +++ b/course/format/singleactivity/lib.php @@ -36,6 +36,9 @@ class format_singleactivity extends format_base { /** @var cm_info the current activity. Use get_activity() to retrieve it. */ private $activity = false; + /** @var int The category ID guessed from the form data. */ + private $categoryid = false; + /** * The URL to use for the specified course * @@ -145,6 +148,30 @@ public function get_default_blocks() { */ public function course_format_options($foreditform = false) { static $courseformatoptions = false; + + $fetchtypes = $courseformatoptions === false; + $fetchtypes = $fetchtypes || ($foreditform && !isset($courseformatoptions['activitytype']['label'])); + + if ($fetchtypes) { + $availabletypes = $this->get_supported_activities(); + if ($this->course) { + // The course exists. Test against the course. + $testcontext = context_course::instance($this->course->id); + } else if ($this->categoryid) { + // The course does not exist yet, but we have a category ID that we can test against. + $testcontext = context_coursecat::instance($this->categoryid); + } else { + // The course does not exist, and we somehow do not have a category. Test capabilities against the system context. + $testcontext = context_system::instance(); + } + foreach (array_keys($availabletypes) as $activity) { + $capability = "mod/{$activity}:addinstance"; + if (!has_capability($capability, $testcontext)) { + unset($availabletypes[$activity]); + } + } + } + if ($courseformatoptions === false) { $config = get_config('format_singleactivity'); $courseformatoptions = array( @@ -153,9 +180,13 @@ public function course_format_options($foreditform = false) { 'type' => PARAM_TEXT, ), ); + + if (!empty($availabletypes) && !isset($availabletypes[$config->activitytype])) { + $courseformatoptions['activitytype']['default'] = array_keys($availabletypes)[0]; + } } + if ($foreditform && !isset($courseformatoptions['activitytype']['label'])) { - $availabletypes = $this->get_supported_activities(); $courseformatoptionsedit = array( 'activitytype' => array( 'label' => new lang_string('activitytype', 'format_singleactivity'), @@ -183,6 +214,11 @@ public function course_format_options($foreditform = false) { */ public function create_edit_form_elements(&$mform, $forsection = false) { global $PAGE; + + if (!$this->course && $submitvalues = $mform->getSubmitValues()) { + $this->categoryid = $submitvalues['category']; + } + $elements = parent::create_edit_form_elements($mform, $forsection); if (!$forsection && ($course = $PAGE->course) && !empty($course->format) && $course->format !== 'site' && $course->format !== 'singleactivity') { diff --git a/course/format/singleactivity/tests/behat/create_course.feature b/course/format/singleactivity/tests/behat/create_course.feature new file mode 100644 index 0000000000000..6eee07ff1cf6c --- /dev/null +++ b/course/format/singleactivity/tests/behat/create_course.feature @@ -0,0 +1,38 @@ +@format @format_singleactivity +Feature: Courses can be created in Single Activity mode + In order to create a single activity course + As a manager + I need to create courses and set default values on them + + Scenario: Create a course as a custom course creator + Given the following "users" exist: + | username | firstname | lastname | email | + | kevin | Kevin | the | kevin@example.com | + And the following "roles" exist: + | shortname | name | archetype | + | creator | Creator | | + And the following "system role assigns" exist: + | user | role | contextlevel | + | kevin | creator | System | + And I log in as "admin" + And I set the following system permissions of "Creator" role: + | capability | permission | + | moodle/course:create | Allow | + | moodle/course:update | Allow | + | moodle/course:manageactivities | Allow | + | moodle/course:viewparticipants | Allow | + | moodle/role:assign | Allow | + | mod/quiz:addinstance | Allow | + And I log out + And I log in as "kevin" + And I am on site homepage + When I press "Add a new course" + And I set the following fields to these values: + | Course full name | My first course | + | Course short name | myfirstcourse | + | Format | Single activity format | + And I press "Update format" + Then I should see "Quiz" in the "Type of activity" "field" + And I should not see "Forum" in the "Type of activity" "field" + And I press "Save and display" + And I should see "Adding a new Quiz" diff --git a/course/format/social/format.php b/course/format/social/format.php index bde9709102155..44cf6ca12ab4a 100644 --- a/course/format/social/format.php +++ b/course/format/social/format.php @@ -50,7 +50,7 @@ $streditsummary = get_string('editsummary'); $introcontent .= html_writer::start_div('editinglink'); $introcontent .= html_writer::link( - new moodle_url('/modedit.php', [ + new moodle_url('/course/modedit.php', [ 'update' => $coursemodule->id, 'sesskey' => sesskey(), ]), diff --git a/course/format/topics/db/upgrade.php b/course/format/topics/db/upgrade.php index 055a902bca658..6dc38410cc8aa 100644 --- a/course/format/topics/db/upgrade.php +++ b/course/format/topics/db/upgrade.php @@ -65,5 +65,8 @@ function xmldb_format_topics_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/course/format/weeks/db/upgrade.php b/course/format/weeks/db/upgrade.php index bbb141f28c58d..0309ac8001418 100644 --- a/course/format/weeks/db/upgrade.php +++ b/course/format/weeks/db/upgrade.php @@ -101,5 +101,8 @@ function xmldb_format_weeks_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/course/lib.php b/course/lib.php index 25af452c7fab7..1094eb8aa4440 100644 --- a/course/lib.php +++ b/course/lib.php @@ -1144,7 +1144,9 @@ function course_delete_module($cmid, $async = false) { } // Delete activity context questions and question categories. - question_delete_activity($cm); + $showinfo = !defined('AJAX_SCRIPT') || AJAX_SCRIPT == '0'; + + question_delete_activity($cm, $showinfo); // Call the delete_instance function, if it returns false throw an exception. if (!$deleteinstancefunction($cm->instance)) { @@ -1289,16 +1291,36 @@ function course_module_flag_for_async_deletion($cmid) { * Checks whether the given course has any course modules scheduled for adhoc deletion. * * @param int $courseid the id of the course. + * @param bool $onlygradable whether to check only gradable modules or all modules. * @return bool true if the course contains any modules pending deletion, false otherwise. */ -function course_modules_pending_deletion($courseid) { +function course_modules_pending_deletion($courseid, bool $onlygradable = false) : bool { if (empty($courseid)) { return false; } + + if ($onlygradable) { + // Fetch modules with grade items. + if (!$coursegradeitems = grade_item::fetch_all(['itemtype' => 'mod', 'courseid' => $courseid])) { + // Return early when there is none. + return false; + } + } + $modinfo = get_fast_modinfo($courseid); foreach ($modinfo->get_cms() as $module) { if ($module->deletioninprogress == '1') { - return true; + if ($onlygradable) { + // Check if the module being deleted is in the list of course modules with grade items. + foreach ($coursegradeitems as $coursegradeitem) { + if ($coursegradeitem->itemmodule == $module->modname && $coursegradeitem->iteminstance == $module->instance) { + // The module being deleted is within the gradable modules. + return true; + } + } + } else { + return true; + } } } return false; @@ -2182,6 +2204,7 @@ function move_courses($courseids, $categoryid) { foreach ($dbcourses as $dbcourse) { $course = new stdClass(); $course->id = $dbcourse->id; + $course->timemodified = time(); $course->category = $category->id; $course->sortorder = $category->sortorder + MAX_COURSES_IN_CATEGORY - $i++; if ($category->visible == 0) { @@ -3994,7 +4017,6 @@ function course_get_user_administration_options($course, $context) { $options->outcomes = !empty($CFG->enableoutcomes) && has_capability('moodle/course:update', $context); $options->badges = !empty($CFG->enablebadges); $options->import = has_capability('moodle/restore:restoretargetimport', $context); - $options->publish = !empty($CFG->enablecoursepublishing) && has_capability('moodle/course:publish', $context); $options->reset = has_capability('moodle/course:reset', $context); $options->roles = has_capability('moodle/role:switchroles', $context); } else { @@ -4571,7 +4593,7 @@ function course_get_recent_courses(int $userid = null, int $limit = 0, int $offs } $basefields = array('id', 'idnumber', 'summary', 'summaryformat', 'startdate', 'enddate', 'category', - 'shortname', 'fullname', 'timeaccess', 'component'); + 'shortname', 'fullname', 'timeaccess', 'component', 'visible'); $sort = trim($sort); if (empty($sort)) { diff --git a/course/management.php b/course/management.php index 27784d152afdc..ad74a3e2f3b3d 100644 --- a/course/management.php +++ b/course/management.php @@ -69,8 +69,11 @@ $course = null; $courseid = null; $topchildren = core_course_category::top()->get_children(); + if (empty($topchildren)) { + throw new moodle_exception('cannotviewcategory', 'error'); + } $category = reset($topchildren); - $categoryid = $category ? $category->id : 0; + $categoryid = $category->id; $context = context_coursecat::instance($category->id); $url->param('categoryid', $category->id); } @@ -313,7 +316,8 @@ $notificationsfail[] = get_string('movecategoryownparent', 'error', $cattomove->get_formatted_name()); continue; } - if (strpos($movetocat->path, $cattomove->path) === 0) { + // Don't allow user to move selected category into one of it's own sub-categories. + if (strpos($movetocat->path, $cattomove->path . '/') === 0) { $notificationsfail[] = get_string('movecategoryparentconflict', 'error', $cattomove->get_formatted_name()); continue; } diff --git a/course/publish/backup.php b/course/publish/backup.php deleted file mode 100644 index f9f9d7a7ab005..0000000000000 --- a/course/publish/backup.php +++ /dev/null @@ -1,113 +0,0 @@ -. // -// // -/////////////////////////////////////////////////////////////////////////// - -/** - * This page display the publication backup form - * - * @package course - * @subpackage publish - * @author Jerome Mouneyrac - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL - * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com - */ - -define('NO_OUTPUT_BUFFERING', true); - -require_once('../../config.php'); -require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); -require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php'); -require_once($CFG->libdir . '/filelib.php'); - - -//retrieve initial page parameters -$id = required_param('id', PARAM_INT); -$hubcourseid = required_param('hubcourseid', PARAM_INT); - -//some permissions and parameters checking -$course = $DB->get_record('course', array('id'=>$id), '*', MUST_EXIST); -require_login($course); - -$context = context_course::instance($course->id); -if (empty($CFG->enablecoursepublishing) || !has_capability('moodle/course:publish', $context) || !confirm_sesskey()) { - throw new moodle_exception('nopermission'); -} - -//page settings -$PAGE->set_url('/course/publish/backup.php'); -$PAGE->set_pagelayout('incourse'); -$PAGE->set_title(get_string('course') . ': ' . $course->fullname); -$PAGE->set_heading($course->fullname); - -//BEGIN backup processing -$backupid = optional_param('backup', false, PARAM_ALPHANUM); -if (!($bc = backup_ui::load_controller($backupid))) { - $bc = new backup_controller(backup::TYPE_1COURSE, $id, backup::FORMAT_MOODLE, - backup::INTERACTIVE_YES, backup::MODE_HUB, $USER->id); -} -$backup = new backup_ui($bc, - array('id' => $id, 'hubcourseid' => $hubcourseid, 'huburl' => HUB_MOODLEORGHUBURL, 'hubname' => 'Moodle.net')); -$backup->process(); -if ($backup->get_stage() == backup_ui::STAGE_FINAL) { - $backup->execute(); -} else { - $backup->save_controller(); -} - -if ($backup->get_stage() !== backup_ui::STAGE_COMPLETE) { - $renderer = $PAGE->get_renderer('core', 'backup'); - echo $OUTPUT->header(); - echo $OUTPUT->heading(get_string('publishcourseon', 'hub', 'Moodle.net'), 3, 'main'); - if ($backup->enforce_changed_dependencies()) { - debugging('Your settings have been altered due to unmet dependencies', DEBUG_DEVELOPER); - } - echo $renderer->progress_bar($backup->get_progress_bar()); - echo $backup->display($renderer); - echo $OUTPUT->footer(); - die(); -} - -//$backupfile = $backup->get_stage_results(); -$backupfile = $bc->get_results(); -$backupfile = $backupfile['backup_destination']; -//END backup processing - -//display the sending file page -echo $OUTPUT->header(); -echo $OUTPUT->heading(get_string('sendingcourse', 'hub'), 3, 'main'); -$renderer = $PAGE->get_renderer('core', 'course'); -echo $renderer->sendingbackupinfo($backupfile); -if (ob_get_level()) { - ob_flush(); -} -flush(); - -//send backup file to the hub -\core\hub\publication::upload_course_backup($hubcourseid, $backupfile); - -//delete the temp backup file from user_tohub aera -$backupfile->delete(); -$bc->destroy(); - -//Output sending success -echo $renderer->sentbackupinfo($id, HUB_MOODLEORGHUBURL, 'Moodle.net'); - -echo $OUTPUT->footer(); diff --git a/course/publish/forms.php b/course/publish/forms.php deleted file mode 100644 index 288475e0aae0b..0000000000000 --- a/course/publish/forms.php +++ /dev/null @@ -1,36 +0,0 @@ -. // -// // -/////////////////////////////////////////////////////////////////////////// - -/** - * @package course - * @subpackage publish - * @author Jerome Mouneyrac - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL - * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com - * - * The forms used for course publication - */ - -defined('MOODLE_INTERNAL') || die(); - -debugging('Support for alternative hubs has been removed from Moodle in 3.4. For communication with moodle.net ' . - 'see lib/classes/hub/ .', DEBUG_DEVELOPER); diff --git a/course/publish/index.php b/course/publish/index.php deleted file mode 100644 index 7bff4732681ff..0000000000000 --- a/course/publish/index.php +++ /dev/null @@ -1,99 +0,0 @@ -. - -/* - * @package course - * @subpackage publish - * @author Jerome Mouneyrac - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL - * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com - * - * The user selects if he wants to publish the course on Moodle.org hub or - * on a specific hub. The site must be registered on a hub to be able to - * publish a course on it. -*/ - -require('../../config.php'); - -$courseid = required_param('id', PARAM_INT); // Course id. -$publicationid = optional_param('publicationid', 0, PARAM_INT); // Id of course publication to unpublish. - -require_login($courseid); -$shortname = format_string($COURSE->shortname); - -$PAGE->set_url('/course/publish/index.php', array('id' => $courseid)); -$PAGE->set_pagelayout('incourse'); -$PAGE->set_title(get_string('publish', 'core_hub') . ': ' . $COURSE->fullname); -$PAGE->set_heading($COURSE->fullname); - -$context = context_course::instance($courseid); -if (empty($CFG->enablecoursepublishing) || !has_capability('moodle/course:publish', $context)) { - throw new moodle_exception('nopermission'); -} - -// If the site is not registered display an error page. -if (!\core\hub\registration::is_registered()) { - echo $OUTPUT->header(); - echo $OUTPUT->heading(get_string('publishcourseon', 'hub', 'Moodle.net'), 3, 'main'); - echo $OUTPUT->box(get_string('notregisteredonhub', 'hub')); - if (has_capability('moodle/site:config', context_system::instance())) { - echo $OUTPUT->single_button(new moodle_url('/admin/registration/index.php'), get_string('register', 'admin')); - } - echo $OUTPUT->footer(); - die(); -} - -// When hub listing status is requested update statuses of all published courses. -$updatestatusid = optional_param('updatestatusid', false, PARAM_INT); -if (!empty($updatestatusid) && confirm_sesskey()) { - if (core\hub\publication::get_publication($updatestatusid, $courseid)) { - core\hub\publication::request_status_update(); - redirect($PAGE->url); - } -} - -$renderer = $PAGE->get_renderer('core', 'course'); - -// Unpublish course. -if ($publication = \core\hub\publication::get_publication($publicationid, $courseid)) { - $confirm = optional_param('confirm', 0, PARAM_BOOL); - if ($confirm && confirm_sesskey()) { - \core\hub\publication::unpublish($publication); - } else { - // Display confirmation page for unpublishing. - $publication = \core\hub\publication::get_publication($publicationid, $courseid, MUST_EXIST); - $publication->courseshortname = format_string($COURSE->shortname); - echo $OUTPUT->header(); - echo $OUTPUT->heading(get_string('unpublishcourse', 'hub', $shortname), 3, 'main'); - echo $renderer->confirmunpublishing($publication); - echo $OUTPUT->footer(); - die(); - } -} - -// List current publications and "Publish" buttons. -echo $OUTPUT->header(); - -echo $OUTPUT->heading(get_string('publishcourse', 'hub', $shortname), 3, 'main'); -echo $renderer->publicationselector($courseid); - -$publications = \core\hub\publication::get_course_publications($courseid); -if (!empty($publications)) { - echo $OUTPUT->heading(get_string('publishedon', 'hub'), 3, 'main'); - echo $renderer->registeredonhublisting($courseid, $publications); -} - -echo $OUTPUT->footer(); diff --git a/course/publish/metadata.php b/course/publish/metadata.php deleted file mode 100644 index 3042864d8b6ab..0000000000000 --- a/course/publish/metadata.php +++ /dev/null @@ -1,192 +0,0 @@ -. // -// // -/////////////////////////////////////////////////////////////////////////// - -/* - * @package course - * @subpackage publish - * @author Jerome Mouneyrac - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL - * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com - * - * This page display the publication metadata form - */ - -require_once('../../config.php'); -require_once($CFG->libdir . '/filelib.php'); - - -//check user access capability to this page -$id = required_param('id', PARAM_INT); - -$course = $DB->get_record('course', array('id' => $id), '*', MUST_EXIST); -require_login($course); - -//page settings -$PAGE->set_url('/course/publish/metadata.php', array('id' => $course->id)); -$PAGE->set_pagelayout('incourse'); -$PAGE->set_title(get_string('course') . ': ' . $course->fullname); -$PAGE->set_heading($course->fullname); - -$context = context_course::instance($course->id); -if (empty($CFG->enablecoursepublishing) || !has_capability('moodle/course:publish', $context)) { - throw new moodle_exception('nopermission'); -} - -// Retrieve hub name and hub url. -require_sesskey(); - -// Set the publication form. -$advertise = optional_param('advertise', false, PARAM_BOOL); -$publicationid = optional_param('publicationid', false, PARAM_INT); -$formparams = array('course' => $course, 'advertise' => $advertise); -if ($publicationid) { - $publication = \core\hub\publication::get_publication($publicationid, $course->id, MUST_EXIST); - $formparams['publication'] = $publication; - $advertise = $formparams['advertise'] = $publication->enrollable; -} -$share = !$advertise; -$coursepublicationform = new \core\hub\course_publication_form('', $formparams); -$fromform = $coursepublicationform->get_data(); - -if (!empty($fromform)) { - - // Retrieve the course information. - $courseinfo = new stdClass(); - $courseinfo->fullname = $fromform->name; - $courseinfo->shortname = $fromform->courseshortname; - $courseinfo->description = $fromform->description; - $courseinfo->language = $fromform->language; - $courseinfo->publishername = $fromform->publishername; - $courseinfo->publisheremail = $fromform->publisheremail; - $courseinfo->contributornames = $fromform->contributornames; - $courseinfo->coverage = $fromform->coverage; - $courseinfo->creatorname = $fromform->creatorname; - $courseinfo->licenceshortname = $fromform->licence; - $courseinfo->subject = $fromform->subject; - $courseinfo->audience = $fromform->audience; - $courseinfo->educationallevel = $fromform->educationallevel; - $creatornotes = $fromform->creatornotes; - $courseinfo->creatornotes = $creatornotes['text']; - $courseinfo->creatornotesformat = $creatornotes['format']; - $courseinfo->sitecourseid = $id; - if (!empty($fromform->deletescreenshots)) { - $courseinfo->deletescreenshots = $fromform->deletescreenshots; - } - if ($share) { - $courseinfo->demourl = $fromform->demourl; - $courseinfo->enrollable = false; - } else { - $courseinfo->courseurl = $fromform->courseurl; - $courseinfo->enrollable = true; - } - - // Retrieve the outcomes of this course. - require_once($CFG->libdir . '/grade/grade_outcome.php'); - $outcomes = grade_outcome::fetch_all_available($id); - if (!empty($outcomes)) { - foreach ($outcomes as $outcome) { - $sentoutcome = new stdClass(); - $sentoutcome->fullname = $outcome->fullname; - $courseinfo->outcomes[] = $sentoutcome; - } - } - - // Retrieve the content information from the course. - $coursecontext = context_course::instance($course->id); - $courseblocks = \core\hub\publication::get_block_instances_by_context($coursecontext->id); - - if (!empty($courseblocks)) { - $blockname = ''; - foreach ($courseblocks as $courseblock) { - if ($courseblock->blockname != $blockname) { - if (!empty($blockname)) { - $courseinfo->contents[] = $content; - } - - $blockname = $courseblock->blockname; - $content = new stdClass(); - $content->moduletype = 'block'; - $content->modulename = $courseblock->blockname; - $content->contentcount = 1; - } else { - $content->contentcount = $content->contentcount + 1; - } - } - $courseinfo->contents[] = $content; - } - - $activities = get_fast_modinfo($course, $USER->id); - foreach ($activities->instances as $activityname => $activitydetails) { - $content = new stdClass(); - $content->moduletype = 'activity'; - $content->modulename = $activityname; - $content->contentcount = count($activities->instances[$activityname]); - $courseinfo->contents[] = $content; - } - - // Save into screenshots field the references to the screenshot content hash - // (it will be like a unique id from the hub perspective). - if (!empty($fromform->deletescreenshots) or $share) { - $courseinfo->screenshots = 0; - } else { - $courseinfo->screenshots = $fromform->existingscreenshotnumber; - } - $files = []; - if (!empty($fromform->screenshots)) { - $fs = get_file_storage(); - $files = $fs->get_area_files(context_user::instance($USER->id)->id, 'user', 'draft', $fromform->screenshots, - 'filepath, filename', false); - $files = array_filter($files, function(stored_file $file) { - return $file->is_valid_image(); - }); - $courseinfo->screenshots += count($files); - } - - // PUBLISH ACTION. - $hubcourseid = \core\hub\publication::publish_course($courseinfo, $files); - - // Redirect to the backup process page. - if ($share) { - $params = array('sesskey' => sesskey(), 'id' => $id, 'hubcourseid' => $hubcourseid); - $backupprocessurl = new moodle_url("/course/publish/backup.php", $params); - redirect($backupprocessurl); - } else { - // Redirect to the index publis page. - redirect(new moodle_url('/course/publish/index.php', ['id' => $id]), - get_string('coursepublished', 'hub', 'Moodle.net'), null, \core\output\notification::NOTIFY_SUCCESS); - } -} - -// OUTPUT SECTION. - -echo $OUTPUT->header(); -echo $OUTPUT->heading(get_string('publishcourseon', 'hub', 'Moodle.net'), 3, 'main'); - -// Display hub information (logo, name, description). -$renderer = $PAGE->get_renderer('core', 'course'); -if ($hubinfo = \core\hub\registration::get_moodlenet_info()) { - echo $renderer->hubinfo($hubinfo); -} - -// Display metadata form. -$coursepublicationform->display(); -echo $OUTPUT->footer(); diff --git a/course/renderer.php b/course/renderer.php index 9d1439c7ad8bc..30c865b19d0f4 100644 --- a/course/renderer.php +++ b/course/renderer.php @@ -2105,128 +2105,6 @@ public function render_activity_navigation(\core_course\output\activity_navigati return $this->output->render_from_template('core_course/activity_navigation', $data); } - /** - * Display the selector to advertise or publish a course - * @param int $courseid - */ - public function publicationselector($courseid) { - $text = ''; - - $advertiseurl = new moodle_url("/course/publish/metadata.php", - array('sesskey' => sesskey(), 'id' => $courseid, 'advertise' => true)); - $advertisebutton = new single_button($advertiseurl, get_string('advertise', 'hub')); - $text .= $this->output->render($advertisebutton); - $text .= html_writer::tag('div', get_string('advertisepublication_help', 'hub'), - array('class' => 'publishhelp')); - - $text .= html_writer::empty_tag('br'); // TODO Delete. - - $uploadurl = new moodle_url("/course/publish/metadata.php", - array('sesskey' => sesskey(), 'id' => $courseid, 'share' => true)); - $uploadbutton = new single_button($uploadurl, get_string('share', 'hub')); - $text .= $this->output->render($uploadbutton); - $text .= html_writer::tag('div', get_string('sharepublication_help', 'hub'), - array('class' => 'publishhelp')); - - return $text; - } - - /** - * Display the listing of hub where a course is registered on - * @param int $courseid - * @param array $publications - */ - public function registeredonhublisting($courseid, $publications) { - global $CFG; - $table = new html_table(); - $table->head = array(get_string('type', 'hub'), - get_string('date'), get_string('status', 'hub'), get_string('operation', 'hub')); - $table->size = array('20%', '30%', '%20', '%25'); - - $brtag = html_writer::empty_tag('br'); - - foreach ($publications as $publication) { - - $params = array('id' => $publication->courseid, 'publicationid' => $publication->id); - $cancelurl = new moodle_url("/course/publish/index.php", $params); - $cancelbutton = new single_button($cancelurl, get_string('removefromhub', 'hub')); - $cancelbutton->class = 'centeredbutton'; - $cancelbuttonhtml = $this->output->render($cancelbutton); - - if ($publication->enrollable) { - $params = array('sesskey' => sesskey(), 'id' => $publication->courseid, 'publicationid' => $publication->id); - $updateurl = new moodle_url("/course/publish/metadata.php", $params); - $updatebutton = new single_button($updateurl, get_string('update', 'hub')); - $updatebutton->class = 'centeredbutton'; - $updatebuttonhtml = $this->output->render($updatebutton); - - $operations = $updatebuttonhtml . $brtag . $cancelbuttonhtml; - } else { - $operations = $cancelbuttonhtml; - } - - // If the publication check time if bigger than May 2010, it has been checked. - if ($publication->timechecked > 1273127954) { - if ($publication->status == 0) { - $status = get_string('statusunpublished', 'hub'); - } else { - $status = get_string('statuspublished', 'hub'); - if (!empty($publication->link)) { - $status = html_writer::link($publication->link, $status); - } - } - - $status .= $brtag . html_writer::tag('a', get_string('updatestatus', 'hub'), - array('href' => $CFG->wwwroot . '/course/publish/index.php?id=' - . $courseid . "&updatestatusid=" . $publication->id - . "&sesskey=" . sesskey())) . - $brtag . get_string('lasttimechecked', 'hub') . ": " - . format_time(time() - $publication->timechecked); - } else { - $status = get_string('neverchecked', 'hub') . $brtag - . html_writer::tag('a', get_string('updatestatus', 'hub'), - array('href' => $CFG->wwwroot . '/course/publish/index.php?id=' - . $courseid . "&updatestatusid=" . $publication->id - . "&sesskey=" . sesskey())); - } - // Add button cells. - $cells = array($publication->enrollable ? - get_string('advertised', 'hub') : get_string('shared', 'hub'), - userdate($publication->timepublished, - get_string('strftimedatetimeshort')), $status, $operations); - $row = new html_table_row($cells); - $table->data[] = $row; - } - - $contenthtml = html_writer::table($table); - - return $contenthtml; - } - - /** - * Display unpublishing confirmation page - * @param stdClass $publication - * $publication->courseshortname - * $publication->courseid - * $publication->hubname - * $publication->huburl - * $publication->id - */ - public function confirmunpublishing($publication) { - $optionsyes = array('sesskey' => sesskey(), 'id' => $publication->courseid, - 'hubcourseid' => $publication->hubcourseid, - 'cancel' => true, 'publicationid' => $publication->id, 'confirm' => true); - $optionsno = array('sesskey' => sesskey(), 'id' => $publication->courseid); - $publication->hubname = html_writer::tag('a', 'Moodle.net', - array('href' => HUB_MOODLEORGHUBURL)); - $formcontinue = new single_button(new moodle_url("/course/publish/index.php", - $optionsyes), get_string('unpublish', 'hub'), 'post'); - $formcancel = new single_button(new moodle_url("/course/publish/index.php", - $optionsno), get_string('cancel'), 'get'); - return $this->output->confirm(get_string('unpublishconfirmation', 'hub', $publication), - $formcontinue, $formcancel); - } - /** * Display waiting information about backup size during uploading backup process * @param object $backupfile the backup stored_file @@ -2240,23 +2118,6 @@ public function sendingbackupinfo($backupfile) { return $html; } - /** - * Display upload successfull message and a button to the publish index page - * @param int $id the course id - * @return $html string - */ - public function sentbackupinfo($id) { - $html = html_writer::tag('div', get_string('sent', 'hub'), - array('class' => 'courseuploadtextinfo')); - $publishindexurl = new moodle_url('/course/publish/index.php', - array('sesskey' => sesskey(), 'id' => $id, - 'published' => true)); - $continue = $this->output->render( - new single_button($publishindexurl, get_string('continue'))); - $html .= html_writer::tag('div', $continue, array('class' => 'sharecoursecontinue')); - return $html; - } - /** * Hub information (logo - name - description - link) * @param object $hubinfo diff --git a/course/templates/activity_navigation.mustache b/course/templates/activity_navigation.mustache index c3583e1900f03..3b2af237a6d83 100644 --- a/course/templates/activity_navigation.mustache +++ b/course/templates/activity_navigation.mustache @@ -64,7 +64,7 @@ } } }} -
+
{{< core/columns-1to1to1}} {{$column1}}
diff --git a/course/templates/coursecard.mustache b/course/templates/coursecard.mustache index 1aba15c597fd7..74300b9fb6c9a 100644 --- a/course/templates/coursecard.mustache +++ b/course/templates/coursecard.mustache @@ -28,7 +28,8 @@ "courseimage": "https://moodlesite/pluginfile/123/course/overviewfiles/123.jpg", "fullname": "course 3", "hasprogress": true, - "progress": 10 + "progress": 10, + "visible": true } ] } @@ -43,8 +44,8 @@
-
-
+
+
{{$coursecategory}}{{/coursecategory}} {{#showshortname}} {{$divider}}{{/divider}} @@ -56,13 +57,18 @@
{{/showshortname}}
- + {{> core_course/favouriteicon }} {{#str}}aria:coursename, core_course{{/str}} {{$coursename}}{{/coursename}} + {{^visible}} +
+ {{#str}} hiddenfromstudents {{/str}} +
+ {{/visible}}
{{$menu}}{{/menu}}
diff --git a/course/tests/behat/app_course_completion.feature b/course/tests/behat/app_course_completion.feature deleted file mode 100644 index f0c30baa969d1..0000000000000 --- a/course/tests/behat/app_course_completion.feature +++ /dev/null @@ -1,36 +0,0 @@ -@core @core_course @app @javascript -Feature: Check course completion feature. - In order to track the progress of the course on mobile device - As a student - I need to be able to update the activity completion status. - - Background: - Given the following "users" exist: - | username | firstname | lastname | email | - | student1 | Student | 1 | student1@example.com | - And the following "courses" exist: - | fullname | shortname | category | enablecompletion | - | Course 1 | C1 | 0 | 1 | - And the following "course enrolments" exist: - | user | course | role | - | student1 | C1 | student | - - Scenario: Complete the activity manually by clicking at the completion checkbox. - Given the following "activities" exist: - | activity | name | course | idnumber | completion | completionview | - | forum | First forum | C1 | forum1 | 1 | 0 | - | forum | Second forum | C1 | forum2 | 1 | 0 | - When I enter the app - And I log in as "student1" - And I press "Course 1" near "Recently accessed courses" in the app - # Set activities as completed. - And I should see "0%" - And I press "Not completed: First forum. Select to mark as complete." in the app - And I should see "50%" - And I press "Not completed: Second forum. Select to mark as complete." in the app - And I should see "100%" - # Set activities as not completed. - And I press "Completed: First forum. Select to mark as not complete." in the app - And I should see "50%" - And I press "Completed: Second forum. Select to mark as not complete." in the app - And I should see "0%" diff --git a/course/tests/behat/app_courselist.feature b/course/tests/behat/app_courselist.feature deleted file mode 100644 index ea68c0542eb95..0000000000000 --- a/course/tests/behat/app_courselist.feature +++ /dev/null @@ -1,120 +0,0 @@ -@core @core_course @app @javascript -Feature: Test course list shown on app start tab - In order to select a course - As a student - I need to see the correct list of courses - - Background: - Given the following "courses" exist: - | fullname | shortname | - | Course 1 | C1 | - | Course 2 | C2 | - And the following "users" exist: - | username | - | student1 | - | student2 | - And the following "course enrolments" exist: - | user | course | role | - | student1 | C1 | student | - | student2 | C1 | student | - | student2 | C2 | student | - - Scenario: Student is registered on one course - When I enter the app - And I log in as "student1" - Then I should see "Course 1" - And I should not see "Course 2" - - Scenario: Student is registered on two courses (shortnames not displayed) - When I enter the app - And I log in as "student2" - Then I should see "Course 1" - And I should see "Course 2" - And I should not see "C1" - And I should not see "C2" - - Scenario: Student is registered on two courses (shortnames displayed) - Given the following config values are set as admin: - | courselistshortnames | 1 | - When I enter the app - And I log in as "student2" - Then I should see "Course 1" - And I should see "Course 2" - And I should see "C1" - And I should see "C2" - - Scenario: Student uses course list to enter course, then leaves it again - When I enter the app - And I log in as "student2" - And I press "Course 2" near "Course overview" in the app - Then the header should be "Course 2" in the app - And I press the back button in the app - Then the header should be "Acceptance test site" in the app - - Scenario: Student uses filter feature to reduce course list - Given the following config values are set as admin: - | courselistshortnames | 1 | - And the following "courses" exist: - | fullname | shortname | - | Frog 3 | C3 | - | Frog 4 | C4 | - | Course 5 | C5 | - | Toad 6 | C6 | - And the following "course enrolments" exist: - | user | course | role | - | student2 | C3 | student | - | student2 | C4 | student | - | student2 | C5 | student | - | student2 | C6 | student | - # Create bogus courses so that the main ones aren't shown in the 'recently accessed' part. - # Because these come later in alphabetical order, they may not be displayed in the lower part - # which is OK. - And the following "courses" exist: - | fullname | shortname | - | Zogus 1 | Z1 | - | Zogus 2 | Z2 | - | Zogus 3 | Z3 | - | Zogus 4 | Z4 | - | Zogus 5 | Z5 | - | Zogus 6 | Z6 | - | Zogus 7 | Z7 | - | Zogus 8 | Z8 | - | Zogus 9 | Z9 | - | Zogus 10 | Z10 | - And the following "course enrolments" exist: - | user | course | role | - | student2 | Z1 | student | - | student2 | Z2 | student | - | student2 | Z3 | student | - | student2 | Z4 | student | - | student2 | Z5 | student | - | student2 | Z6 | student | - | student2 | Z7 | student | - | student2 | Z8 | student | - | student2 | Z9 | student | - | student2 | Z10 | student | - When I enter the app - And I log in as "student2" - Then I should see "C1" - And I should see "C2" - And I should see "C3" - And I should see "C4" - And I should see "C5" - And I should see "C6" - And I press "more" near "Course overview" in the app - And I press "Filter my courses" in the app - And I set the field "Filter my courses" to "fr" in the app - Then I should not see "C1" - And I should not see "C2" - And I should see "C3" - And I should see "C4" - And I should not see "C5" - And I should not see "C6" - And I press "more" near "Course overview" in the app - And I press "Filter my courses" in the app - Then I should see "C1" - And I should see "C2" - And I should see "C3" - And I should see "C4" - And I should see "C5" - And I should see "C6" diff --git a/course/tests/behat/behat_course.php b/course/tests/behat/behat_course.php index 35465843b41a2..16fa6e3e3ad39 100644 --- a/course/tests/behat/behat_course.php +++ b/course/tests/behat/behat_course.php @@ -1144,11 +1144,8 @@ public function i_click_on_in_the_activity($element, $selectortype, $activitynam protected function get_activity_element($element, $selectortype, $activityname) { $activitynode = $this->get_activity_node($activityname); - // Transforming to Behat selector/locator. - list($selector, $locator) = $this->transform_selector($selectortype, $element); - $exception = new ElementNotFoundException($this->getSession(), '"' . $element . '" "' . $selectortype . '" in "' . $activityname . '" '); - - return $this->find($selector, $locator, $exception, $activitynode); + $exception = new ElementNotFoundException($this->getSession(), "'{$element}' '{$selectortype}' in '${activityname}'"); + return $this->find($selectortype, $element, $exception, $activitynode); } /** diff --git a/course/tests/behat/course_creation.feature b/course/tests/behat/course_creation.feature index 2596eb281de38..859afcc8e0773 100644 --- a/course/tests/behat/course_creation.feature +++ b/course/tests/behat/course_creation.feature @@ -68,3 +68,31 @@ Feature: Managers can create courses | id_enddate_day | 24 | | id_enddate_month | October | | id_enddate_year | 2016 | + + Scenario: Create a course as a custom course creator + Given the following "users" exist: + | username | firstname | lastname | email | + | kevin | Kevin | the | kevin@example.com | + And the following "roles" exist: + | shortname | name | archetype | + | creator | Creator | | + And the following "system role assigns" exist: + | user | role | contextlevel | + | kevin | creator | System | + And I log in as "admin" + And I set the following system permissions of "Creator" role: + | capability | permission | + | moodle/course:create | Allow | + | moodle/course:manageactivities | Allow | + | moodle/course:viewparticipants | Allow | + And I log out + And I log in as "kevin" + And I am on site homepage + When I press "Add a new course" + And I set the following fields to these values: + | Course full name | My first course | + | Course short name | myfirstcourse | + And I press "Save and display" + And I follow "Participants" + Then I should see "Kevin the" + And I should see "Teacher" diff --git a/course/tests/courselib_test.php b/course/tests/courselib_test.php index 26197f01b8e0c..5e70ddff4b80b 100644 --- a/course/tests/courselib_test.php +++ b/course/tests/courselib_test.php @@ -3193,7 +3193,6 @@ public function test_course_get_user_administration_options_for_managers() { $this->assertFalse($adminoptions->outcomes); $this->assertTrue($adminoptions->badges); $this->assertTrue($adminoptions->import); - $this->assertFalse($adminoptions->publish); $this->assertTrue($adminoptions->reset); $this->assertTrue($adminoptions->roles); } @@ -3225,7 +3224,6 @@ public function test_course_get_user_administration_options_for_students() { $this->assertFalse($adminoptions->outcomes); $this->assertTrue($adminoptions->badges); $this->assertFalse($adminoptions->import); - $this->assertFalse($adminoptions->publish); $this->assertFalse($adminoptions->reset); $this->assertFalse($adminoptions->roles); @@ -5021,6 +5019,7 @@ public function test_core_course_core_calendar_get_valid_event_timestart_range_w * Test the course_get_recent_courses function. */ public function test_course_get_recent_courses() { + global $DB; $this->resetAfterTest(); $generator = $this->getDataGenerator(); @@ -5043,9 +5042,15 @@ public function test_course_get_recent_courses() { // No course accessed. $this->assertCount(0, $result); + $time = time(); foreach ($courses as $course) { $context = context_course::instance($course->id); course_view($context); + $DB->set_field('user_lastaccess', 'timeaccess', $time, [ + 'userid' => $student->id, + 'courseid' => $course->id, + ]); + $time++; } // Every course accessed. @@ -5056,10 +5061,10 @@ public function test_course_get_recent_courses() { $result = course_get_recent_courses($student->id, 2); $this->assertCount(2, $result); - // Every course accessed, with limit and offset. Should return only the last created course ($course[2]). + // Every course accessed, with limit and offset should return the first course. $result = course_get_recent_courses($student->id, 3, 2); $this->assertCount(1, $result); - $this->assertArrayHasKey($courses[2]->id, $result); + $this->assertArrayHasKey($courses[0]->id, $result); // Every course accessed, order by shortname DESC. The last create course ($course[2]) should have the greater shortname. $result = course_get_recent_courses($student->id, 0, 0, 'shortname DESC'); @@ -5085,4 +5090,50 @@ public function test_course_get_recent_courses() { $this->assertCount(3, $result); $this->assertArrayNotHasKey($courses[0]->id, $result); } + + /** + * Data provider for test_course_modules_pending_deletion. + * + * @return array An array of arrays contain test data + */ + public function provider_course_modules_pending_deletion() { + return [ + 'Non-gradable activity, check all' => [['forum'], 0, false, true], + 'Gradable activity, check all' => [['assign'], 0, false, true], + 'Non-gradable activity, check gradables' => [['forum'], 0, true, false], + 'Gradable activity, check gradables' => [['assign'], 0, true, true], + 'Non-gradable within multiple, check all' => [['quiz', 'forum', 'assign'], 1, false, true], + 'Non-gradable within multiple, check gradables' => [['quiz', 'forum', 'assign'], 1, true, false], + 'Gradable within multiple, check all' => [['quiz', 'forum', 'assign'], 2, false, true], + 'Gradable within multiple, check gradables' => [['quiz', 'forum', 'assign'], 2, true, true], + ]; + } + + /** + * Tests the function course_modules_pending_deletion. + * + * @param string[] $modules A complete list aff all available modules before deletion + * @param int $indextodelete The index of the module in the $modules array that we want to test with + * @param bool $gradable The value to pass to the gradable argument of the course_modules_pending_deletion function + * @param bool $expected The expected result + * @dataProvider provider_course_modules_pending_deletion + */ + public function test_course_modules_pending_deletion(array $modules, int $indextodelete, bool $gradable, bool $expected) { + $this->resetAfterTest(); + + // Ensure recyclebin is enabled. + set_config('coursebinenable', true, 'tool_recyclebin'); + + // Create course and modules. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + $moduleinstances = []; + foreach ($modules as $module) { + $moduleinstances[] = $generator->create_module($module, array('course' => $course->id)); + } + + course_delete_module($moduleinstances[$indextodelete]->cmid, true); // Try to delete the instance asynchronously. + $this->assertEquals($expected, course_modules_pending_deletion($course->id, $gradable)); + } } diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php index ac06afab57c99..bc6560d4d8ded 100644 --- a/course/tests/externallib_test.php +++ b/course/tests/externallib_test.php @@ -958,7 +958,6 @@ public function test_get_course_contents() { foreach ($sections[2]['modules'] as $module) { if ($module['id'] == $urlcm->id and $module['modname'] == 'url') { $this->assertContains('width=100,height=100', $module['onclick']); - $this->assertContains('moodle.org', $module['customdata']); $testexecuted = $testexecuted + 1; } } @@ -2334,12 +2333,11 @@ public function test_get_user_administration_options() { $this->assertFalse($adminoptions->outcomes); $this->assertFalse($adminoptions->badges); $this->assertFalse($adminoptions->import); - $this->assertFalse($adminoptions->publish); $this->assertFalse($adminoptions->reset); $this->assertFalse($adminoptions->roles); $this->assertFalse($adminoptions->editcompletion); } else { - $this->assertCount(15, $course['options']); + $this->assertCount(14, $course['options']); $this->assertFalse($adminoptions->update); $this->assertFalse($adminoptions->filters); $this->assertFalse($adminoptions->reports); @@ -2351,7 +2349,6 @@ public function test_get_user_administration_options() { $this->assertFalse($adminoptions->outcomes); $this->assertTrue($adminoptions->badges); $this->assertFalse($adminoptions->import); - $this->assertFalse($adminoptions->publish); $this->assertFalse($adminoptions->reset); $this->assertFalse($adminoptions->roles); $this->assertFalse($adminoptions->editcompletion); diff --git a/course/tests/indicators_test.php b/course/tests/indicators_test.php index 1749a13a57645..3ad47f95e891e 100644 --- a/course/tests/indicators_test.php +++ b/course/tests/indicators_test.php @@ -28,6 +28,7 @@ global $CFG; require_once(__DIR__ . '/../../lib/completionlib.php'); require_once(__DIR__ . '/../../completion/criteria/completion_criteria_self.php'); +require_once(__DIR__ . '/../../analytics/tests/fixtures/test_target_course_users.php'); /** * Unit tests for core_course indicators. @@ -313,4 +314,30 @@ public function test_potential_social() { // Page social is level 1 (the lower level). $this->assertEquals($indicator::get_min_value(), $values[$cm2->id][0]); } + + /** + * test_activities_due + * + * @return void + */ + public function test_activities_due() { + global $DB; + + $this->resetAfterTest(true); + $this->setAdminuser(); + + $course1 = $this->getDataGenerator()->create_course(); + $user1 = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student'); + + $target = \core_analytics\manager::get_target('test_target_course_users'); + $indicators = array('\core_course\analytics\indicator\activities_due'); + foreach ($indicators as $key => $indicator) { + $indicators[$key] = \core_analytics\manager::get_indicator($indicator); + } + + $model = \core_analytics\model::create($target, $indicators); + $model->enable('\core\analytics\time_splitting\single_range'); + $model->train(); + } } diff --git a/course/tests/restore_test.php b/course/tests/restore_test.php index 470e03b13daa0..4f659902ffb50 100644 --- a/course/tests/restore_test.php +++ b/course/tests/restore_test.php @@ -131,6 +131,157 @@ protected function restore_to_new_course($backupid, $userid = 2) { return $this->restore_course($backupid, 0, $userid); } + /** + * Restore a course. + * + * @param int $backupid The backup ID. + * @param int $courseid The course ID to restore in, or 0. + * @param int $userid The ID of the user performing the restore. + * @param int $target THe target of the restore. + * + * @return stdClass The updated course object. + */ + protected function async_restore_course($backupid, $courseid, $userid, $target) { + global $DB; + + if (!$courseid) { + $target = backup::TARGET_NEW_COURSE; + $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}"); + $courseid = restore_dbops::create_new_course('Tmp', 'tmp', $categoryid); + } + + $rc = new restore_controller($backupid, $courseid, backup::INTERACTIVE_NO, backup::MODE_ASYNC, $userid, $target); + $target == backup::TARGET_NEW_COURSE ?: $rc->get_plan()->get_setting('overwrite_conf')->set_value(true); + $this->assertTrue($rc->execute_precheck()); + + $restoreid = $rc->get_restoreid(); + $rc->destroy(); + + // Create the adhoc task. + $asynctask = new \core\task\asynchronous_restore_task(); + $asynctask->set_blocking(false); + $asynctask->set_custom_data(array('backupid' => $restoreid)); + \core\task\manager::queue_adhoc_task($asynctask); + + // We are expecting trace output during this test. + $this->expectOutputRegex("/$restoreid/"); + + // Execute adhoc task. + $now = time(); + $task = \core\task\manager::get_next_adhoc_task($now); + $this->assertInstanceOf('\\core\\task\\asynchronous_restore_task', $task); + $task->execute(); + \core\task\manager::adhoc_task_complete($task); + + $course = $DB->get_record('course', array('id' => $rc->get_courseid())); + + return $course; + } + + /** + * Restore a course to an existing course. + * + * @param int $backupid The backup ID. + * @param int $courseid The course ID to restore in. + * @param int $userid The ID of the user performing the restore. + * @param int $target The type of restore we are performing. + * @return stdClass The updated course object. + */ + protected function async_restore_to_existing_course($backupid, $courseid, + $userid = 2, $target = backup::TARGET_CURRENT_ADDING) { + return $this->async_restore_course($backupid, $courseid, $userid, $target); + } + + /** + * Restore a course to a new course. + * + * @param int $backupid The backup ID. + * @param int $userid The ID of the user performing the restore. + * @return stdClass The new course object. + */ + protected function async_restore_to_new_course($backupid, $userid = 2) { + return $this->async_restore_course($backupid, 0, $userid, 0); + } + + public function test_async_restore_existing_idnumber_in_new_course() { + $this->resetAfterTest(); + + $dg = $this->getDataGenerator(); + $c1 = $dg->create_course(['idnumber' => 'ABC']); + $backupid = $this->backup_course($c1->id); + $c2 = $this->async_restore_to_new_course($backupid); + + // The ID number is set empty. + $this->assertEquals('', $c2->idnumber); + } + + public function test_async_restore_course_info_in_existing_course() { + global $DB; + $this->resetAfterTest(); + $dg = $this->getDataGenerator(); + + $this->assertEquals(1, get_config('restore', 'restore_merge_course_shortname')); + $this->assertEquals(1, get_config('restore', 'restore_merge_course_fullname')); + $this->assertEquals(1, get_config('restore', 'restore_merge_course_startdate')); + + $startdate = mktime(12, 0, 0, 7, 1, 2016); // 01-Jul-2016. + + // Create two courses with different start dates,in each course create a chat that opens 1 week after the course start date. + $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN', 'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE, + 'startdate' => $startdate]); + $chat1 = $dg->create_module('chat', ['name' => 'First', 'course' => $c1->id, 'chattime' => $c1->startdate + 1 * WEEKSECS]); + $c2 = $dg->create_course(['shortname' => 'A', 'fullname' => 'B', 'summary' => 'C', 'summaryformat' => FORMAT_PLAIN, + 'startdate' => $startdate + 2 * WEEKSECS]); + $chat2 = $dg->create_module('chat', ['name' => 'Second', 'course' => $c2->id, 'chattime' => $c2->startdate + 1 * WEEKSECS]); + $backupid = $this->backup_course($c1->id); + + // The information is restored but adapted because names are already taken. + $c2 = $this->async_restore_to_existing_course($backupid, $c2->id); + $this->assertEquals('SN_1', $c2->shortname); + $this->assertEquals('FN copy 1', $c2->fullname); + $this->assertEquals('DESC', $c2->summary); + $this->assertEquals(FORMAT_MOODLE, $c2->summaryformat); + $this->assertEquals($startdate, $c2->startdate); + + // Now course c2 has two chats - one ('Second') was already there and one ('First') was restored from the backup. + // Their dates are exactly the same as they were in the original modules. + $restoredchat1 = $DB->get_record('chat', ['name' => 'First', 'course' => $c2->id]); + $restoredchat2 = $DB->get_record('chat', ['name' => 'Second', 'course' => $c2->id]); + $this->assertEquals($chat1->chattime, $restoredchat1->chattime); + $this->assertEquals($chat2->chattime, $restoredchat2->chattime); + } + + public function test_async_restore_course_info_in_existing_course_delete_first() { + global $DB; + $this->resetAfterTest(); + $dg = $this->getDataGenerator(); + + $this->assertEquals(1, get_config('restore', 'restore_merge_course_shortname')); + $this->assertEquals(1, get_config('restore', 'restore_merge_course_fullname')); + $this->assertEquals(1, get_config('restore', 'restore_merge_course_startdate')); + + $startdate = mktime(12, 0, 0, 7, 1, 2016); // 01-Jul-2016. + + // Create two courses with different start dates,in each course create a chat that opens 1 week after the course start date. + $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN', 'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE, + 'startdate' => $startdate]); + $chat1 = $dg->create_module('chat', ['name' => 'First', 'course' => $c1->id, 'chattime' => $c1->startdate + 1 * WEEKSECS]); + $c2 = $dg->create_course(['shortname' => 'A', 'fullname' => 'B', 'summary' => 'C', 'summaryformat' => FORMAT_PLAIN, + 'startdate' => $startdate + 2 * WEEKSECS]); + $chat2 = $dg->create_module('chat', ['name' => 'Second', 'course' => $c2->id, 'chattime' => $c2->startdate + 1 * WEEKSECS]); + $backupid = $this->backup_course($c1->id); + + // The information is restored and the existing course settings is modified. + $c2 = $this->async_restore_to_existing_course($backupid, $c2->id, 2, backup::TARGET_CURRENT_DELETING); + $this->assertEquals(FORMAT_MOODLE, $c2->summaryformat); + + // Now course2 should have a new forum with the original forum deleted. + $restoredchat1 = $DB->get_record('chat', ['name' => 'First', 'course' => $c2->id]); + $restoredchat2 = $DB->get_record('chat', ['name' => 'Second', 'course' => $c2->id]); + $this->assertEquals($chat1->chattime, $restoredchat1->chattime); + $this->assertEmpty($restoredchat2); + } + public function test_restore_existing_idnumber_in_new_course() { $this->resetAfterTest(); diff --git a/customfield/field/checkbox/classes/data_controller.php b/customfield/field/checkbox/classes/data_controller.php index 881e515ad4c21..4d080d7c449c9 100644 --- a/customfield/field/checkbox/classes/data_controller.php +++ b/customfield/field/checkbox/classes/data_controller.php @@ -55,8 +55,8 @@ public function instance_form_definition(\MoodleQuickForm $mform) { $field = $this->get_field(); $config = $field->get('configdata'); $elementname = $this->get_form_element_name(); - // TODO MDL-65506 element 'advcheckbox' does not support 'required' rule. If checkbox is required (i.e. "agree to terms") - // then use 'checkbox' form element. + // If checkbox is required (i.e. "agree to terms") then use 'checkbox' form element. + // The advcheckbox element cannot be used for required fields because advcheckbox elements always provide a value. $isrequired = $field->get_configdata_property('required'); $mform->addElement($isrequired ? 'checkbox' : 'advcheckbox', $elementname, $this->get_field()->get_formatted_name()); $mform->setDefault($elementname, $config['checkbydefault']); diff --git a/customfield/field/text/lang/en/customfield_text.php b/customfield/field/text/lang/en/customfield_text.php index de273bf7bb30c..e878d29215caa 100644 --- a/customfield/field/text/lang/en/customfield_text.php +++ b/customfield/field/text/lang/en/customfield_text.php @@ -31,7 +31,7 @@ $string['errorconfigmaxlen'] = 'The maximum number of characters allowed must be between 1 and 1333.'; $string['errormaxlength'] = 'The maximum number of characters allowed in this field is {$a}.'; $string['islink'] = 'Link field'; -$string['islink_help'] = 'To transform the text into a link, enter a URL containing $$ as a placeholder, where $$ will be replaced with the text. For example, to transform a Twitter ID to a link, enter http://twitter.com/$$.'; +$string['islink_help'] = 'To transform the text into a link, enter a URL containing $$ as a placeholder, where $$ will be replaced with the text. For example, to transform a Twitter ID to a link, enter https://twitter.com/$$.'; $string['ispassword'] = 'Password field'; $string['linktarget'] = 'Link target'; $string['maxlength'] = 'Maximum number of characters'; diff --git a/customfield/tests/api_test.php b/customfield/tests/api_test.php index 45d50d1712e72..e76d410e05b91 100644 --- a/customfield/tests/api_test.php +++ b/customfield/tests/api_test.php @@ -38,17 +38,11 @@ class core_customfield_api_testcase extends advanced_testcase { /** - * Tests set up. - */ - public function setUp() { - $this->resetAfterTest(); - } - - /** - * Get generator + * Get generator. + * * @return core_customfield_generator */ - protected function get_generator() : core_customfield_generator { + protected function get_generator(): core_customfield_generator { return $this->getDataGenerator()->get_plugin_generator('core_customfield'); } @@ -57,7 +51,7 @@ protected function get_generator() : core_customfield_generator { * * @param array $expected * @param array $array array of objects with "get($property)" method - * @param sring $propertyname + * @param string $propertyname */ protected function assert_property_in_array($expected, $array, $propertyname) { $this->assertEquals($expected, array_values(array_map(function($a) use ($propertyname) { @@ -72,6 +66,8 @@ protected function assert_property_in_array($expected, $array, $propertyname) { * in the interface using drag-drop. */ public function test_move_category() { + $this->resetAfterTest(); + // Create the categories. $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0]; $id0 = $this->get_generator()->create_category($params)->get('id'); @@ -121,6 +117,8 @@ public function test_move_category() { * Tests for \core_customfield\api::get_categories_with_fields() behaviour. */ public function test_get_categories_with_fields() { + $this->resetAfterTest(); + // Create the categories. $options = [ 'component' => 'core_course', @@ -155,6 +153,8 @@ public function test_get_categories_with_fields() { * Test for functions api::save_category() and rename_category) */ public function test_save_category() { + $this->resetAfterTest(); + $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0, 'name' => 'Cat1', 'contextid' => context_system::instance()->id]; $c1 = category_controller::create(0, (object)$params); @@ -188,6 +188,8 @@ public function test_save_category() { * Test for function handler::create_category */ public function test_create_category() { + $this->resetAfterTest(); + $handler = \core_course\customfield\course_handler::create(); $c1id = $handler->create_category(); $c1 = $handler->get_categories_with_fields()[$c1id]; @@ -210,6 +212,8 @@ public function test_create_category() { * Tests for \core_customfield\api::delete_category() behaviour. */ public function test_delete_category_with_fields() { + $this->resetAfterTest(); + global $DB; // Create two categories with fields and data. $options = [ diff --git a/customfield/tests/category_controller_test.php b/customfield/tests/category_controller_test.php index 656bc5bc79da4..04ce151e65e68 100644 --- a/customfield/tests/category_controller_test.php +++ b/customfield/tests/category_controller_test.php @@ -37,21 +37,20 @@ class core_customfield_category_controller_testcase extends advanced_testcase { /** - * Tests set up. - */ - public function setUp() { - $this->resetAfterTest(); - } - - /** - * Get generator + * Get generator. + * * @return core_customfield_generator */ - protected function get_generator() : core_customfield_generator { + protected function get_generator(): core_customfield_generator { return $this->getDataGenerator()->get_plugin_generator('core_customfield'); } + /** + * Test for the field_controller::__construct function. + */ public function test_constructor() { + $this->resetAfterTest(); + $c = category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 0]); $handler = $c->get_handler(); $this->assertTrue($c instanceof category_controller); @@ -75,6 +74,8 @@ public function test_constructor() { */ public function test_constructor_errors() { global $DB; + $this->resetAfterTest(); + $cat = $this->get_generator()->create_category(); $catrecord = $cat->to_record(); @@ -173,6 +174,7 @@ public function test_constructor_errors() { * \core_customfield\category_controller::get() */ public function test_create_category() { + $this->resetAfterTest(); // Create the category. $lpg = $this->get_generator(); @@ -201,6 +203,8 @@ public function test_create_category() { * Tests for \core_customfield\category_controller::set() behaviour. */ public function test_rename_category() { + $this->resetAfterTest(); + // Create the category. $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0, 'name' => 'Cat1', 'contextid' => context_system::instance()->id]; @@ -224,6 +228,8 @@ public function test_rename_category() { * Tests for \core_customfield\category_controller::delete() behaviour. */ public function test_delete_category() { + $this->resetAfterTest(); + // Create the category. $lpg = $this->get_generator(); $category0 = $lpg->create_category(); diff --git a/customfield/tests/data_controller_test.php b/customfield/tests/data_controller_test.php index ac5b4056a07b8..d125c5c9c2168 100644 --- a/customfield/tests/data_controller_test.php +++ b/customfield/tests/data_controller_test.php @@ -35,17 +35,11 @@ class core_customfield_data_controller_testcase extends advanced_testcase { /** - * Tests set up. - */ - public function setUp() { - $this->resetAfterTest(); - } - - /** - * Get generator + * Get generator. + * * @return core_customfield_generator */ - protected function get_generator() : core_customfield_generator { + protected function get_generator(): core_customfield_generator { return $this->getDataGenerator()->get_plugin_generator('core_customfield'); } @@ -54,6 +48,8 @@ protected function get_generator() : core_customfield_generator { */ public function test_constructor() { global $DB; + $this->resetAfterTest(); + // Create a course, fields category and fields. $course = $this->getDataGenerator()->create_course(); $category0 = $this->get_generator()->create_category(['name' => 'aaaa']); @@ -122,6 +118,8 @@ public function test_constructor() { */ public function test_constructor_errors() { global $DB; + $this->resetAfterTest(); + // Create a category, field and data. $category = $this->get_generator()->create_category(); $field = $this->get_generator()->create_field(['categoryid' => $category->get('id')]); @@ -180,4 +178,4 @@ public function test_constructor_errors() { $this->assertEquals(moodle_exception::class, get_class($e)); } } -} \ No newline at end of file +} diff --git a/customfield/tests/field_controller_test.php b/customfield/tests/field_controller_test.php index b7044695be918..a642919e2624a 100644 --- a/customfield/tests/field_controller_test.php +++ b/customfield/tests/field_controller_test.php @@ -39,17 +39,11 @@ class core_customfield_field_controller_testcase extends advanced_testcase { /** - * Tests set up. - */ - public function setUp() { - $this->resetAfterTest(); - } - - /** - * Get generator + * Get generator. + * * @return core_customfield_generator */ - protected function get_generator() : core_customfield_generator { + protected function get_generator(): core_customfield_generator { return $this->getDataGenerator()->get_plugin_generator('core_customfield'); } @@ -58,6 +52,8 @@ protected function get_generator() : core_customfield_generator { */ public function test_constructor() { global $DB; + $this->resetAfterTest(); + // Create the category. $category0 = $this->get_generator()->create_category(); @@ -103,6 +99,8 @@ public function test_constructor() { */ public function test_constructor_errors() { global $DB; + $this->resetAfterTest(); + // Create a category and a field. $category = $this->get_generator()->create_category(); $field = $this->get_generator()->create_field(['categoryid' => $category->get('id')]); @@ -175,6 +173,8 @@ public function test_constructor_errors() { */ public function test_create_field() { global $DB; + $this->resetAfterTest(); + $lpg = $this->get_generator(); $category = $lpg->create_category(); $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); @@ -203,6 +203,8 @@ public function test_create_field() { */ public function test_delete_field() { global $DB; + $this->resetAfterTest(); + $lpg = $this->get_generator(); $category = $lpg->create_category(); $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); @@ -229,6 +231,8 @@ public function test_delete_field() { * Tests for \core_customfield\field_controller::get_configdata_property() behaviour. */ public function test_get_configdata_property() { + $this->resetAfterTest(); + $lpg = $this->get_generator(); $category = $lpg->create_category(); $configdata = ['a' => 'b', 'c' => ['d', 'e']]; @@ -243,4 +247,4 @@ public function test_get_configdata_property() { $this->assertEquals(['d', 'e'], $field->get_configdata_property('c')); $this->assertEquals(null, $field->get_configdata_property('x')); } -} \ No newline at end of file +} diff --git a/customfield/tests/generator_test.php b/customfield/tests/generator_test.php index 4788d9cf3470b..c5335c37d9fef 100644 --- a/customfield/tests/generator_test.php +++ b/customfield/tests/generator_test.php @@ -39,7 +39,7 @@ class core_customfield_generator_testcase extends advanced_testcase { * Get generator * @return core_customfield_generator */ - protected function get_generator() : core_customfield_generator { + protected function get_generator(): core_customfield_generator { return $this->getDataGenerator()->get_plugin_generator('core_customfield'); } diff --git a/customfield/tests/privacy_test.php b/customfield/tests/privacy_test.php index 9a15ced565881..4a63085fb3f20 100644 --- a/customfield/tests/privacy_test.php +++ b/customfield/tests/privacy_test.php @@ -38,63 +38,59 @@ */ class core_customfield_privacy_testcase extends provider_testcase { - /** @var stdClass[] */ - private $courses = []; - /** @var \core_customfield\category_controller[] */ - private $cfcats = []; - /** @var \core_customfield\field_controller[] */ - private $cffields = []; - /** - * Set up + * Generate data. + * + * @return array */ - public function setUp() { + protected function generate_test_data(): array { $this->resetAfterTest(); - $this->cfcats[1] = $this->get_generator()->create_category(); - $this->cfcats[2] = $this->get_generator()->create_category(); - $this->cffields[11] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[1]->get('id'), 'type' => 'checkbox']); - $this->cffields[12] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[1]->get('id'), 'type' => 'date']); - $this->cffields[13] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[1]->get('id'), + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + $cfcats[1] = $generator->create_category(); + $cfcats[2] = $generator->create_category(); + $cffields[11] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'checkbox']); + $cffields[12] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'date']); + $cffields[13] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'select', 'configdata' => ['options' => "a\nb\nc"]]); - $this->cffields[14] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[1]->get('id'), 'type' => 'text']); - $this->cffields[15] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[1]->get('id'), 'type' => 'textarea']); - $this->cffields[21] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[2]->get('id')]); - $this->cffields[22] = $this->get_generator()->create_field( - ['categoryid' => $this->cfcats[2]->get('id')]); - - $this->courses[1] = $this->getDataGenerator()->create_course(); - $this->courses[2] = $this->getDataGenerator()->create_course(); - $this->courses[3] = $this->getDataGenerator()->create_course(); - - $this->get_generator()->add_instance_data($this->cffields[11], $this->courses[1]->id, 1); - $this->get_generator()->add_instance_data($this->cffields[12], $this->courses[1]->id, 1546300800); - $this->get_generator()->add_instance_data($this->cffields[13], $this->courses[1]->id, 2); - $this->get_generator()->add_instance_data($this->cffields[14], $this->courses[1]->id, 'Hello1'); - $this->get_generator()->add_instance_data($this->cffields[15], $this->courses[1]->id, + $cffields[14] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'text']); + $cffields[15] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'textarea']); + $cffields[21] = $generator->create_field( + ['categoryid' => $cfcats[2]->get('id')]); + $cffields[22] = $generator->create_field( + ['categoryid' => $cfcats[2]->get('id')]); + + $courses[1] = $this->getDataGenerator()->create_course(); + $courses[2] = $this->getDataGenerator()->create_course(); + $courses[3] = $this->getDataGenerator()->create_course(); + + $generator->add_instance_data($cffields[11], $courses[1]->id, 1); + $generator->add_instance_data($cffields[12], $courses[1]->id, 1546300800); + $generator->add_instance_data($cffields[13], $courses[1]->id, 2); + $generator->add_instance_data($cffields[14], $courses[1]->id, 'Hello1'); + $generator->add_instance_data($cffields[15], $courses[1]->id, ['text' => '

Hi there

', 'format' => FORMAT_HTML]); - $this->get_generator()->add_instance_data($this->cffields[21], $this->courses[1]->id, 'hihi1'); + $generator->add_instance_data($cffields[21], $courses[1]->id, 'hihi1'); - $this->get_generator()->add_instance_data($this->cffields[14], $this->courses[2]->id, 'Hello2'); + $generator->add_instance_data($cffields[14], $courses[2]->id, 'Hello2'); - $this->get_generator()->add_instance_data($this->cffields[21], $this->courses[2]->id, 'hihi2'); + $generator->add_instance_data($cffields[21], $courses[2]->id, 'hihi2'); - $this->setUser($this->getDataGenerator()->create_user()); - } + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); - /** - * Get generator - * @return core_customfield_generator - */ - protected function get_generator() : core_customfield_generator { - return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + return [ + 'user' => $user, + 'cfcats' => $cfcats, + 'cffields' => $cffields, + 'courses' => $courses, + ]; } /** @@ -111,11 +107,17 @@ public function test_get_metadata() { */ public function test_get_customfields_data_contexts() { global $DB; - list($sql, $params) = $DB->get_in_or_equal([$this->courses[1]->id, $this->courses[2]->id], SQL_PARAMS_NAMED); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + list($sql, $params) = $DB->get_in_or_equal([$courses[1]->id, $courses[2]->id], SQL_PARAMS_NAMED); $r = provider::get_customfields_data_contexts('core_course', 'course', '=0', $sql, $params); - $this->assertEquals([context_course::instance($this->courses[1]->id)->id, - context_course::instance($this->courses[2]->id)->id], + $this->assertEquals([context_course::instance($courses[1]->id)->id, + context_course::instance($courses[2]->id)->id], $r->get_contextids(), '', 0, 10, true); } @@ -123,6 +125,8 @@ public function test_get_customfields_data_contexts() { * Test for provider::get_customfields_configuration_contexts() */ public function test_get_customfields_configuration_contexts() { + $this->generate_test_data(); + $r = provider::get_customfields_configuration_contexts('core_course', 'course'); $this->assertEquals([context_system::instance()->id], $r->get_contextids()); } @@ -132,13 +136,20 @@ public function test_get_customfields_configuration_contexts() { */ public function test_export_customfields_data() { global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + // Hack one of the fields so it has an invalid field type. - $invalidfieldid = $this->cffields[21]->get('id'); + $invalidfieldid = $cffields[21]->get('id'); $DB->update_record('customfield_field', ['id' => $invalidfieldid, 'type' => 'invalid']); - $context = context_course::instance($this->courses[1]->id); + $context = context_course::instance($courses[1]->id); $contextlist = new approved_contextlist($USER, 'core_customfield', [$context->id]); - provider::export_customfields_data($contextlist, 'core_course', 'course', '=0', '=:i', ['i' => $this->courses[1]->id]); + provider::export_customfields_data($contextlist, 'core_course', 'course', '=0', '=:i', ['i' => $courses[1]->id]); /** @var core_privacy\tests\request\content_writer $writer */ $writer = writer::with_context($context); @@ -147,7 +158,7 @@ public function test_export_customfields_data() { $invaldfieldischecked = false; foreach ($DB->get_records('customfield_data', []) as $dbrecord) { $data = $writer->get_data(['Custom fields data', $dbrecord->id]); - if ($dbrecord->instanceid == $this->courses[1]->id) { + if ($dbrecord->instanceid == $courses[1]->id) { $this->assertEquals($dbrecord->fieldid, $data->fieldid); $this->assertNotEmpty($data->fieldtype); $this->assertNotEmpty($data->fieldshortname); @@ -167,10 +178,17 @@ public function test_export_customfields_data() { */ public function test_delete_customfields_data() { global $USER, $DB; - $approvedcontexts = new approved_contextlist($USER, 'core_course', [context_course::instance($this->courses[1]->id)->id]); + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + $approvedcontexts = new approved_contextlist($USER, 'core_course', [context_course::instance($courses[1]->id)->id]); provider::delete_customfields_data($approvedcontexts, 'core_course', 'course'); - $this->assertEmpty($DB->get_records('customfield_data', ['instanceid' => $this->courses[1]->id])); - $this->assertNotEmpty($DB->get_records('customfield_data', ['instanceid' => $this->courses[2]->id])); + $this->assertEmpty($DB->get_records('customfield_data', ['instanceid' => $courses[1]->id])); + $this->assertNotEmpty($DB->get_records('customfield_data', ['instanceid' => $courses[2]->id])); } /** @@ -178,9 +196,16 @@ public function test_delete_customfields_data() { */ public function test_delete_customfields_configuration() { global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + // Remember the list of fields in the category 2 before we delete it. - $catid1 = $this->cfcats[1]->get('id'); - $catid2 = $this->cfcats[2]->get('id'); + $catid1 = $cfcats[1]->get('id'); + $catid2 = $cfcats[2]->get('id'); $fids2 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid2]); $this->assertNotEmpty($fids2); list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED); @@ -208,9 +233,16 @@ public function test_delete_customfields_configuration() { */ public function test_delete_customfields_configuration_for_context() { global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + // Remember the list of fields in the category 2 before we delete it. - $catid1 = $this->cfcats[1]->get('id'); - $catid2 = $this->cfcats[2]->get('id'); + $catid1 = $cfcats[1]->get('id'); + $catid2 = $cfcats[2]->get('id'); $fids2 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid2]); $this->assertNotEmpty($fids2); list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED); @@ -238,12 +270,19 @@ public function test_delete_customfields_configuration_for_context() { */ public function test_delete_customfields_data_for_context() { global $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + provider::delete_customfields_data_for_context('core_course', 'course', - context_course::instance($this->courses[1]->id)); + context_course::instance($courses[1]->id)); $fids2 = $DB->get_fieldset_select('customfield_field', 'id', '1=1', []); list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED); - $fparams['course1'] = $this->courses[1]->id; - $fparams['course2'] = $this->courses[2]->id; + $fparams['course1'] = $courses[1]->id; + $fparams['course2'] = $courses[2]->id; $this->assertEmpty($DB->get_records_select('customfield_data', 'instanceid = :course1 AND fieldid ' . $fsql, $fparams)); $this->assertNotEmpty($DB->get_records_select('customfield_data', 'instanceid = :course2 AND fieldid ' . $fsql, $fparams)); } diff --git a/dataformat/pdf/classes/writer.php b/dataformat/pdf/classes/writer.php index 0559c93aebc0e..1f204d83e6174 100644 --- a/dataformat/pdf/classes/writer.php +++ b/dataformat/pdf/classes/writer.php @@ -89,6 +89,11 @@ public function start_sheet($columns) { public function write_record($record, $rownum) { $rowheight = 0; + // If $record is an object convert it to an array. + if (is_object($record)) { + $record = (array)$record; + } + foreach ($record as $cell) { $rowheight = max($rowheight, $this->pdf->getStringHeight($this->colwidth, $cell, false, true, '', 1)); } @@ -99,12 +104,19 @@ public function write_record($record, $rownum) { $this->print_heading(); } - $total = count($record); - $counter = 1; - foreach ($record as $cell) { - $nextposition = ($counter == $total) ? 1 : 0; + // Get the last key for this record. + end($record); + $lastkey = key($record); + + // Reset the record pointer. + reset($record); + + // Loop through each element. + foreach ($record as $key => $cell) { + // Determine whether we're at the last element of the record. + $nextposition = ($lastkey === $key) ? 1 : 0; + // Write the element. $this->pdf->Multicell($this->colwidth, $rowheight, $cell, 1, 'L', false, $nextposition); - $counter++; } } diff --git a/enrol/database/db/upgrade.php b/enrol/database/db/upgrade.php index ca2733c53cb7c..9c288eb34e2b8 100644 --- a/enrol/database/db/upgrade.php +++ b/enrol/database/db/upgrade.php @@ -39,5 +39,8 @@ function xmldb_enrol_database_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/externallib.php b/enrol/externallib.php index 593541e131a72..89cff4856fa0d 100644 --- a/enrol/externallib.php +++ b/enrol/externallib.php @@ -1020,6 +1020,70 @@ public static function edit_user_enrolment_returns() { ); } + /** + * Returns description of submit_user_enrolment_form parameters. + * + * @return external_function_parameters. + */ + public static function submit_user_enrolment_form_parameters() { + return new external_function_parameters([ + 'formdata' => new external_value(PARAM_RAW, 'The data from the event form'), + ]); + } + + /** + * External function that handles the user enrolment form submission. + * + * @param string $formdata The user enrolment form data in s URI encoded param string + * @return array An array consisting of the processing result and error flag, if available + */ + public static function submit_user_enrolment_form($formdata) { + global $CFG, $DB, $PAGE; + + // Parameter validation. + $params = self::validate_parameters(self::submit_user_enrolment_form_parameters(), ['formdata' => $formdata]); + + $data = []; + parse_str($params['formdata'], $data); + + $userenrolment = $DB->get_record('user_enrolments', ['id' => $data['ue']], '*', MUST_EXIST); + $instance = $DB->get_record('enrol', ['id' => $userenrolment->enrolid], '*', MUST_EXIST); + $plugin = enrol_get_plugin($instance->enrol); + $course = get_course($instance->courseid); + $context = context_course::instance($course->id); + self::validate_context($context); + + require_once("$CFG->dirroot/enrol/editenrolment_form.php"); + $customformdata = [ + 'ue' => $userenrolment, + 'modal' => true, + 'enrolinstancename' => $plugin->get_instance_name($instance) + ]; + $mform = new enrol_user_enrolment_form(null, $customformdata, 'post', '', null, true, $data); + + if ($validateddata = $mform->get_data()) { + require_once($CFG->dirroot . '/enrol/locallib.php'); + $manager = new course_enrolment_manager($PAGE, $course); + $result = $manager->edit_enrolment($userenrolment, $validateddata); + + return ['result' => $result]; + } else { + return ['result' => false, 'validationerror' => true]; + } + } + + /** + * Returns description of submit_user_enrolment_form() result value + * + * @return external_description + */ + public static function submit_user_enrolment_form_returns() { + return new external_single_structure([ + 'result' => new external_value(PARAM_BOOL, 'True if the user\'s enrolment was successfully updated'), + 'validationerror' => new external_value(PARAM_BOOL, 'Indicates invalid form data', VALUE_DEFAULT, false), + ]); + } + /** * Returns description of unenrol_user_enrolment() parameters * diff --git a/enrol/flatfile/db/upgrade.php b/enrol/flatfile/db/upgrade.php index c286910f29c8e..9e216f6fac30f 100644 --- a/enrol/flatfile/db/upgrade.php +++ b/enrol/flatfile/db/upgrade.php @@ -39,5 +39,8 @@ function xmldb_enrol_flatfile_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/guest/db/upgrade.php b/enrol/guest/db/upgrade.php index a4f9d25eb980b..81fa08cb9bca1 100644 --- a/enrol/guest/db/upgrade.php +++ b/enrol/guest/db/upgrade.php @@ -39,5 +39,8 @@ function xmldb_enrol_guest_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/imsenterprise/db/upgrade.php b/enrol/imsenterprise/db/upgrade.php index d89f2962cbad4..137602d74f4f5 100644 --- a/enrol/imsenterprise/db/upgrade.php +++ b/enrol/imsenterprise/db/upgrade.php @@ -45,5 +45,8 @@ function xmldb_enrol_imsenterprise_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/locallib.php b/enrol/locallib.php index c77e1510c9fa6..dbe8636395d41 100644 --- a/enrol/locallib.php +++ b/enrol/locallib.php @@ -1191,7 +1191,7 @@ private function prepare_user_for_display($user, $extrafields, $now) { ); foreach ($extrafields as $field) { - $details[$field] = $user->{$field}; + $details[$field] = s($user->{$field}); } // Last time user has accessed the site. diff --git a/enrol/lti/classes/helper.php b/enrol/lti/classes/helper.php index c8fadbafc4c91..a101b0d2437ba 100644 --- a/enrol/lti/classes/helper.php +++ b/enrol/lti/classes/helper.php @@ -576,18 +576,19 @@ protected static function get_cartridge_parameters($toolid) { // Work out the name of the tool. $title = self::get_name($tool); $launchurl = self::get_launch_url($toolid); - $launchurl = $launchurl->out(); - $icon = self::get_icon($tool); + $launchurl = $launchurl->out(false); + $iconurl = self::get_icon($tool); + $iconurl = $iconurl->out(false); $securelaunchurl = null; - $secureicon = null; + $secureiconurl = null; $vendorurl = new \moodle_url('/'); - $vendorurl = $vendorurl->out(); + $vendorurl = $vendorurl->out(false); $description = self::get_description($tool); // If we are a https site, we can add the launch url and icon urls as secure equivalents. if (\is_https()) { $securelaunchurl = $launchurl; - $secureicon = $icon; + $secureiconurl = $iconurl; } return array( @@ -595,13 +596,13 @@ protected static function get_cartridge_parameters($toolid) { "/blti:title" => $title, "/blti:description" => $description, "/blti:extensions" => array( - "/lticm:property[@name='icon_url']" => $icon, - "/lticm:property[@name='secure_icon_url']" => $secureicon + "/lticm:property[@name='icon_url']" => $iconurl, + "/lticm:property[@name='secure_icon_url']" => $secureiconurl ), "/blti:launch_url" => $launchurl, "/blti:secure_launch_url" => $securelaunchurl, - "/blti:icon" => $icon, - "/blti:secure_icon" => $secureicon, + "/blti:icon" => $iconurl, + "/blti:secure_icon" => $secureiconurl, "/blti:vendor" => array( "/lticp:code" => $SITE->shortname, "/lticp:name" => $SITE->fullname, @@ -634,7 +635,7 @@ protected static function set_xpath($xpath, $parameters, $prefix = '') { if (is_null($value)) { $node->parentNode->removeChild($node); } else { - $node->nodeValue = $value; + $node->nodeValue = s($value); } } } else { diff --git a/enrol/lti/db/upgrade.php b/enrol/lti/db/upgrade.php index 7b5736b44a54d..21c0943f74529 100644 --- a/enrol/lti/db/upgrade.php +++ b/enrol/lti/db/upgrade.php @@ -66,5 +66,8 @@ function xmldb_enrol_lti_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/manual/amd/build/form-potential-user-selector.min.js b/enrol/manual/amd/build/form-potential-user-selector.min.js index bf66147628b8b..393173d748330 100644 --- a/enrol/manual/amd/build/form-potential-user-selector.min.js +++ b/enrol/manual/amd/build/form-potential-user-selector.min.js @@ -1 +1 @@ -define(["jquery","core/ajax","core/templates","core/str"],function(a,b,c,d){var e=100;return{processResults:function(b,c){var d=[];return a.isArray(c)?(a.each(c,function(a,b){d.push({value:b.id,label:b._label})}),d):c},transport:function(f,g,h,i){var j,k=a(f).attr("courseid"),l=a(f).attr("userfields").split(",");"undefined"==typeof k&&(k="1");var m=a(f).attr("enrolid");"undefined"==typeof m&&(m=""),j=b.call([{methodname:"core_enrol_get_potential_users",args:{courseid:k,enrolid:m,search:g,searchanywhere:!0,page:0,perpage:e+1}}]),j[0].then(function(b){var f=[],g=0;return b.length<=e?(a.each(b,function(b,d){var e=d,g=[];a.each(l,function(a,b){"undefined"!=typeof d[b]&&""!==d[b]&&(e.hasidentity=!0,g.push(d[b]))}),e.identity=g.join(", "),f.push(c.render("enrol_manual/form-user-selector-suggestion",e))}),a.when.apply(a.when,f).then(function(){var c=arguments;a.each(b,function(a,b){b._label=c[g],g++}),h(b)})):d.get_string("toomanyuserstoshow","core",">"+e).then(function(a){h(a)})}).fail(i)}}}); \ No newline at end of file +define(["jquery","core/ajax","core/templates","core/str"],function(a,b,c,d){return{processResults:function(b,c){var d=[];return a.isArray(c)?(a.each(c,function(a,b){d.push({value:b.id,label:b._label})}),d):c},transport:function(e,f,g,h){var i,j=a(e).attr("courseid"),k=a(e).attr("userfields").split(",");"undefined"==typeof j&&(j="1");var l=a(e).attr("enrolid");"undefined"==typeof l&&(l="");var m=parseInt(a(e).attr("perpage"));isNaN(m)&&(m=100),i=b.call([{methodname:"core_enrol_get_potential_users",args:{courseid:j,enrolid:l,search:f,searchanywhere:!0,page:0,perpage:m+1}}]),i[0].then(function(b){var e=[],f=0;return b.length<=m?(a.each(b,function(b,d){var f=d,g=[];a.each(k,function(a,b){"undefined"!=typeof d[b]&&""!==d[b]&&(f.hasidentity=!0,g.push(d[b]))}),f.identity=g.join(", "),e.push(c.render("enrol_manual/form-user-selector-suggestion",f))}),a.when.apply(a.when,e).then(function(){var c=arguments;a.each(b,function(a,b){b._label=c[f],f++}),g(b)})):d.get_string("toomanyuserstoshow","core",">"+m).then(function(a){g(a)})}).fail(h)}}}); \ No newline at end of file diff --git a/enrol/manual/amd/src/form-potential-user-selector.js b/enrol/manual/amd/src/form-potential-user-selector.js index 7fac602a91a36..c2e53d82fd516 100644 --- a/enrol/manual/amd/src/form-potential-user-selector.js +++ b/enrol/manual/amd/src/form-potential-user-selector.js @@ -25,9 +25,6 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax, Templates, Str) { - /** @var {Number} Maximum number of users to show. */ - var MAXUSERS = 100; - return /** @alias module:enrol_manual/form-potential-user-selector */ { processResults: function(selector, results) { @@ -57,6 +54,10 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax, if (typeof enrolid === "undefined") { enrolid = ''; } + var perpage = parseInt($(selector).attr('perpage')); + if (isNaN(perpage)) { + perpage = 100; + } promise = Ajax.call([{ methodname: 'core_enrol_get_potential_users', @@ -66,7 +67,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax, search: query, searchanywhere: true, page: 0, - perpage: MAXUSERS + 1 + perpage: perpage + 1 } }]); @@ -74,7 +75,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax, var promises = [], i = 0; - if (results.length <= MAXUSERS) { + if (results.length <= perpage) { // Render the label. $.each(results, function(index, user) { var ctx = user, @@ -101,7 +102,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax, }); } else { - return Str.get_string('toomanyuserstoshow', 'core', '>' + MAXUSERS).then(function(toomanyuserstoshow) { + return Str.get_string('toomanyuserstoshow', 'core', '>' + perpage).then(function(toomanyuserstoshow) { success(toomanyuserstoshow); return; }); diff --git a/enrol/manual/classes/enrol_users_form.php b/enrol/manual/classes/enrol_users_form.php index 165a6e996de1a..5a4e50f54f114 100644 --- a/enrol/manual/classes/enrol_users_form.php +++ b/enrol/manual/classes/enrol_users_form.php @@ -99,6 +99,7 @@ public function definition() { 'multiple' => true, 'courseid' => $course->id, 'enrolid' => $instance->id, + 'perpage' => $CFG->maxusersperpage, 'userfields' => implode(',', get_extra_user_fields($context)) ); $mform->addElement('autocomplete', 'userlist', get_string('selectusers', 'enrol_manual'), array(), $options); diff --git a/enrol/manual/db/upgrade.php b/enrol/manual/db/upgrade.php index 48dfc6929bf63..0546271b635f0 100644 --- a/enrol/manual/db/upgrade.php +++ b/enrol/manual/db/upgrade.php @@ -39,5 +39,8 @@ function xmldb_enrol_manual_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/manual/tests/behat/quickenrolment.feature b/enrol/manual/tests/behat/quickenrolment.feature index 2afa506adc372..72bb05926b64b 100644 --- a/enrol/manual/tests/behat/quickenrolment.feature +++ b/enrol/manual/tests/behat/quickenrolment.feature @@ -154,6 +154,16 @@ Feature: Teacher can search and enrol users one by one into the course And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row" Then I should see "Too many users (>100) to show" + @javascript + Scenario: Changing the Maximum users per page setting affects the enrolment pop-up. + Given the following config values are set as admin: + | maxusersperpage | 5 | + And I navigate to course participants + And I press "Enrol users" + When I set the field "Select users" to "student00" + And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row" + Then I should see "Too many users (>5) to show" + @javascript Scenario: Change the Show user identity setting affects the enrolment pop-up. Given I log out diff --git a/enrol/mnet/db/upgrade.php b/enrol/mnet/db/upgrade.php index f6d9efb4af0ac..62dd9bb04d31f 100644 --- a/enrol/mnet/db/upgrade.php +++ b/enrol/mnet/db/upgrade.php @@ -39,5 +39,8 @@ function xmldb_enrol_mnet_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/paypal/db/upgrade.php b/enrol/paypal/db/upgrade.php index 93ebd504d81f6..40b8abda19e28 100644 --- a/enrol/paypal/db/upgrade.php +++ b/enrol/paypal/db/upgrade.php @@ -138,5 +138,8 @@ function xmldb_enrol_paypal_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/self/db/upgrade.php b/enrol/self/db/upgrade.php index cb255e8bf6dd5..42046662970d4 100644 --- a/enrol/self/db/upgrade.php +++ b/enrol/self/db/upgrade.php @@ -39,5 +39,8 @@ function xmldb_enrol_self_upgrade($oldversion) { // Automatically generated Moodle v3.6.0 release upgrade line. // Put any upgrade step following this. + // Automatically generated Moodle v3.7.0 release upgrade line. + // Put any upgrade step following this. + return true; } diff --git a/enrol/self/lib.php b/enrol/self/lib.php index bac388754b930..0f5d84435b0af 100644 --- a/enrol/self/lib.php +++ b/enrol/self/lib.php @@ -448,8 +448,9 @@ public function sync(progress_trace $trace, $courseid = null) { $userid = $instance->userid; unset($instance->userid); $this->unenrol_user($instance, $userid); - $days = $instance->customint2 / 60*60*24; - $trace->output("unenrolling user $userid from course $instance->courseid as they have did not log in for at least $days days", 1); + $days = $instance->customint2 / DAYSECS; + $trace->output("unenrolling user $userid from course $instance->courseid " . + "as they did not log in for at least $days days", 1); } $rs->close(); @@ -465,8 +466,9 @@ public function sync(progress_trace $trace, $courseid = null) { $userid = $instance->userid; unset($instance->userid); $this->unenrol_user($instance, $userid); - $days = $instance->customint2 / 60*60*24; - $trace->output("unenrolling user $userid from course $instance->courseid as they have did not access course for at least $days days", 1); + $days = $instance->customint2 / DAYSECS; + $trace->output("unenrolling user $userid from course $instance->courseid " . + "as they did not access the course for at least $days days", 1); } $rs->close(); diff --git a/enrol/self/tests/self_test.php b/enrol/self/tests/self_test.php index 0a6fcffe03483..e939eaf4ca492 100644 --- a/enrol/self/tests/self_test.php +++ b/enrol/self/tests/self_test.php @@ -61,7 +61,7 @@ public function test_longtimnosee() { $now = time(); - $trace = new null_progress_trace(); + $trace = new progress_trace_buffer(new text_progress_trace(), false); // Prepare some data. @@ -133,18 +133,32 @@ public function test_longtimnosee() { // Execute sync - this is the same thing used from cron. $selfplugin->sync($trace, $course2->id); + $output = $trace->get_buffer(); + $trace->reset_buffer(); $this->assertEquals(10, $DB->count_records('user_enrolments')); - + $this->assertStringContainsString('No expired enrol_self enrolments detected', $output); $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user1->id))); $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user2->id))); $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user1->id))); $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user3->id))); + $selfplugin->sync($trace, null); + $output = $trace->get_buffer(); + $trace->reset_buffer(); $this->assertEquals(6, $DB->count_records('user_enrolments')); $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user1->id))); $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user2->id))); $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user1->id))); $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user3->id))); + $this->assertStringContainsString('unenrolling user ' . $user1->id . ' from course ' . $course1->id . + ' as they did not log in for at least 14 days', $output); + $this->assertStringContainsString('unenrolling user ' . $user1->id . ' from course ' . $course3->id . + ' as they did not log in for at least 50 days', $output); + $this->assertStringContainsString('unenrolling user ' . $user2->id . ' from course ' . $course1->id . + ' as they did not access the course for at least 14 days', $output); + $this->assertStringContainsString('unenrolling user ' . $user3->id . ' from course ' . $course3->id . + ' as they did not access the course for at least 50 days', $output); + $this->assertStringNotContainsString('unenrolling user ' . $user4->id, $output); $this->assertEquals(6, $DB->count_records('role_assignments')); $this->assertEquals(4, $DB->count_records('role_assignments', array('roleid'=>$studentrole->id))); @@ -669,7 +683,7 @@ public function test_get_welcome_email_contact() { $context = context_course::instance($course1->id); // Get editing teacher role. - $editingteacherrole = $DB->get_record('role', ['archetype' => 'editingteacher']); + $editingteacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); $this->assertNotEmpty($editingteacherrole); // Enable self enrolment plugin and set to send email from course contact. @@ -700,7 +714,7 @@ public function test_get_welcome_email_contact() { $this->assertEquals($user1->email, $contact->email); // Get manager role, and enrol user as manager. - $managerrole = $DB->get_record('role', ['archetype' => 'manager']); + $managerrole = $DB->get_record('role', ['shortname' => 'manager']); $this->assertNotEmpty($managerrole); $instance1->customint4 = ENROL_SEND_EMAIL_FROM_KEY_HOLDER; $DB->update_record('enrol', $instance1); diff --git a/enrol/tests/externallib_test.php b/enrol/tests/externallib_test.php index da07c87ab97c8..ee4ca4bba3bd6 100644 --- a/enrol/tests/externallib_test.php +++ b/enrol/tests/externallib_test.php @@ -958,6 +958,154 @@ public function test_edit_user_enrolment() { $this->assertEquals(ENROL_USER_SUSPENDED, $ue->status); } + /** + * dataProvider for test_submit_user_enrolment_form(). + */ + public function submit_user_enrolment_form_provider() { + $now = new DateTime(); + + $nextmonth = clone($now); + $nextmonth->add(new DateInterval('P1M')); + + return [ + 'Invalid data' => [ + 'customdata' => [ + 'status' => ENROL_USER_ACTIVE, + 'timestart' => [ + 'day' => $now->format('j'), + 'month' => $now->format('n'), + 'year' => $now->format('Y'), + 'hour' => $now->format('G'), + 'minute' => 0, + 'enabled' => 1, + ], + 'timeend' => [ + 'day' => $now->format('j'), + 'month' => $now->format('n'), + 'year' => $now->format('Y'), + 'hour' => $now->format('G'), + 'minute' => 0, + 'enabled' => 1, + ], + ], + 'expectedresult' => false, + 'validationerror' => true + ], + 'Valid data' => [ + 'customdata' => [ + 'status' => ENROL_USER_ACTIVE, + 'timestart' => [ + 'day' => $now->format('j'), + 'month' => $now->format('n'), + 'year' => $now->format('Y'), + 'hour' => $now->format('G'), + 'minute' => 0, + 'enabled' => 1, + ], + 'timeend' => [ + 'day' => $nextmonth->format('j'), + 'month' => $nextmonth->format('n'), + 'year' => $nextmonth->format('Y'), + 'hour' => $nextmonth->format('G'), + 'minute' => 0, + 'enabled' => 1, + ], + ], + 'expectedresult' => true, + 'validationerror' => false + ], + 'Suspend user' => [ + 'customdata' => [ + 'status' => ENROL_USER_SUSPENDED, + ], + 'expectedresult' => true, + 'validationerror' => false + ], + ]; + } + + /** + * @param array $customdata The data we are providing to the webservice. + * @param bool $expectedresult The result we are expecting to receive from the webservice. + * @param bool $validationerror The validationerror we are expecting to receive from the webservice. + * @dataProvider submit_user_enrolment_form_provider + */ + public function test_submit_user_enrolment_form($customdata, $expectedresult, $validationerror) { + global $CFG, $DB; + + $this->resetAfterTest(true); + $datagen = $this->getDataGenerator(); + + /** @var enrol_manual_plugin $manualplugin */ + $manualplugin = enrol_get_plugin('manual'); + + $studentroleid = $DB->get_field('role', 'id', ['shortname' => 'student'], MUST_EXIST); + $teacherroleid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher'], MUST_EXIST); + $course = $datagen->create_course(); + $user = $datagen->create_user(); + $teacher = $datagen->create_user(); + + $instanceid = null; + $instances = enrol_get_instances($course->id, true); + foreach ($instances as $inst) { + if ($inst->enrol == 'manual') { + $instanceid = (int)$inst->id; + break; + } + } + if (empty($instanceid)) { + $instanceid = $manualplugin->add_default_instance($course); + if (empty($instanceid)) { + $instanceid = $manualplugin->add_instance($course); + } + } + $this->assertNotNull($instanceid); + + $instance = $DB->get_record('enrol', ['id' => $instanceid], '*', MUST_EXIST); + $manualplugin->enrol_user($instance, $user->id, $studentroleid, 0, 0, ENROL_USER_ACTIVE); + $manualplugin->enrol_user($instance, $teacher->id, $teacherroleid, 0, 0, ENROL_USER_ACTIVE); + $ueid = (int) $DB->get_field( + 'user_enrolments', + 'id', + ['enrolid' => $instance->id, 'userid' => $user->id], + MUST_EXIST + ); + + // Login as teacher. + $teacher->ignoresesskey = true; + $this->setUser($teacher); + + $formdata = [ + 'ue' => $ueid, + 'ifilter' => 0, + 'status' => null, + 'timestart' => null, + 'timeend' => null, + ]; + + $formdata = array_merge($formdata, $customdata); + + require_once("$CFG->dirroot/enrol/editenrolment_form.php"); + $formdata = enrol_user_enrolment_form::mock_generate_submit_keys($formdata); + + $querystring = http_build_query($formdata, '', '&'); + + $result = external_api::clean_returnvalue( + core_enrol_external::submit_user_enrolment_form_returns(), + core_enrol_external::submit_user_enrolment_form($querystring) + ); + + $this->assertEquals( + ['result' => $expectedresult, 'validationerror' => $validationerror], + $result, + '', 0.0, 10, true); + + if (!empty($result['result'])) { + $ue = $DB->get_record('user_enrolments', ['id' => $ueid], '*', MUST_EXIST); + $this->assertEquals($formdata['status'], $ue->status); + } + } + /** * Test for core_enrol_external::unenrol_user_enrolment(). */ diff --git a/favourites/tests/component_favourite_service_test.php b/favourites/tests/component_favourite_service_test.php index 78c43d242e0c3..b4440073aaad9 100644 --- a/favourites/tests/component_favourite_service_test.php +++ b/favourites/tests/component_favourite_service_test.php @@ -88,7 +88,7 @@ protected function get_mock_repository(array $mockstore) { // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { $returns[$index] = $mockrow; } } @@ -107,7 +107,7 @@ protected function get_mock_repository(array $mockstore) { $crit = ['userid' => $userid, 'component' => $comp, 'itemtype' => $type, 'itemid' => $id, 'contextid' => $ctxid]; foreach ($mockstore as $fakerow) { $fakerowarr = (array)$fakerow; - if (array_diff($crit, $fakerowarr) == []) { + if (array_diff_assoc($crit, $fakerowarr) == []) { return $fakerow; } } @@ -133,7 +133,7 @@ protected function get_mock_repository(array $mockstore) { // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { $count++; } } @@ -156,7 +156,7 @@ protected function get_mock_repository(array $mockstore) { // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { unset($mockstore[$index]); } } @@ -169,7 +169,7 @@ protected function get_mock_repository(array $mockstore) { foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; echo "Here"; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { return true; } } diff --git a/favourites/tests/user_favourite_service_test.php b/favourites/tests/user_favourite_service_test.php index c48e81feeac70..b4e4b61f66788 100644 --- a/favourites/tests/user_favourite_service_test.php +++ b/favourites/tests/user_favourite_service_test.php @@ -88,7 +88,7 @@ protected function get_mock_repository(array $mockstore) { // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { $returns[$index] = $mockrow; } } @@ -107,7 +107,7 @@ protected function get_mock_repository(array $mockstore) { $crit = ['userid' => $userid, 'component' => $comp, 'itemtype' => $type, 'itemid' => $id, 'contextid' => $ctxid]; foreach ($mockstore as $fakerow) { $fakerowarr = (array)$fakerow; - if (array_diff($crit, $fakerowarr) == []) { + if (array_diff_assoc($crit, $fakerowarr) == []) { return $fakerow; } } @@ -133,7 +133,7 @@ protected function get_mock_repository(array $mockstore) { // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { $count++; } } @@ -156,7 +156,7 @@ protected function get_mock_repository(array $mockstore) { // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. foreach ($mockstore as $index => $mockrow) { $mockrowarr = (array)$mockrow; - if (array_diff($criteria, $mockrowarr) == []) { + if (array_diff_assoc($criteria, $mockrowarr) == []) { return true; } } diff --git a/files/converter/googledrive/classes/converter.php b/files/converter/googledrive/classes/converter.php index aeda2ffbc6293..f55f8db05f201 100644 --- a/files/converter/googledrive/classes/converter.php +++ b/files/converter/googledrive/classes/converter.php @@ -124,7 +124,7 @@ public function start_document_conversion(\core_files\conversion $conversion) { $uploadurl; // Google returns a location header with the location for the upload. foreach ($headers as $header) { - if (strpos($header, 'Location:') === 0) { + if (stripos($header, 'Location:') === 0) { $uploadurl = trim(substr($header, strpos($header, ':') + 1)); } } diff --git a/files/converter/unoconv/classes/converter.php b/files/converter/unoconv/classes/converter.php index bbcedf0e73b30..cb20f607b2d50 100644 --- a/files/converter/unoconv/classes/converter.php +++ b/files/converter/unoconv/classes/converter.php @@ -194,7 +194,9 @@ public function serve_test_document() { $conversions = conversion::get_conversions_for_file($testdocx, $format); foreach ($conversions as $conversion) { - $conversion->delete(); + if ($conversion->get('id')) { + $conversion->delete(); + } } $conversion = new conversion(0, (object) [ diff --git a/filter/activitynames/tests/filter_test.php b/filter/activitynames/tests/filter_test.php index 403170efffe5a..b21c924c1b7d8 100644 --- a/filter/activitynames/tests/filter_test.php +++ b/filter/activitynames/tests/filter_test.php @@ -37,7 +37,6 @@ class filter_activitynames_filter_testcase extends advanced_testcase { public function test_links() { - global $CFG; $this->resetAfterTest(true); // Create a test course. @@ -59,8 +58,8 @@ public function test_links() { preg_match_all('~([^<]*)~', $filtered, $matches); - // There should be 3 links links. - $this->assertEquals(2, count($matches[1])); + // There should be 2 links links. + $this->assertCount(2, $matches[1]); // Check text of title attribute. $this->assertEquals($page1->name, $matches[1][0]); @@ -74,4 +73,33 @@ public function test_links() { $this->assertEquals($page1->name, $matches[3][0]); $this->assertEquals($page2->name, $matches[3][1]); } + + public function test_links_activity_named_hyphen() { + $this->resetAfterTest(true); + + // Create a test course. + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + // Work around an issue with the activity names filter which maintains a static cache + // of activities for current course ID. We can re-build the cache by switching user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Create a page activity named '-' (single hyphen). + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id, 'name' => '-']); + + $html = '

Please read the - page.

'; + $filtered = format_text($html, FORMAT_HTML, array('context' => $context)); + + // Find the page link in the filtered html. + preg_match_all('~([^<]*)~', + $filtered, $matches); + + // We should have exactly one match. + $this->assertCount(1, $matches[1]); + + $this->assertEquals($page->name, $matches[1][0]); + $this->assertEquals($page->cmid, $matches[2][0]); + $this->assertEquals($page->name, $matches[3][0]); + } } diff --git a/filter/classes/external.php b/filter/classes/external.php index ed6218f976304..07911a9ec5558 100644 --- a/filter/classes/external.php +++ b/filter/classes/external.php @@ -85,7 +85,7 @@ public static function get_available_in_context($contexts) { } catch (Exception $e) { $warnings[] = array( 'item' => 'context', - 'itemid' => $context['instanceid'], + 'itemid' => $contextinfo['instanceid'], 'warningcode' => $e->getCode(), 'message' => $e->getMessage(), ); diff --git a/filter/emoticon/filter.php b/filter/emoticon/filter.php index 5f5b3ff5dc052..6b6651ddcaca4 100644 --- a/filter/emoticon/filter.php +++ b/filter/emoticon/filter.php @@ -104,14 +104,14 @@ protected function replace_emoticons($text) { } // Detect all zones that we should not handle (including the nested tags). - $processing = preg_split('/(<\/?(?:span|script)[^>]*>)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + $processing = preg_split('/(<\/?(?:span|script|pre)[^>]*>)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); // Initialize the results. $resulthtml = ""; $exclude = 0; // Define the patterns that mark the start of the forbidden zones. - $excludepattern = array('/^