From 26dfacda61bad8385f3a0a168c4a5e8e0fb7a4c6 Mon Sep 17 00:00:00 2001 From: Lucy Fu Date: Mon, 12 May 2025 16:10:42 -0400 Subject: [PATCH] Fixes #38416 - Hosts bulk action : Assign organization/ location --- .../api/v2/hosts_bulk_actions_controller.rb | 43 +++ .../foreman/controller/taxonomy_multiple.rb | 19 +- app/services/bulk_hosts_manager.rb | 15 + config/initializers/f_foreman_permissions.rb | 2 +- config/routes/api/v2.rb | 1 + test/controllers/hosts_controller_test.rb | 4 +- .../assignTaxonomy/BulkAssignTaxonomyModal.js | 257 ++++++++++++++++++ .../assignTaxonomy/TaxonomySelect.js | 83 ++++++ .../BulkActions/assignTaxonomy/actions.js | 41 +++ .../BulkActions/assignTaxonomy/index.js | 24 ++ .../react_app/components/HostsIndex/index.js | 49 +++- 11 files changed, 516 insertions(+), 22 deletions(-) create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js create mode 100644 webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js diff --git a/app/controllers/api/v2/hosts_bulk_actions_controller.rb b/app/controllers/api/v2/hosts_bulk_actions_controller.rb index 3287ce4c11..317b2bc51f 100644 --- a/app/controllers/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/api/v2/hosts_bulk_actions_controller.rb @@ -78,6 +78,32 @@ def reassign_hostgroup end end + api :PUT, "/hosts/bulk/assign_taxonomy", N_("Assign organization/location") + param_group :bulk_host_ids + param :target_organization_id, :number, :desc => N_("ID of the organization to assign the hosts to") + param :target_location_id, :number, :desc => N_("ID of the location to assign the hosts to") + param :mismatch_setting_organization, :bool, N_("Fix organization on mismatch") + param :mismatch_setting_location, :bool, N_("Fix location on mismatch") + def assign_taxonomy + validate_taxonomy_settings + clear_current_taxonomy + find_editable_hosts + + targets = Taxonomy.where(id: [params[:target_organization_id], params[:target_location_id]]) + messages = targets.map do |tax| + tax_type = tax.type.downcase + BulkHostsManager.new(hosts: @hosts).assign_taxonomy(tax, params["mismatch_setting_#{tax_type}"]) + tax_type == 'organization' ? _("Organization is set to %s.") % tax.name : _("Location is set to %s.") % tax.name + end + + process_response(true, { :message => n_("Updated host: %{update}", "Updated hosts: %{update}", + @hosts.count) % { update: messages.join(" ") }}) + rescue => e + render_error(:custom_error, :status => :unprocessable_entity, :locals => { :message => e.message}) + ensure + restore_current_taxonomy + end + protected def action_permission @@ -99,6 +125,23 @@ def find_editable_hosts find_bulk_hosts(:edit_hosts, params) end + def clear_current_taxonomy + @context = Foreman::ThreadSession::Context.get + Foreman::ThreadSession::Context.set(user: @context[:user]) + end + + def restore_current_taxonomy + Foreman::ThreadSession::Context.set(**@context) if @context + end + + def validate_taxonomy_settings + if [params[:mismatch_setting_organization], params[:mismatch_setting_location]].any? { |val| val == false } + raise _("Cannot update host(s) because of mismatch in settings.") + elsif [params[:mismatch_setting_organization], params[:mismatch_setting_location]].all? { |val| val.nil? } + raise _("At lease one of organization/location mismatch settings should be specified.") + end + end + def rebuild_config all_fails = BulkHostsManager.new(hosts: @hosts).rebuild_configuration failed_host_ids = all_fails.flat_map { |_key, values| values&.map(&:id) } diff --git a/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb b/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb index f812a0dcef..94e2f0efc2 100644 --- a/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb +++ b/app/controllers/concerns/foreman/controller/taxonomy_multiple.rb @@ -36,21 +36,12 @@ def update_multiple_taxonomies(type) end taxonomy = Taxonomy.find_by_id(id) - - if params[type][:optimistic_import] == 'yes' - @hosts.update_all("#{type}_id".to_sym => taxonomy.id) - # hosts location needs to be updated before import missing ids - taxonomy.import_missing_ids - else - if taxonomy.need_to_be_selected_ids.count == 0 - @hosts.update_all("#{type}_id".to_sym => taxonomy.id) - else - error "Cannot update #{taxonomy.type} to #{taxonomy.name} because of mismatch in settings" - redirect_back_or_to helpers.current_hosts_path - return - end + begin + BulkHostsManager.new(hosts: @hosts).assign_taxonomy(taxonomy, params[type][:optimistic_import] == 'yes') + success "Updated hosts: Changed #{type.to_s.classify}" + rescue => e + error e.message end - success "Updated hosts: Changed #{type.to_s.classify}" redirect_back_or_to helpers.current_hosts_path end end diff --git a/app/services/bulk_hosts_manager.rb b/app/services/bulk_hosts_manager.rb index ef93a27f66..1e85b9e9c8 100644 --- a/app/services/bulk_hosts_manager.rb +++ b/app/services/bulk_hosts_manager.rb @@ -38,4 +38,19 @@ def rebuild_configuration end all_fails end + + # @param [Boolean] optimistic_import + # either fix on mismatch or fail on mismatch + def assign_taxonomy(taxonomy, optimistic_import) + tax_type = taxonomy.type.downcase + if optimistic_import + @hosts.update_all("#{tax_type}_id".to_sym => taxonomy.id) + # hosts location needs to be updated before import missing ids + taxonomy.import_missing_ids + elsif taxonomy.need_to_be_selected_ids.empty? + @hosts.update_all("#{tax_type}_id".to_sym => taxonomy.id) + else + raise "Cannot update #{taxonomy.type.downcase} to #{taxonomy.name} because of mismatch in settings" + end + end end diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 6d29d1a8f1..6cb8446d75 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -271,7 +271,7 @@ :"api/v2/hosts" => [:update, :disassociate, :forget_status], :"api/v2/interfaces" => [:create, :update, :destroy], :"api/v2/compute_resources" => [:associate], - :"api/v2/hosts_bulk_actions" => [:build, :reassign_hostgroup], + :"api/v2/hosts_bulk_actions" => [:build, :reassign_hostgroup, :assign_taxonomy], } map.permission :destroy_hosts, {:hosts => [:destroy, :multiple_actions, :reset_multiple, :multiple_destroy, :submit_multiple_destroy], :"api/v2/hosts" => [:destroy], diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 74ce286d37..a670eb347e 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -4,6 +4,7 @@ # new v2 routes that point to v2 scope "(:apiv)", :module => :v2, :defaults => {:apiv => 'v2'}, :apiv => /v2/, :constraints => ApiConstraints.new(:version => 2, :default => true) do match 'hosts/bulk', :to => 'hosts_bulk_actions#bulk_destroy', :via => [:delete] + put 'hosts/bulk/assign_taxonomy', :to => 'hosts_bulk_actions#assign_taxonomy' match 'hosts/bulk/build', :to => 'hosts_bulk_actions#build', :via => [:put] match 'hosts/bulk/reassign_hostgroup', :to => 'hosts_bulk_actions#reassign_hostgroup', :via => [:put] diff --git a/test/controllers/hosts_controller_test.rb b/test/controllers/hosts_controller_test.rb index 8584b66927..09c6826614 100644 --- a/test/controllers/hosts_controller_test.rb +++ b/test/controllers/hosts_controller_test.rb @@ -811,7 +811,7 @@ def test_unset_manage :host_ids => Host.pluck('hosts.id'), }, session: set_session_user assert_redirected_to :controller => :hosts, :action => :index - assert flash[:error] == "Cannot update Location to Location 1 because of mismatch in settings" + assert_equal "Cannot update location to Location 1 because of mismatch in settings", flash[:error] end test "update multiple location does not update location of hosts if fails on pessimistic import" do @request.env['HTTP_REFERER'] = current_hosts_path @@ -870,7 +870,7 @@ def test_unset_manage :host_ids => Host.pluck('hosts.id'), }, session: set_session_user assert_redirected_to :controller => :hosts, :action => :index - assert_equal "Cannot update Organization to Organization 1 because of mismatch in settings", flash[:error] + assert_equal "Cannot update organization to Organization 1 because of mismatch in settings", flash[:error] end test "update multiple organization does not update organization of hosts if fails on pessimistic import" do @request.env['HTTP_REFERER'] = current_hosts_path diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js new file mode 100644 index 0000000000..a449afc817 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js @@ -0,0 +1,257 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { + Modal, + Button, + MenuToggle, + SelectOption, + TextContent, + Text, +} from '@patternfly/react-core'; +import { addToast } from '../../../ToastsList/slice'; +import { translate as __ } from '../../../../common/I18n'; +import { STATUS } from '../../../../constants'; +import { + selectAPIStatus, + selectAPIResponse, +} from '../../../../redux/API/APISelectors'; +import { + BULK_ASSIGN_TAXONOMY_KEY, + bulkAssignTaxonomy, + fetchOrganizations, + fetchLocations, + ORGANIZATION_KEY, + LOCATION_KEY, +} from './actions'; +import { foremanUrl } from '../../../../common/helpers'; +import { APIActions } from '../../../../redux/API'; +import { + HOSTS_API_PATH, + API_REQUEST_KEY, +} from '../../../../routes/Hosts/constants'; +import TaxonomySelect from './TaxonomySelect'; + +const BulkAssignTaxonomyModal = ({ + isOpen, + closeModal, + selectedCount, + fetchBulkParams, +}) => { + const dispatch = useDispatch(); + const [organizationId, setOrganizationId] = useState(''); + const [locationId, setLocationId] = useState(''); + const [orgSelectOpen, setOrgSelectOpen] = useState(false); + const [locSelectOpen, setLocSelectOpen] = useState(false); + const [orgFixRadioChecked, setOrgFixRadioChecked] = useState(true); + const [locFixRadioChecked, setLocFixRadioChecked] = useState(true); + const organizations = useSelector(state => + selectAPIResponse(state, ORGANIZATION_KEY) + ); + const organizationStatus = useSelector(state => + selectAPIStatus(state, ORGANIZATION_KEY) + ); + const locations = useSelector(state => + selectAPIResponse(state, LOCATION_KEY) + ); + const locationStatus = useSelector(state => + selectAPIStatus(state, LOCATION_KEY) + ); + const hostUpdateStatus = useSelector(state => + selectAPIStatus(state, BULK_ASSIGN_TAXONOMY_KEY) + ); + const handleModalClose = () => { + setOrganizationId(''); + setLocationId(''); + setOrgFixRadioChecked(true); + setLocFixRadioChecked(true); + closeModal(); + }; + + useEffect(() => { + dispatch(fetchOrganizations()); + dispatch(fetchLocations()); + }, [dispatch]); + + const onOrgToggleClick = () => { + setOrgSelectOpen(!orgSelectOpen); + }; + const toggleOrg = toggleRef => ( + + {getSelectedLabel(organizationId, organizations)} + + ); + + const handleOrgSelect = (event, selection) => { + setOrganizationId(selection); + setOrgSelectOpen(false); + }; + + const onLocToggleClick = () => { + setLocSelectOpen(!locSelectOpen); + }; + const toggleLoc = toggleRef => ( + + {getSelectedLabel(locationId, locations)} + + ); + + const handleLocSelect = (event, selection) => { + setLocationId(selection); + setLocSelectOpen(false); + }; + + const getSelectedLabel = (id, taxonomy) => + taxonomy.results.find(t => t.id === id)?.name; + + const handleError = error => { + const { + response: { + data: { + error: { message }, + }, + }, + } = error; + dispatch(addToast({ type: 'danger', message })); + handleModalClose(); + }; + + const handleSuccess = response => { + dispatch( + addToast({ + type: 'success', + message: response.data.message, + }) + ); + dispatch( + APIActions.get({ + key: API_REQUEST_KEY, + url: foremanUrl(HOSTS_API_PATH), + }) + ); + handleModalClose(); + }; + + const handleSave = () => { + const requestBody = { + included: { + search: fetchBulkParams(), + }, + target_location_id: locationId, + target_organization_id: organizationId, + mismatch_setting_location: locFixRadioChecked, + mismatch_setting_organization: orgFixRadioChecked, + }; + + dispatch(bulkAssignTaxonomy(requestBody, handleSuccess, handleError)); + }; + + const modalActions = [ + , + , + ]; + return ( + + + + {__( + 'Select organization/location to add hosts to. This change may affect all your selected hosts.' + )} + + + {organizations && organizationStatus === STATUS.RESOLVED && ( + setOrgSelectOpen(isSelectOpen)} + toggle={toggleOrg} + radioChecked={orgFixRadioChecked} + setRadioChecked={setOrgFixRadioChecked} + > + {organizations.results?.map(org => ( + + {org.name} + + ))} + + )} +
+ {locations && locationStatus === STATUS.RESOLVED && ( + setLocSelectOpen(isSelectOpen)} + toggle={toggleLoc} + radioChecked={locFixRadioChecked} + setRadioChecked={setLocFixRadioChecked} + > + {locations.results?.map(loc => ( + + {loc.name} + + ))} + + )} +
+ ); +}; + +BulkAssignTaxonomyModal.propTypes = { + isOpen: PropTypes.bool, + closeModal: PropTypes.func, + selectedCount: PropTypes.number.isRequired, + fetchBulkParams: PropTypes.func.isRequired, +}; + +BulkAssignTaxonomyModal.defaultProps = { + isOpen: false, + closeModal: () => {}, +}; + +export default BulkAssignTaxonomyModal; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js new file mode 100644 index 0000000000..945c66df28 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/TaxonomySelect.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Bullseye, Radio, Select, Tooltip } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { translate as __ } from '../../../../common/I18n'; + +const TaxonomySelect = ({ + headerText, + taxonomy, + children, + radioChecked, + setRadioChecked, + ...selectProps +}) => { + const taxonomyType = + taxonomy === 'organization' ? __('organization') : __('location'); + const mismatchTip = ( + + ); + + return ( +
+

+ {headerText} + + + +

+ + + setRadioChecked(checked)} + name={`radioFixOnMismatch${taxonomy}`} + label={__('Fix on mismatch')} + id={`radio-fix-on-mismatch-${taxonomy}`} + ouiaId={`radio-fix-on-mismatch-${taxonomy}`} + /> + setRadioChecked(!checked)} + name={`radioFailOnMismatch${taxonomy}`} + label={__('Fail on mismatch')} + id={`radio-fail-on-mismatch-${taxonomy}`} + ouiaId={`radio-fail-on-mismatch-${taxonomy}`} + style={{ marginLeft: '50px' }} + /> + +
+ ); +}; + +TaxonomySelect.propTypes = { + headerText: PropTypes.string, + taxonomy: PropTypes.string, + children: PropTypes.node, + radioChecked: PropTypes.bool, + setRadioChecked: PropTypes.func, +}; + +TaxonomySelect.defaultProps = { + headerText: '', + taxonomy: '', + children: [], + radioChecked: false, + setRadioChecked: undefined, +}; + +export default TaxonomySelect; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js new file mode 100644 index 0000000000..66676d8ba6 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/actions.js @@ -0,0 +1,41 @@ +import { APIActions } from '../../../../redux/API'; +import { foremanUrl } from '../../../../common/helpers'; + +export const BULK_ASSIGN_TAXONOMY_KEY = 'BULK_ASSIGN_TAXONOMY'; +export const bulkAssignTaxonomy = (params, handleSuccess, handleError) => { + const url = foremanUrl(`/api/v2/hosts/bulk/assign_taxonomy`); + return APIActions.put({ + key: BULK_ASSIGN_TAXONOMY_KEY, + url, + handleSuccess, + handleError, + params, + }); +}; + +export const ORGANIZATION_KEY = 'ORGANIZATION'; +export const LOCATION_KEY = 'LOCATION'; + +export const fetchOrganizations = () => { + const url = foremanUrl('/api/v2/organizations'); + return APIActions.get({ + key: ORGANIZATION_KEY, + url, + params: { + per_page: 'all', + }, + }); +}; + +export const fetchLocations = () => { + const url = foremanUrl('/api/v2/locations'); + return APIActions.get({ + key: LOCATION_KEY, + url, + params: { + per_page: 'all', + }, + }); +}; + +export default bulkAssignTaxonomy; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js new file mode 100644 index 0000000000..808de7092f --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js @@ -0,0 +1,24 @@ +import React, { useContext } from 'react'; +import { ForemanActionsBarContext } from '../../../../components/HostDetails/ActionsBar'; +import { useForemanModal } from '../../../../components/ForemanModal/ForemanModalHooks'; +import BulkAssignTaxonomyModal from './BulkAssignTaxonomyModal'; + +const BulkAssignTaxonomyModalScene = () => { + const { selectedCount, fetchBulkParams } = useContext( + ForemanActionsBarContext + ); + const { modalOpen, setModalClosed } = useForemanModal({ + id: 'bulk-assign-taxonomy-modal', + }); + return ( + + ); +}; + +export default BulkAssignTaxonomyModalScene; diff --git a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js index 2410450885..0041afe3ca 100644 --- a/webpack/assets/javascripts/react_app/components/HostsIndex/index.js +++ b/webpack/assets/javascripts/react_app/components/HostsIndex/index.js @@ -5,10 +5,13 @@ import { Tr, Td, ActionsColumn } from '@patternfly/react-table'; import { ToolbarItem, Divider, - MenuItem, Flex, FlexItem, Button, + Menu, + MenuItem, + MenuContent, + MenuList, Split, SplitItem, TextContent, @@ -46,6 +49,7 @@ import { import { bulkDeleteHosts } from './BulkActions/bulkDelete'; import BulkBuildHostModal from './BulkActions/buildHosts'; import BulkReassignHostgroupModal from './BulkActions/reassignHostGroup'; +import BulkAssignTaxonomyModal from './BulkActions/assignTaxonomy'; import { foremanUrl } from '../../common/helpers'; import Slot from '../common/Slot'; import forceSingleton from '../../common/forceSingleton'; @@ -214,6 +218,11 @@ const HostsIndex = () => { id: 'bulk-reassign-hg-modal', }) ); + dispatch( + addModal({ + id: 'bulk-assign-taxonomy-modal', + }) + ); }, [dispatch]); const { setModalOpen: setHgModalOpen } = useForemanModal({ @@ -222,6 +231,9 @@ const HostsIndex = () => { const { setModalOpen: setBuildModalOpen } = useForemanModal({ id: 'bulk-build-hosts-modal', }); + const { setModalOpen: setTaxonomyModalOpen } = useForemanModal({ + id: 'bulk-assign-taxonomy-modal', + }); const dropdownItems = [ { {__('Build management')} , setMenuOpen(false)} + > + + + + {__('Host group')} + + + {__('Organization/location')} + + + + + } > - {__('Change host group')} + {__('Change associations')} , ]; @@ -442,6 +480,7 @@ const HostsIndex = () => { > +