diff --git a/app/controllers/concerns/foreman_openscap/api/v2/hosts_bulk_actions_controller_extensions.rb b/app/controllers/concerns/foreman_openscap/api/v2/hosts_bulk_actions_controller_extensions.rb
new file mode 100644
index 000000000..d543d4bc9
--- /dev/null
+++ b/app/controllers/concerns/foreman_openscap/api/v2/hosts_bulk_actions_controller_extensions.rb
@@ -0,0 +1,78 @@
+module ForemanOpenscap
+ module Api
+ module V2
+ module HostsBulkActionsControllerExtensions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :find_editable_hosts, only: [:change_openscap_proxy]
+ end
+
+ def change_openscap_proxy
+ openscap_proxy_id = params[:openscap_proxy_id]
+
+ if openscap_proxy_id.blank?
+ return render json: {
+ error: {
+ message: _("No OpenSCAP Proxy selected."),
+ },
+ }, status: :unprocessable_entity
+ end
+
+ smart_proxy = begin
+ ::SmartProxy.find(openscap_proxy_id)
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ if smart_proxy.nil?
+ return render json: {
+ error: {
+ message: _("OpenSCAP proxy with id %s not found") % openscap_proxy_id,
+ },
+ }, status: :unprocessable_entity
+ end
+
+ unless smart_proxy.has_feature?('Openscap')
+ return render json: {
+ error: {
+ message: _("The selected proxy does not have the OpenSCAP feature enabled."),
+ },
+ }, status: :unprocessable_entity
+ end
+
+ failed_hosts = []
+ @hosts.each do |host|
+ host.openscap_proxy = smart_proxy
+ failed_hosts << host unless host.save
+ end
+
+ if failed_hosts.empty?
+ process_response(true, {
+ message: _("OpenSCAP Proxy set to %s") % smart_proxy.name,
+ })
+ else
+ render_error(:bulk_hosts_error, status: :unprocessable_entity,
+ locals: {
+ message: n_("Failed to assign OpenSCAP Proxy to %s host",
+ "Failed to assign OpenSCAP Proxy to %s hosts",
+ failed_hosts.count) % failed_hosts.count,
+ failed_host_ids: failed_hosts.map(&:id),
+ })
+ end
+ end
+
+ private
+
+ def action_permission
+ case params[:action]
+ when 'change_openscap_proxy'
+ 'edit'
+ else
+ super
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index e9f5ca4f5..dee9d38fa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -103,6 +103,8 @@
end
end
end
+
+ match 'hosts/bulk/change_openscap_proxy', :to => 'hosts_bulk_actions#change_openscap_proxy', :via => [:put]
end
end
end
diff --git a/lib/foreman_openscap/engine.rb b/lib/foreman_openscap/engine.rb
index 7ade8dd68..dc070c240 100644
--- a/lib/foreman_openscap/engine.rb
+++ b/lib/foreman_openscap/engine.rb
@@ -93,7 +93,8 @@ class Engine < ::Rails::Engine
:resource_type => 'ForemanOpenscap::ScapContent'
permission :edit_hosts, { :hosts => %i[openscap_proxy_changed
select_multiple_openscap_proxy
- update_multiple_openscap_proxy] },
+ update_multiple_openscap_proxy],
+ 'api/v2/hosts_bulk_actions' => [:change_openscap_proxy] },
:resource_type => "Host"
permission :view_hosts, { 'api/v2/hosts' => [:policies_enc] }, :resource_type => 'Host'
permission :edit_hostgroups, { :hostgroups => [:openscap_proxy_changed] }, :resource_type => "Hostgroup"
@@ -195,6 +196,7 @@ class Engine < ::Rails::Engine
# Include concerns in this config.to_prepare block
config.to_prepare do
::Api::V2::HostsController.send(:include, ForemanOpenscap::Api::V2::HostsControllerExtensions)
+ ::Api::V2::HostsBulkActionsController.send(:include, ForemanOpenscap::Api::V2::HostsBulkActionsControllerExtensions)
::Host::Managed.send(:include, ForemanOpenscap::OpenscapProxyExtensions)
::Host::Managed.send(:include, ForemanOpenscap::OpenscapProxyCoreExtensions)
::Host::Managed.send(:prepend, ForemanOpenscap::HostExtensions)
diff --git a/test/functional/api/v2/hosts_bulk_actions_controller_test.rb b/test/functional/api/v2/hosts_bulk_actions_controller_test.rb
new file mode 100644
index 000000000..280fd9804
--- /dev/null
+++ b/test/functional/api/v2/hosts_bulk_actions_controller_test.rb
@@ -0,0 +1,98 @@
+require 'test_plugin_helper'
+
+class Api::V2::HostsBulkActionsControllerTest < ActionController::TestCase
+ def setup
+ as_admin do
+ @organization = FactoryBot.create(:organization)
+ @location = FactoryBot.create(:location)
+ @proxy = FactoryBot.create(:openscap_proxy,
+ :organizations => [@organization],
+ :locations => [@location])
+ @host1 = FactoryBot.create(:host, :managed,
+ :organization => @organization,
+ :location => @location)
+ @host2 = FactoryBot.create(:host, :managed,
+ :organization => @organization,
+ :location => @location)
+ @host_ids = [@host1.id, @host2.id]
+ end
+ end
+
+ def valid_bulk_params(host_ids = @host_ids)
+ {
+ :organization_id => @organization.id,
+ :location_id => @location.id,
+ :included => {
+ :ids => host_ids,
+ },
+ :excluded => {
+ :ids => [],
+ },
+ }
+ end
+
+ test "should assign openscap proxy to selected hosts" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => @proxy.id),
+ session: set_session_user
+
+ assert_response :success
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/OpenSCAP Proxy set to/, response['message'])
+ assert_includes response['message'], @proxy.name
+
+ [@host1, @host2].each do |host|
+ host.reload
+ assert_equal @proxy.id, host.openscap_proxy_id
+ end
+ end
+
+ test "should require openscap_proxy_id" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params,
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/No OpenSCAP Proxy selected/, response['error']['message'])
+ end
+
+ test "should return error when proxy is not found" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => 0),
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/not found/, response['error']['message'])
+ end
+
+ test "should return error when proxy lacks Openscap feature" do
+ other_proxy = FactoryBot.create(:smart_proxy,
+ :organizations => [@organization],
+ :locations => [@location])
+ openscap_feature = Feature.find_by(:name => 'Openscap')
+ other_proxy.features.delete(openscap_feature) if openscap_feature
+ refute other_proxy.reload.has_feature?('Openscap')
+
+ put :change_openscap_proxy,
+ params: valid_bulk_params.merge(:openscap_proxy_id => other_proxy.id),
+ session: set_session_user
+
+ assert_response :unprocessable_entity
+ response = ActiveSupport::JSON.decode(@response.body)
+ assert_match(/OpenSCAP feature/, response['error']['message'])
+ end
+
+ test "should assign openscap proxy for a single host" do
+ put :change_openscap_proxy,
+ params: valid_bulk_params([@host1.id]).merge(:openscap_proxy_id => @proxy.id),
+ session: set_session_user
+
+ assert_response :success
+ @host1.reload
+ assert_equal @proxy.id, @host1.openscap_proxy_id
+ @host2.reload
+ assert_nil @host2.openscap_proxy_id
+ end
+end
diff --git a/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js b/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js
new file mode 100644
index 000000000..1d02856c4
--- /dev/null
+++ b/webpack/components/HostsIndex/BulkActions/changeOpenscapProxy/BulkChangeOpenscapProxyModal.js
@@ -0,0 +1,229 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ Modal,
+ Button,
+ Grid,
+ GridItem,
+ Form,
+ FormGroup,
+ Stack,
+ StackItem,
+} from '@patternfly/react-core';
+// eslint-disable-next-line import/no-unresolved
+import { SimpleDropdown } from '@patternfly/react-templates';
+import { foremanUrl } from 'foremanReact/common/helpers';
+import { APIActions } from 'foremanReact/redux/API';
+import { sprintf, translate as __ } from 'foremanReact/common/I18n';
+import { STATUS } from 'foremanReact/constants';
+import {
+ selectAPIStatus,
+ selectAPIResponse,
+} from 'foremanReact/redux/API/APISelectors';
+import { buildBulkRequestBody } from 'foremanReact/components/HostsIndex/BulkActions/helpers';
+import {
+ BULK_CHANGE_OPENSCAP_PROXY_KEY,
+ HOSTS_API_PATH,
+ HOSTS_API_REQUEST_KEY,
+ OPENSCAP_PROXIES_KEY,
+} from '../../../OpenscapRemediationWizard/constants';
+
+const fetchOpenscapProxies = () =>
+ APIActions.get({
+ key: OPENSCAP_PROXIES_KEY,
+ url: foremanUrl(
+ '/api/smart_proxies?search=feature%3DOpenscap&per_page=all'
+ ),
+ });
+
+const BulkChangeOpenscapProxyModal = ({
+ isOpen,
+ closeModal,
+ selectAllHostsMode,
+ selectedCount,
+ fetchBulkParams,
+ organizationId,
+ locationId,
+}) => {
+ const dispatch = useDispatch();
+ const [proxyId, setProxyId] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ dispatch(fetchOpenscapProxies());
+ } else {
+ setIsSubmitting(false);
+ }
+ }, [dispatch, isOpen]);
+
+ const proxies = useSelector(state =>
+ selectAPIResponse(state, OPENSCAP_PROXIES_KEY)
+ );
+ const proxyStatus = useSelector(state =>
+ selectAPIStatus(state, OPENSCAP_PROXIES_KEY)
+ );
+
+ const handleModalClose = () => {
+ setProxyId('');
+ setIsSubmitting(false);
+ closeModal();
+ };
+
+ const handleSuccess = () => {
+ dispatch(
+ APIActions.get({
+ key: HOSTS_API_REQUEST_KEY,
+ url: foremanUrl(HOSTS_API_PATH),
+ })
+ );
+ handleModalClose();
+ };
+
+ const handleError = () => {
+ setIsSubmitting(false);
+ handleModalClose();
+ };
+
+ const handleConfirm = () => {
+ const requestBody = buildBulkRequestBody({
+ fetchBulkParams,
+ organizationId,
+ locationId,
+ openscap_proxy_id: proxyId,
+ });
+
+ setIsSubmitting(true);
+ dispatch(
+ APIActions.put({
+ key: BULK_CHANGE_OPENSCAP_PROXY_KEY,
+ url: foremanUrl('/api/v2/hosts/bulk/change_openscap_proxy'),
+ handleSuccess,
+ successToast: response => response.data.message,
+ handleError,
+ errorToast: error =>
+ error?.response?.data?.error?.message || __('Error'),
+ params: requestBody,
+ })
+ );
+ };
+
+ const descriptionText = selectAllHostsMode ? (
+ <>
+ {__('Assign OpenSCAP proxy for ')}
+ {__('ALL selected hosts.')}
+
+ {__('This will change previous proxy assignments on the selected hosts.')}
+ >
+ ) : (
+ <>
+ {__('Assign OpenSCAP proxy for ')}
+ {sprintf(__('%s selected hosts.'), selectedCount)}
+
+ {__('This will change previous proxy assignments on the selected hosts.')}
+ >
+ );
+
+ const getProxyLabel = id => {
+ const proxy = proxies?.results?.find(
+ p => p.id.toString() === id.toString()
+ );
+ return proxy?.name || id;
+ };
+
+ const proxyItems =
+ proxies?.results?.map(proxy => ({
+ value: proxy.id.toString(),
+ content: proxy.name,
+ onClick: () => setProxyId(proxy.id.toString()),
+ })) || [];
+
+ const modalActions = [
+ ,
+ ,
+ ];
+
+ return (
+