From d395925a4020c6a022cd3bae4d500d54998a3834 Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Mon, 8 Jan 2024 16:26:46 +0000 Subject: [PATCH] MDL-80548 admin: Add bulk user actions also to user list --- admin/amd/build/bulk_user_actions.min.js | 10 ++ admin/amd/build/bulk_user_actions.min.js.map | 1 + admin/amd/src/bulk_user_actions.js | 91 +++++++++++++++++++ .../local/systemreports/users.php | 7 ++ admin/tests/behat/browse_users.feature | 26 ++++++ .../mfa/classes/local/form/reset_factor.php | 3 + admin/tool/mfa/lib.php | 3 + admin/tool/mfa/reset_factor.php | 16 ++-- admin/user.php | 18 +++- admin/user/user_bulk.php | 9 +- admin/user/user_bulk_cohortadd.php | 10 +- admin/user/user_bulk_cohortadd_form.php | 3 + admin/user/user_bulk_confirm.php | 10 +- admin/user/user_bulk_delete.php | 10 +- admin/user/user_bulk_display.php | 3 +- admin/user/user_bulk_download.php | 5 +- admin/user/user_bulk_forcepasswordchange.php | 8 +- admin/user/user_bulk_forms.php | 52 +++++++++-- admin/user/user_bulk_message.php | 10 +- admin/user/user_message_form.php | 2 + cohort/tests/behat/add_cohort.feature | 18 ++++ lib/upgrade.txt | 2 + user/tests/behat/delete_users.feature | 16 ++++ 23 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 admin/amd/build/bulk_user_actions.min.js create mode 100644 admin/amd/build/bulk_user_actions.min.js.map create mode 100644 admin/amd/src/bulk_user_actions.js diff --git a/admin/amd/build/bulk_user_actions.min.js b/admin/amd/build/bulk_user_actions.min.js new file mode 100644 index 0000000000000..df8e7b09bb03a --- /dev/null +++ b/admin/amd/build/bulk_user_actions.min.js @@ -0,0 +1,10 @@ +define("core_admin/bulk_user_actions",["exports","core_reportbuilder/local/selectors","core_table/local/dynamic/events","core_form/changechecker","core/custom_interaction_events","jquery"],(function(_exports,reportSelectors,tableEvents,FormChangeChecker,CustomEvents,_jquery){var obj;function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj} +/** + * Add bulk actions to the users list report + * + * @module core_admin/bulk_user_actions + * @copyright 2024 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,reportSelectors=_interopRequireWildcard(reportSelectors),tableEvents=_interopRequireWildcard(tableEvents),FormChangeChecker=_interopRequireWildcard(FormChangeChecker),CustomEvents=_interopRequireWildcard(CustomEvents),_jquery=(obj=_jquery)&&obj.__esModule?obj:{default:obj};const Selectors_bulkActionsForm="form#user-bulk-action-form",Selectors_userReportWrapper='[data-region="report-user-list-wrapper"]',Selectors_checkbox='input[type="checkbox"][data-togglegroup="report-select-all"][data-toggle="slave"]',Selectors_masterCheckbox='input[type="checkbox"][data-togglegroup="report-select-all"][data-toggle="master"]',Selectors_checkedRows='[data-togglegroup="report-select-all"][data-toggle="slave"]:checked';_exports.init=()=>{var _userBulkForm$closest;const userBulkForm=document.querySelector(Selectors_bulkActionsForm),userReport=null==userBulkForm||null===(_userBulkForm$closest=userBulkForm.closest(Selectors_userReportWrapper))||void 0===_userBulkForm$closest?void 0:_userBulkForm$closest.querySelector(reportSelectors.regions.report);if(!userBulkForm||!userReport)return;const actionSelect=userBulkForm.querySelector("select");CustomEvents.define(actionSelect,[CustomEvents.events.accessibleChange]),(0,_jquery.default)(actionSelect).on(CustomEvents.events.accessibleChange,(event=>{if(event.target.value&&"0"!=="".concat(event.target.value)){const e=new Event("submit",{cancelable:!0});userBulkForm.dispatchEvent(e),e.defaultPrevented||(FormChangeChecker.markFormSubmitted(userBulkForm),userBulkForm.submit())}}));const updateUserIds=()=>{const selectedUsers=[...userReport.querySelectorAll(Selectors_checkedRows)],selectedUserIds=selectedUsers.map((check=>parseInt(check.value)));userBulkForm.querySelector('[name="userids"]').value=selectedUserIds.join(","),actionSelect.disabled=0===selectedUsers.length;const selectedUsersNames=selectedUsers.map((check=>document.querySelector('label[for="'.concat(check.id,'"]')).textContent));userBulkForm.data={userids:selectedUserIds,usernames:selectedUsersNames}};updateUserIds(),document.addEventListener("change",(event=>{(event.target.matches(Selectors_checkbox)||event.target.matches(Selectors_masterCheckbox))&&userReport.contains(event.target)&&updateUserIds()})),document.addEventListener(tableEvents.tableContentRefreshed,(event=>{userReport.contains(event.target)&&updateUserIds()}))}})); + +//# sourceMappingURL=bulk_user_actions.min.js.map \ No newline at end of file diff --git a/admin/amd/build/bulk_user_actions.min.js.map b/admin/amd/build/bulk_user_actions.min.js.map new file mode 100644 index 0000000000000..84606bc2486b2 --- /dev/null +++ b/admin/amd/build/bulk_user_actions.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"bulk_user_actions.min.js","sources":["../src/bulk_user_actions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Add bulk actions to the users list report\n *\n * @module core_admin/bulk_user_actions\n * @copyright 2024 Marina Glancy\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport * as tableEvents from 'core_table/local/dynamic/events';\nimport * as FormChangeChecker from 'core_form/changechecker';\nimport * as CustomEvents from 'core/custom_interaction_events';\nimport jQuery from 'jquery';\n\nconst Selectors = {\n bulkActionsForm: 'form#user-bulk-action-form',\n userReportWrapper: '[data-region=\"report-user-list-wrapper\"]',\n checkbox: 'input[type=\"checkbox\"][data-togglegroup=\"report-select-all\"][data-toggle=\"slave\"]',\n masterCheckbox: 'input[type=\"checkbox\"][data-togglegroup=\"report-select-all\"][data-toggle=\"master\"]',\n checkedRows: '[data-togglegroup=\"report-select-all\"][data-toggle=\"slave\"]:checked',\n};\n\n/**\n * Initialise module\n */\nexport const init = () => {\n\n const userBulkForm = document.querySelector(Selectors.bulkActionsForm);\n const userReport = userBulkForm?.closest(Selectors.userReportWrapper)?.querySelector(reportSelectors.regions.report);\n if (!userBulkForm || !userReport) {\n return;\n }\n const actionSelect = userBulkForm.querySelector('select');\n CustomEvents.define(actionSelect, [CustomEvents.events.accessibleChange]);\n\n jQuery(actionSelect).on(CustomEvents.events.accessibleChange, event => {\n if (event.target.value && `${event.target.value}` !== \"0\") {\n const e = new Event('submit', {cancelable: true});\n userBulkForm.dispatchEvent(e);\n if (!e.defaultPrevented) {\n FormChangeChecker.markFormSubmitted(userBulkForm);\n userBulkForm.submit();\n }\n }\n });\n\n // Every time the checkboxes in the report are changed, update the list of users in the form values\n // and enable/disable the action select.\n const updateUserIds = () => {\n const selectedUsers = [...userReport.querySelectorAll(Selectors.checkedRows)];\n const selectedUserIds = selectedUsers.map(check => parseInt(check.value));\n userBulkForm.querySelector('[name=\"userids\"]').value = selectedUserIds.join(',');\n actionSelect.disabled = selectedUsers.length === 0;\n const selectedUsersNames = selectedUsers.map(check => document.querySelector(`label[for=\"${check.id}\"]`).textContent);\n // Add the user ids and names to the form data attributes so they can be available from the\n // other JS modules that listen to the form submit event.\n userBulkForm.data = {userids: selectedUserIds, usernames: selectedUsersNames};\n };\n\n updateUserIds();\n\n document.addEventListener('change', event => {\n // When checkboxes are checked next to individual users or the master toggle (Select all/none).\n if ((event.target.matches(Selectors.checkbox) || event.target.matches(Selectors.masterCheckbox))\n && userReport.contains(event.target)) {\n updateUserIds();\n }\n });\n\n document.addEventListener(tableEvents.tableContentRefreshed, event => {\n // When the report contents is updated (i.e. page is changed, filters applied, etc).\n if (userReport.contains(event.target)) {\n updateUserIds();\n }\n });\n};\n"],"names":["Selectors","userBulkForm","document","querySelector","userReport","closest","_userBulkForm$closest","reportSelectors","regions","report","actionSelect","CustomEvents","define","events","accessibleChange","on","event","target","value","e","Event","cancelable","dispatchEvent","defaultPrevented","FormChangeChecker","markFormSubmitted","submit","updateUserIds","selectedUsers","querySelectorAll","selectedUserIds","map","check","parseInt","join","disabled","length","selectedUsersNames","id","textContent","data","userids","usernames","addEventListener","matches","contains","tableEvents","tableContentRefreshed"],"mappings":";;;;;;;0WA6BMA,0BACe,6BADfA,4BAEiB,2CAFjBA,mBAGQ,oFAHRA,yBAIc,qFAJdA,sBAKW,oFAMG,qCAEVC,aAAeC,SAASC,cAAcH,2BACtCI,WAAaH,MAAAA,4CAAAA,aAAcI,QAAQL,qEAAtBM,sBAAoDH,cAAcI,gBAAgBC,QAAQC,YACxGR,eAAiBG,wBAGhBM,aAAeT,aAAaE,cAAc,UAChDQ,aAAaC,OAAOF,aAAc,CAACC,aAAaE,OAAOC,uCAEhDJ,cAAcK,GAAGJ,aAAaE,OAAOC,kBAAkBE,WACtDA,MAAMC,OAAOC,OAAqC,MAA5B,UAAGF,MAAMC,OAAOC,OAAiB,OACjDC,EAAI,IAAIC,MAAM,SAAU,CAACC,YAAY,IAC3CpB,aAAaqB,cAAcH,GACtBA,EAAEI,mBACHC,kBAAkBC,kBAAkBxB,cACpCA,aAAayB,oBAOnBC,cAAgB,WACZC,cAAgB,IAAIxB,WAAWyB,iBAAiB7B,wBAChD8B,gBAAkBF,cAAcG,KAAIC,OAASC,SAASD,MAAMd,SAClEjB,aAAaE,cAAc,oBAAoBe,MAAQY,gBAAgBI,KAAK,KAC5ExB,aAAayB,SAAoC,IAAzBP,cAAcQ,aAChCC,mBAAqBT,cAAcG,KAAIC,OAAS9B,SAASC,mCAA4B6B,MAAMM,UAAQC,cAGzGtC,aAAauC,KAAO,CAACC,QAASX,gBAAiBY,UAAWL,qBAG9DV,gBAEAzB,SAASyC,iBAAiB,UAAU3B,SAE3BA,MAAMC,OAAO2B,QAAQ5C,qBAAuBgB,MAAMC,OAAO2B,QAAQ5C,4BAC3DI,WAAWyC,SAAS7B,MAAMC,SACjCU,mBAIRzB,SAASyC,iBAAiBG,YAAYC,uBAAuB/B,QAErDZ,WAAWyC,SAAS7B,MAAMC,SAC1BU"} \ No newline at end of file diff --git a/admin/amd/src/bulk_user_actions.js b/admin/amd/src/bulk_user_actions.js new file mode 100644 index 0000000000000..6e1a07565c3ca --- /dev/null +++ b/admin/amd/src/bulk_user_actions.js @@ -0,0 +1,91 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Add bulk actions to the users list report + * + * @module core_admin/bulk_user_actions + * @copyright 2024 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import * as reportSelectors from 'core_reportbuilder/local/selectors'; +import * as tableEvents from 'core_table/local/dynamic/events'; +import * as FormChangeChecker from 'core_form/changechecker'; +import * as CustomEvents from 'core/custom_interaction_events'; +import jQuery from 'jquery'; + +const Selectors = { + bulkActionsForm: 'form#user-bulk-action-form', + userReportWrapper: '[data-region="report-user-list-wrapper"]', + checkbox: 'input[type="checkbox"][data-togglegroup="report-select-all"][data-toggle="slave"]', + masterCheckbox: 'input[type="checkbox"][data-togglegroup="report-select-all"][data-toggle="master"]', + checkedRows: '[data-togglegroup="report-select-all"][data-toggle="slave"]:checked', +}; + +/** + * Initialise module + */ +export const init = () => { + + const userBulkForm = document.querySelector(Selectors.bulkActionsForm); + const userReport = userBulkForm?.closest(Selectors.userReportWrapper)?.querySelector(reportSelectors.regions.report); + if (!userBulkForm || !userReport) { + return; + } + const actionSelect = userBulkForm.querySelector('select'); + CustomEvents.define(actionSelect, [CustomEvents.events.accessibleChange]); + + jQuery(actionSelect).on(CustomEvents.events.accessibleChange, event => { + if (event.target.value && `${event.target.value}` !== "0") { + const e = new Event('submit', {cancelable: true}); + userBulkForm.dispatchEvent(e); + if (!e.defaultPrevented) { + FormChangeChecker.markFormSubmitted(userBulkForm); + userBulkForm.submit(); + } + } + }); + + // Every time the checkboxes in the report are changed, update the list of users in the form values + // and enable/disable the action select. + const updateUserIds = () => { + const selectedUsers = [...userReport.querySelectorAll(Selectors.checkedRows)]; + const selectedUserIds = selectedUsers.map(check => parseInt(check.value)); + userBulkForm.querySelector('[name="userids"]').value = selectedUserIds.join(','); + actionSelect.disabled = selectedUsers.length === 0; + const selectedUsersNames = selectedUsers.map(check => document.querySelector(`label[for="${check.id}"]`).textContent); + // Add the user ids and names to the form data attributes so they can be available from the + // other JS modules that listen to the form submit event. + userBulkForm.data = {userids: selectedUserIds, usernames: selectedUsersNames}; + }; + + updateUserIds(); + + document.addEventListener('change', event => { + // When checkboxes are checked next to individual users or the master toggle (Select all/none). + if ((event.target.matches(Selectors.checkbox) || event.target.matches(Selectors.masterCheckbox)) + && userReport.contains(event.target)) { + updateUserIds(); + } + }); + + document.addEventListener(tableEvents.tableContentRefreshed, event => { + // When the report contents is updated (i.e. page is changed, filters applied, etc). + if (userReport.contains(event.target)) { + updateUserIds(); + } + }); +}; diff --git a/admin/classes/reportbuilder/local/systemreports/users.php b/admin/classes/reportbuilder/local/systemreports/users.php index 680b1c5f047b0..b3ae4c5643468 100644 --- a/admin/classes/reportbuilder/local/systemreports/users.php +++ b/admin/classes/reportbuilder/local/systemreports/users.php @@ -67,6 +67,13 @@ protected function initialise(): void { $this->add_base_fields("{$entityuseralias}.id, {$entityuseralias}.confirmed, {$entityuseralias}.mnethostid, {$entityuseralias}.suspended, {$entityuseralias}.username, " . implode(', ', $fullnamefields)); + if ($this->get_parameter('withcheckboxes', false, PARAM_BOOL)) { + $canviewfullnames = has_capability('moodle/site:viewfullnames', \context_system::instance()); + $this->set_checkbox_toggleall(static function(\stdClass $row) use ($canviewfullnames): array { + return [$row->id, fullname($row, $canviewfullnames)]; + }); + } + $paramguest = database::generate_param_name(); $this->add_base_condition_sql("{$entityuseralias}.deleted <> 1 AND {$entityuseralias}.id <> :{$paramguest}", [$paramguest => $CFG->siteguest]); diff --git a/admin/tests/behat/browse_users.feature b/admin/tests/behat/browse_users.feature index dd2d3fcb7504d..0c6a8e309d7d8 100644 --- a/admin/tests/behat/browse_users.feature +++ b/admin/tests/behat/browse_users.feature @@ -195,3 +195,29 @@ Feature: An administrator can browse user accounts Then I should see "Username" And I should see "User picture" And I should see "Additional names" + + @javascript + Scenario: Browse user list as a person with limited capabilities + Given the following "users" exist: + | username | firstname | lastname | email | + | manager | Max | Manager | manager@example.com | + And the following "roles" exist: + | name | shortname | description | archetype | + | Custom manager | custom1 | My custom role 1 | | + And the following "permission overrides" exist: + | capability | permission | role | contextlevel | reference | + | moodle/site:configview | Allow | custom1 | System | | + | moodle/user:update | Allow | custom1 | System | | + And the following "role assigns" exist: + | user | role | contextlevel | reference | + | manager | custom1 | System | | + When I log in as "manager" + And I navigate to "Users > Accounts > Browse list of users" in site administration + And I click on "User One" "checkbox" + And the "Bulk user actions" select box should contain "Confirm" + And the "Bulk user actions" select box should not contain "Delete" + And I set the field "Bulk user actions" to "Force password change" + And I should see "Are you absolutely sure you want to force a password change to User One ?" + And I press "Yes" + And I press "Continue" + And I should see "Browse list of users" diff --git a/admin/tool/mfa/classes/local/form/reset_factor.php b/admin/tool/mfa/classes/local/form/reset_factor.php index 3db0e114e91e7..b9ccbc06a9e38 100644 --- a/admin/tool/mfa/classes/local/form/reset_factor.php +++ b/admin/tool/mfa/classes/local/form/reset_factor.php @@ -40,6 +40,9 @@ public function definition(): void { $mform->addElement('hidden', 'bulkaction', $bulkaction); $mform->setType('bulkaction', PARAM_BOOL); + $mform->addElement('hidden', 'returnurl'); + $mform->setType('returnurl', PARAM_LOCALURL); + $factors = array_map(function ($element) { return $element->get_display_name(); }, $factors); diff --git a/admin/tool/mfa/lib.php b/admin/tool/mfa/lib.php index c858c764c3d60..0dbce19945b19 100644 --- a/admin/tool/mfa/lib.php +++ b/admin/tool/mfa/lib.php @@ -112,6 +112,9 @@ function tool_mfa_after_config(): void { * @return array */ function tool_mfa_bulk_user_actions(): array { + if (!has_capability('moodle/site:config', context_system::instance())) { + return []; + } return [ 'tool_mfa_reset_factors' => new action_link( new moodle_url('/admin/tool/mfa/reset_factor.php'), diff --git a/admin/tool/mfa/reset_factor.php b/admin/tool/mfa/reset_factor.php index e63feb493bb3b..4a7334cb0fd1c 100644 --- a/admin/tool/mfa/reset_factor.php +++ b/admin/tool/mfa/reset_factor.php @@ -28,17 +28,19 @@ admin_externalpage_setup('tool_mfa_resetfactor'); $bulk = !empty($SESSION->bulk_users); +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); $factors = \tool_mfa\plugininfo\factor::get_factors(); $form = new \tool_mfa\local\form\reset_factor(null, ['factors' => $factors, 'bulk' => $bulk]); +if ($bulk) { + $form->set_data(['returnurl' => $returnurl]); + $return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); +} else { + $return = new moodle_url('/admin/category.php', ['category' => 'toolmfafolder']); +} if ($form->is_cancelled()) { - if ($bulk) { - $url = new moodle_url('/admin/user/user_bulk.php'); - } else { - $url = new moodle_url('/admin/category.php', ['category' => 'toolmfafolder']); - } - redirect($url); + redirect($return); } else if ($fromform = $form->get_data()) { // Get factor from select index. if ($fromform->factor !== 'all') { @@ -82,7 +84,7 @@ \core\notification::success(get_string('resetsuccessbulk', 'tool_mfa', $stringvar)); unset($SESSION->bulk_users); // Redirect to bulk actions page. - redirect(new moodle_url('/admin/user/user_bulk.php')); + redirect($return); } echo $OUTPUT->header(); diff --git a/admin/user.php b/admin/user.php index 4e838e69d43e9..63e16a229c19a 100644 --- a/admin/user.php +++ b/admin/user.php @@ -4,6 +4,7 @@ require_once($CFG->libdir.'/adminlib.php'); require_once($CFG->libdir.'/authlib.php'); require_once($CFG->dirroot.'/user/lib.php'); + require_once($CFG->dirroot.'/'.$CFG->admin.'/user/user_bulk_forms.php'); $delete = optional_param('delete', 0, PARAM_INT); $confirm = optional_param('confirm', '', PARAM_ALPHANUM); //md5 confirmation hash @@ -172,8 +173,23 @@ echo html_writer::end_div(); } + echo html_writer::start_div('', ['data-region' => 'report-user-list-wrapper']); + + $bulkactions = new user_bulk_action_form(new moodle_url('/admin/user/user_bulk.php'), + ['excludeactions' => ['displayonpage'], 'passuserids' => true, 'hidesubmit' => true], + 'post', '', + ['id' => 'user-bulk-action-form']); + $bulkactions->set_data(['returnurl' => $PAGE->url->out_as_local_url(false)]); + $report = \core_reportbuilder\system_report_factory::create(\core_admin\reportbuilder\local\systemreports\users::class, - context_system::instance()); + context_system::instance(), parameters: ['withcheckboxes' => $bulkactions->has_bulk_actions()]); echo $report->output(); + if ($bulkactions->has_bulk_actions()) { + $PAGE->requires->js_call_amd('core_admin/bulk_user_actions', 'init'); + $bulkactions->display(); + } + + echo html_writer::end_div(); + echo $OUTPUT->footer(); diff --git a/admin/user/user_bulk.php b/admin/user/user_bulk.php index 90aeb021fe636..5084ffe4d5b84 100644 --- a/admin/user/user_bulk.php +++ b/admin/user/user_bulk.php @@ -37,11 +37,18 @@ // Create the bulk operations form. $actionform = new user_bulk_action_form(); +$actionform->set_data(['returnurl' => $PAGE->url->out_as_local_url(false)]); if ($data = $actionform->get_data()) { + if ($data->passuserids) { + // This means we called the form from /admin/user.php or similar and the userids should be taken from the form + // data and not from $SESSION->bulk_users. For backwards compatibility we still set $SESSION->bulk_users. + $users = preg_split('/,/', $data->userids, -1, PREG_SPLIT_NO_EMPTY); + $SESSION->bulk_users = array_combine($users, $users); + } // Check if an action should be performed and do so. $bulkactions = $actionform->get_actions(); if (array_key_exists($data->action, $bulkactions)) { - redirect($bulkactions[$data->action]->url); + redirect(new moodle_url($bulkactions[$data->action]->url, ['returnurl' => $data->returnurl ?: null])); } } diff --git a/admin/user/user_bulk_cohortadd.php b/admin/user/user_bulk_cohortadd.php index 21e949f5433eb..a3dc8c63e474f 100644 --- a/admin/user/user_bulk_cohortadd.php +++ b/admin/user/user_bulk_cohortadd.php @@ -34,6 +34,9 @@ admin_externalpage_setup('userbulk'); require_capability('moodle/cohort:assign', context_system::instance()); +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); + $users = $SESSION->bulk_users; $strnever = get_string('never'); @@ -60,7 +63,7 @@ unset($allcohorts); if (count($cohorts) < 2) { - redirect(new moodle_url('/admin/user/user_bulk.php'), get_string('bulknocohort', 'core_cohort')); + redirect($return, get_string('bulknocohort', 'core_cohort')); } $countries = get_string_manager()->get_list_of_countries(true); @@ -78,9 +81,10 @@ unset($countries); $mform = new user_bulk_cohortadd_form(null, $cohorts); +$mform->set_data(['returnurl' => $returnurl]); if (empty($users) or $mform->is_cancelled()) { - redirect(new moodle_url('/admin/user/user_bulk.php')); + redirect($return); } else if ($data = $mform->get_data()) { // process request @@ -89,7 +93,7 @@ cohort_add_member($data->cohort, $user->id); } } - redirect(new moodle_url('/admin/user/user_bulk.php')); + redirect($return); } // Need to sort by date diff --git a/admin/user/user_bulk_cohortadd_form.php b/admin/user/user_bulk_cohortadd_form.php index 5ba19cce557cc..bff2c06a7d144 100644 --- a/admin/user/user_bulk_cohortadd_form.php +++ b/admin/user/user_bulk_cohortadd_form.php @@ -31,6 +31,9 @@ function definition() { $mform = $this->_form; $cohorts = $this->_customdata; + $mform->addElement('hidden', 'returnurl'); + $mform->setType('returnurl', PARAM_LOCALURL); + $mform->addElement('select', 'cohort', get_string('cohort', 'core_cohort'), $cohorts); $mform->addRule('cohort', get_string('required'), 'required', null, 'client'); diff --git a/admin/user/user_bulk_confirm.php b/admin/user/user_bulk_confirm.php index 4efec34c8073d..c55362262cddf 100644 --- a/admin/user/user_bulk_confirm.php +++ b/admin/user/user_bulk_confirm.php @@ -11,7 +11,8 @@ admin_externalpage_setup('userbulk'); require_capability('moodle/user:update', context_system::instance()); -$return = $CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk.php'; +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); if (empty($SESSION->bulk_users)) { redirect($return); @@ -45,7 +46,7 @@ } else { echo $OUTPUT->notification(get_string('changessaved'), 'notifysuccess'); } - $continue = new single_button(new moodle_url($return), get_string('continue'), 'post'); + $continue = new single_button($return, get_string('continue'), 'post'); echo $OUTPUT->render($continue); echo $OUTPUT->box_end(); } else { @@ -53,8 +54,9 @@ $userlist = $DB->get_records_select_menu('user', "id $in", $params, 'fullname', 'id,'.$DB->sql_fullname().' AS fullname'); $usernames = implode(', ', $userlist); echo $OUTPUT->heading(get_string('confirmation', 'admin')); - $formcontinue = new single_button(new moodle_url('user_bulk_confirm.php', array('confirm' => 1)), get_string('yes')); - $formcancel = new single_button(new moodle_url('user_bulk.php'), get_string('no'), 'get'); + $formcontinue = new single_button(new moodle_url('user_bulk_confirm.php', + ['confirm' => 1, 'returnurl' => $returnurl]), get_string('yes')); + $formcancel = new single_button($return, get_string('no'), 'get'); echo $OUTPUT->confirm(get_string('confirmcheckfull', '', $usernames), $formcontinue, $formcancel); } diff --git a/admin/user/user_bulk_delete.php b/admin/user/user_bulk_delete.php index 859c92f49ddb9..2a9ca180bcc26 100644 --- a/admin/user/user_bulk_delete.php +++ b/admin/user/user_bulk_delete.php @@ -11,7 +11,8 @@ admin_externalpage_setup('userbulk'); require_capability('moodle/user:delete', context_system::instance()); -$return = $CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk.php'; +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); if (empty($SESSION->bulk_users)) { redirect($return); @@ -43,7 +44,7 @@ } else { echo $OUTPUT->notification(get_string('changessaved'), 'notifysuccess'); } - $continue = new single_button(new moodle_url($return), get_string('continue'), 'post'); + $continue = new single_button($return, get_string('continue'), 'post'); echo $OUTPUT->render($continue); echo $OUTPUT->box_end(); } else { @@ -51,8 +52,9 @@ $userlist = $DB->get_records_select_menu('user', "id $in", $params, 'fullname', 'id,'.$DB->sql_fullname().' AS fullname'); $usernames = implode(', ', $userlist); echo $OUTPUT->heading(get_string('confirmation', 'admin')); - $formcontinue = new single_button(new moodle_url('user_bulk_delete.php', array('confirm' => 1)), get_string('yes')); - $formcancel = new single_button(new moodle_url('user_bulk.php'), get_string('no'), 'get'); + $formcontinue = new single_button(new moodle_url('user_bulk_delete.php', + ['confirm' => 1, 'returnurl' => $returnurl]), get_string('yes')); + $formcancel = new single_button($return, get_string('no'), 'get'); echo $OUTPUT->confirm(get_string('deletecheckfull', '', $usernames), $formcontinue, $formcancel); } diff --git a/admin/user/user_bulk_display.php b/admin/user/user_bulk_display.php index 2e3b6c38f8de5..7ce20d75879f4 100644 --- a/admin/user/user_bulk_display.php +++ b/admin/user/user_bulk_display.php @@ -8,7 +8,8 @@ admin_externalpage_setup('userbulk'); -$return = $CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk.php'; +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); if (empty($SESSION->bulk_users)) { redirect($return); diff --git a/admin/user/user_bulk_download.php b/admin/user/user_bulk_download.php index 1e908f67ccea1..316be90775631 100644 --- a/admin/user/user_bulk_download.php +++ b/admin/user/user_bulk_download.php @@ -34,8 +34,11 @@ admin_externalpage_setup('userbulk'); require_capability('moodle/user:update', context_system::instance()); +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); + if (empty($SESSION->bulk_users)) { - redirect(new moodle_url('/admin/user/user_bulk.php')); + redirect($return); } if ($dataformat) { diff --git a/admin/user/user_bulk_forcepasswordchange.php b/admin/user/user_bulk_forcepasswordchange.php index 09e9361d48266..607c47160b172 100644 --- a/admin/user/user_bulk_forcepasswordchange.php +++ b/admin/user/user_bulk_forcepasswordchange.php @@ -12,7 +12,8 @@ admin_externalpage_setup('userbulk'); require_capability('moodle/user:update', context_system::instance()); -$return = $CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk.php'; +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); if (empty($SESSION->bulk_users)) { redirect($return); @@ -62,8 +63,9 @@ $usernames .= ', ...'; } echo $OUTPUT->heading(get_string('confirmation', 'admin')); - $formcontinue = new single_button(new moodle_url('/admin/user/user_bulk_forcepasswordchange.php', array('confirm' => 1)), get_string('yes')); - $formcancel = new single_button(new moodle_url('/admin/user/user_bulk.php'), get_string('no'), 'get'); + $formcontinue = new single_button(new moodle_url('/admin/user/user_bulk_forcepasswordchange.php', + ['confirm' => 1, 'returnurl' => $returnurl]), get_string('yes')); + $formcancel = new single_button($return, get_string('no'), 'get'); echo $OUTPUT->confirm(get_string('forcepasswordchangecheckfull', '', $usernames), $formcontinue, $formcancel); } diff --git a/admin/user/user_bulk_forms.php b/admin/user/user_bulk_forms.php index 2dcf4c5c3f89c..8af94588a8650 100644 --- a/admin/user/user_bulk_forms.php +++ b/admin/user/user_bulk_forms.php @@ -35,6 +35,9 @@ */ class user_bulk_action_form extends moodleform { + /** @var bool */ + protected $hasbulkactions = false; + /** * Returns an array of action_link's of all bulk actions available for this user. * @@ -44,6 +47,8 @@ public function get_actions(): array { global $CFG; + $canaccessbulkactions = has_any_capability(['moodle/user:update', 'moodle/user:delete'], context_system::instance()); + $syscontext = context_system::instance(); $actions = []; if (has_capability('moodle/user:update', $syscontext)) { @@ -51,7 +56,7 @@ public function get_actions(): array { new moodle_url('/admin/user/user_bulk_confirm.php'), get_string('confirm')); } - if (has_capability('moodle/site:readallmessages', $syscontext) && !empty($CFG->messaging)) { + if ($canaccessbulkactions && has_capability('moodle/site:readallmessages', $syscontext) && !empty($CFG->messaging)) { $actions['message'] = new action_link( new moodle_url('/admin/user/user_bulk_message.php'), get_string('messageselectadd')); @@ -61,9 +66,11 @@ public function get_actions(): array { new moodle_url('/admin/user/user_bulk_delete.php'), get_string('delete')); } - $actions['displayonpage'] = new action_link( + if ($canaccessbulkactions) { + $actions['displayonpage'] = new action_link( new moodle_url('/admin/user/user_bulk_display.php'), get_string('displayonpage')); + } if (has_capability('moodle/user:update', $syscontext)) { $actions['download'] = new action_link( @@ -76,7 +83,7 @@ public function get_actions(): array { new moodle_url('/admin/user/user_bulk_forcepasswordchange.php'), get_string('forcepasswordchange')); } - if (has_capability('moodle/cohort:assign', $syscontext)) { + if ($canaccessbulkactions && has_capability('moodle/cohort:assign', $syscontext)) { $actions['addtocohort'] = new action_link( new moodle_url('/admin/user/user_bulk_cohortadd.php'), get_string('bulkadd', 'core_cohort')); @@ -93,6 +100,15 @@ public function get_actions(): array { } } + // This method may be called from 'Bulk actions' and 'Browse user list' pages. Some actions + // may be irrelevant in one of the contexts and they can be excluded by specifying the + // 'excludeactions' customdata. + $excludeactions = $this->_customdata['excludeactions'] ?? []; + foreach ($excludeactions as $excludeaction) { + unset($actions[$excludeaction]); + } + + $this->hasbulkactions = !empty($actions); return $actions; } @@ -101,20 +117,42 @@ public function get_actions(): array { * Form definition */ public function definition() { - global $CFG; - $mform =& $this->_form; + $mform->addElement('hidden', 'returnurl'); + $mform->setType('returnurl', PARAM_LOCALURL); + + // When 'passuserids' is specified in the customdata, the user ids are expected in the form + // data rather than in the $SESSION->bulk_users . + $passuserids = !empty($this->_customdata['passuserids']); + $mform->addElement('hidden', 'passuserids', $passuserids); + $mform->setType('passuserids', PARAM_BOOL); + + $mform->addElement('hidden', 'userids'); + $mform->setType('userids', PARAM_SEQUENCE); + $actions = [0 => get_string('choose') . '...']; $bulkactions = $this->get_actions(); foreach ($bulkactions as $key => $action) { $actions[$key] = $action->text; } $objs = array(); - $objs[] =& $mform->createElement('select', 'action', null, $actions); - $objs[] =& $mform->createElement('submit', 'doaction', get_string('go')); + $objs[] = $selectel = $mform->createElement('select', 'action', get_string('userbulk', 'admin'), $actions); + $selectel->setHiddenLabel(true); + if (empty($this->_customdata['hidesubmit'])) { + $objs[] =& $mform->createElement('submit', 'doaction', get_string('go')); + } $mform->addElement('group', 'actionsgrp', get_string('withselectedusers'), $objs, ' ', false); } + + /** + * Is there at least one available bulk action in this form + * + * @return bool + */ + public function has_bulk_actions(): bool { + return $this->hasbulkactions; + } } /** diff --git a/admin/user/user_bulk_message.php b/admin/user/user_bulk_message.php index 29159dbf00118..9495f4b55e91d 100644 --- a/admin/user/user_bulk_message.php +++ b/admin/user/user_bulk_message.php @@ -10,7 +10,8 @@ admin_externalpage_setup('userbulk'); require_capability('moodle/site:manageallmessaging', context_system::instance()); -$return = $CFG->wwwroot.'/'.$CFG->admin.'/user/user_bulk.php'; +$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); +$return = new moodle_url($returnurl ?: '/admin/user/user_bulk.php'); if (empty($SESSION->bulk_users)) { redirect($return); @@ -39,6 +40,7 @@ } $msgform = new user_message_form('user_bulk_message.php'); +$msgform->set_data(['returnurl' => $returnurl]); if ($msgform->is_cancelled()) { redirect($return); @@ -58,8 +60,10 @@ echo $OUTPUT->heading(get_string('confirmation', 'admin')); echo $OUTPUT->box($msg, 'boxwidthnarrow boxaligncenter generalbox', 'preview'); //TODO: clean once we start using proper text formats here - $formcontinue = new single_button(new moodle_url('user_bulk_message.php', array('confirm' => 1, 'msg' => $msg)), get_string('yes')); //TODO: clean once we start using proper text formats here - $formcancel = new single_button(new moodle_url('user_bulk.php'), get_string('no'), 'get'); + $formcontinue = new single_button(new moodle_url('user_bulk_message.php', + ['confirm' => 1, 'msg' => $msg, 'returnurl' => $returnurl]), + get_string('yes')); // TODO: clean once we start using proper text formats here. + $formcancel = new single_button($return, get_string('no'), 'get'); echo $OUTPUT->confirm(get_string('confirmmessage', 'bulkusers', $usernames), $formcontinue, $formcancel); echo $OUTPUT->footer(); die; diff --git a/admin/user/user_message_form.php b/admin/user/user_message_form.php index 0e8205102bcf9..9f192417be569 100644 --- a/admin/user/user_message_form.php +++ b/admin/user/user_message_form.php @@ -12,6 +12,8 @@ function definition() { $mform =& $this->_form; $mform->addElement('header', 'general', get_string('message', 'message')); + $mform->addElement('hidden', 'returnurl'); + $mform->setType('returnurl', PARAM_LOCALURL); $mform->addElement('editor', 'messagebody', get_string('messagebody'), null, null); $mform->addRule('messagebody', '', 'required', null, 'server'); diff --git a/cohort/tests/behat/add_cohort.feature b/cohort/tests/behat/add_cohort.feature index b20e2417a7376..5b1f6a6b18f68 100644 --- a/cohort/tests/behat/add_cohort.feature +++ b/cohort/tests/behat/add_cohort.feature @@ -100,6 +100,24 @@ Feature: Add cohorts of users And the "Current users" select box should contain "Forth User (forth@example.com)" And the "Current users" select box should not contain "First User (first@example.com)" + @javascript + Scenario: Add users to a cohort using a user list bulk action + When I navigate to "Users > Accounts > Browse list of users" in site administration + And I click on "Third User" "checkbox" + And I click on "Forth User" "checkbox" + And I set the field "Bulk user actions" to "Add to cohort" + And I set the field "Cohort" to "Test cohort name [333]" + And I press "Add to cohort" + And I should see "Browse list of users" + And I navigate to "Users > Accounts > Cohorts" in site administration + Then the following should exist in the "reportbuilder-table" table: + | Name | Cohort size | + | Test cohort name | 2 | + And I press "Assign" action in the "Test cohort name" report row + And the "Current users" select box should contain "Third User (third@example.com)" + And the "Current users" select box should contain "Forth User (forth@example.com)" + And the "Current users" select box should not contain "First User (first@example.com)" + @javascript Scenario: Edit cohort name in-place When I navigate to "Users > Accounts > Cohorts" in site administration diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 79c35c13d2575..2d74afb762fbc 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -64,6 +64,8 @@ information provided here is intended especially for developers. - $numtasks - $mode * Removed \zip_writer::sanitise_filepath and \zipwriter::sanitise_filename as they are now automatically sanitised in the zipstream. +* Plugins implementing callback `bulk_user_actions()` should be aware that bulk user actions can be executed + from /admin/user.php as well as from the bulk actions page. The 'returnurl' parameter will be passed in the request. === 4.3 === diff --git a/user/tests/behat/delete_users.feature b/user/tests/behat/delete_users.feature index abc176d82b82b..6bd0716e7ba36 100644 --- a/user/tests/behat/delete_users.feature +++ b/user/tests/behat/delete_users.feature @@ -57,6 +57,22 @@ Feature: Deleting users And the "Available" select box should not contain "User Three" And the "Available" select box should contain "User One" + @javascript + Scenario: Deleting users from bulk actions in the user list + When I log in as "admin" + And I navigate to "Users > Accounts > Browse list of users" in site administration + And I click on "User Four" "checkbox" + And I click on "User Three" "checkbox" + And I set the field "Bulk user actions" to "Delete" + And I should see "Are you absolutely sure you want to completely delete the user User Four, User Three, including their enrolments, activity and other user data?" + And I press "Yes" + And I should see "Changes saved" + And I press "Continue" + And I should see "Browse list of users" + And I should not see "User Four" + And I should not see "User Three" + And I should see "User One" + @javascript @core_message Scenario: Deleting users who have unread messages sent or received When I log in as "user1"