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 => (
+
+ {locations && locationStatus === STATUS.RESOLVED && (
+