diff --git a/app/controllers/connections_controller.rb b/app/controllers/connections_controller.rb index 39611517c..92215e23b 100644 --- a/app/controllers/connections_controller.rb +++ b/app/controllers/connections_controller.rb @@ -28,7 +28,7 @@ def edit # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity @coupled_frames = @frame.bay.frames @possible_destination_servers = [] @all_servers_per_frame = [] - if @from_port.is_power_input? && @from_server.is_not_a_pdu? + if @from_port.is_power? && @from_server.is_not_a_pdu? # @coupled_frames.each { |frame| @possible_destination_servers << [frame.name, frame.pdus.collect { |v| [v.name, v.id] }] } # Frame.order(:name).each { |frame| @all_servers_per_frame << [frame.name, frame.pdus.collect { |v| [v.name, v.id] }] } else @@ -46,7 +46,7 @@ def edit # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity # Destination server @to_server = if @to_port.present? @to_port.server - elsif @from_port.is_power_input? + elsif @from_port.is_power? nil # @frame.pdus.first else @frame.servers.where("position IS NOT NULL AND modele_id IS NOT NULL").order(:position).first diff --git a/app/controllers/port_types_controller.rb b/app/controllers/port_types_controller.rb index 2aaa93866..25fab048e 100644 --- a/app/controllers/port_types_controller.rb +++ b/app/controllers/port_types_controller.rb @@ -63,6 +63,6 @@ def set_port_type # Never trust parameters from the scary internet, only allow the white list through. def port_type_params - params.expect(port_type: %i[name power]) + params.expect(port_type: [:name, :is_power, { usable_by: [] }]) end end diff --git a/app/decorators/card_type_decorator.rb b/app/decorators/card_type_decorator.rb index e607d99af..5a2fe3f49 100644 --- a/app/decorators/card_type_decorator.rb +++ b/app/decorators/card_type_decorator.rb @@ -4,6 +4,10 @@ class CardTypeDecorator < ApplicationDecorator class << self include ActionView::Helpers::FormOptionsHelper + def port_types_options_for_select + PortTypeDecorator.options_for_select(PortType.usable_by_server) + end + def grouped_by_port_type_options_for_select(selected = nil) grouped_card_types = CardType.includes(:port_type) .sorted diff --git a/app/decorators/port_type_decorator.rb b/app/decorators/port_type_decorator.rb index 76a69e223..a12091c15 100644 --- a/app/decorators/port_type_decorator.rb +++ b/app/decorators/port_type_decorator.rb @@ -5,6 +5,14 @@ def self.options_for_select(collection = PortType) collection.select(:id, :name).sorted.map { |p| [p.to_s, p.id] } end + def self.usable_by_options_for_select + PortType::USABLE_BY_VALUES.map { |value| [PortType.human_attribute_name("usable_by.#{value}"), value] } + end + + def human_usable_by + usable_by.map { |value| PortType.human_attribute_name("usable_by.#{value}") }.to_sentence + end + def css_class_name case name when "RJ", "XRJ" diff --git a/app/decorators/power_distribution_unit_socket_decorator.rb b/app/decorators/power_distribution_unit_socket_decorator.rb index 41d1c9a29..ad1450916 100644 --- a/app/decorators/power_distribution_unit_socket_decorator.rb +++ b/app/decorators/power_distribution_unit_socket_decorator.rb @@ -2,8 +2,8 @@ class PowerDistributionUnitSocketDecorator < ApplicationDecorator class << self - def port_type_options_for_select - PortTypeDecorator.options_for_select(PortType.where(power: true)) + def port_types_options_for_select + PortTypeDecorator.options_for_select(PortType.power_ones.usable_by_pdu) end end end diff --git a/app/models/cable.rb b/app/models/cable.rb index 06afe48ef..f7e79a9cd 100644 --- a/app/models/cable.rb +++ b/app/models/cable.rb @@ -29,7 +29,7 @@ class Cable < ApplicationRecord after_update :touch_ports scope :sorted, lambda { - alim_cables = joins(:port_types).where(port_types: PortType.where(name: "ALIM")).uniq + alim_cables = joins(:port_types).merge(PortType.power_ones).distinct other_cables = order(:cards, ports: { position: :asc }).select { |c| alim_cables.exclude?(c) } other_cables + alim_cables diff --git a/app/models/card.rb b/app/models/card.rb index c4cfd879c..2bad5a143 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -7,7 +7,7 @@ class Card < ApplicationRecord belongs_to :card_type delegate :port_quantity, to: :card_type, allow_nil: true - delegate :is_power_input?, to: :card_type, allow_nil: true + delegate :is_power?, to: :card_type, allow_nil: true belongs_to :twin_card, class_name: "Card", optional: true belongs_to :server, touch: true diff --git a/app/models/card_type.rb b/app/models/card_type.rb index c5ffc77ff..1b0c62596 100644 --- a/app/models/card_type.rb +++ b/app/models/card_type.rb @@ -4,7 +4,7 @@ class CardType < ApplicationRecord has_changelog belongs_to :port_type, counter_cache: true - delegate :is_power_input?, to: :port_type, allow_nil: true + delegate :is_power?, to: :port_type, allow_nil: true has_many :cards, dependent: :restrict_with_error has_many :servers, through: :cards diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/models/concerns/enum/array.rb b/app/models/concerns/enum/array.rb new file mode 100644 index 000000000..25bb55652 --- /dev/null +++ b/app/models/concerns/enum/array.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Enum + module Array + extend ActiveSupport::Concern + + MISSING_VALUE_MESSAGE = "%s is not a valid value for %s" # rubocop:disable Style/FormatStringToken + private_constant :MISSING_VALUE_MESSAGE + + class_methods do + def array_enum(name = nil, mapping = nil, validate: false) + name = name.to_s + column = connection.quote_column_name(name) + mapping_hash = ActiveSupport::HashWithIndifferentAccess.new(mapping) + + defined_enums[name] = mapping_hash + + define_singleton_method(name.pluralize) do + mapping_hash + end + + mapping.each_key do |key| + scope :"#{name}_#{key}", -> { where("? = ANY(#{column})", key) } + end + + define_method(name) do + Array(self[name]).map { |value| mapping_hash.key(value) || value } + end + + define_method(:"#{name}=") do |values| + self[name] = Array(values).compact_blank.map do |value| + raise_missing_value(name, value) unless validate || mapping_hash.key?(value) + + mapping_hash[value] || value + end.uniq + end + + validates name, array_inclusion: { in: mapping_hash.keys } if validate + end + + private + + def raise_missing_value(name, value) + raise ArgumentError, format(MISSING_VALUE_MESSAGE, name:, value:) + end + end + end +end diff --git a/app/models/port.rb b/app/models/port.rb index 2a96c26d1..fc5f20882 100644 --- a/app/models/port.rb +++ b/app/models/port.rb @@ -16,7 +16,7 @@ class Port < ApplicationRecord # rubocop:disable Metrics/ClassLength delegate :server, to: :card, allow_nil: true delegate :circuit, to: :power_distribution_unit_socket, allow_nil: true - delegate :is_power_input?, to: :card, allow_nil: true + delegate :is_power?, to: :card, allow_nil: true delegate :paired_connection, to: :connection, allow_nil: true delegate :color, to: :cable, prefix: true, allow_nil: true delegate :name, to: :cable, prefix: true, allow_nil: true diff --git a/app/models/port_type.rb b/app/models/port_type.rb index 17a1c2fda..d1a5d8e02 100644 --- a/app/models/port_type.rb +++ b/app/models/port_type.rb @@ -1,16 +1,18 @@ # frozen_string_literal: true class PortType < ApplicationRecord + include Enum::Array + has_changelog has_many :card_types, dependent: :restrict_with_error has_many :sockets, class_name: "PowerDistributionUnit::Socket", dependent: :restrict_with_error + USABLE_BY_VALUES = %w[pdu server].freeze + array_enum :usable_by, USABLE_BY_VALUES.index_with(&:to_s), validate: true + scope :sorted, -> { order(name: :asc) } + scope :power_ones, -> { where(is_power: true) } delegate :to_s, to: :name - - def is_power_input? - name == "ALIM" - end end diff --git a/app/services/import_equipment_by_csv.rb b/app/services/import_equipment_by_csv.rb index 96b263f99..94fd20230 100644 --- a/app/services/import_equipment_by_csv.rb +++ b/app/services/import_equipment_by_csv.rb @@ -126,7 +126,7 @@ def init_slots(data, server) # SLOTS ALIM valeur = "ALIM" nb_ports = data["Alim"].to_s.gsub(valeur, "").to_i - port_type = PortType.find_or_create_by!(name: valeur) + port_type = PortType.find_or_create_by!(name: valeur, is_power: true) card_alim = CardType.find_or_create_by!(name: "#{nb_ports}#{valeur}", port_quantity: nb_ports, port_type: port_type) Card.find_or_create_by!(card_type: card_alim, server: server, composant: composant_slot_alim) end diff --git a/app/validators/array_inclusion_validator.rb b/app/validators/array_inclusion_validator.rb new file mode 100644 index 000000000..eeeb85c22 --- /dev/null +++ b/app/validators/array_inclusion_validator.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ArrayInclusionValidator < ActiveModel::EachValidator + def validate_each(record, attribute, values) + not_allowed_values = values - options[:in] + + return if not_allowed_values.empty? + + wrong_values = not_allowed_values.join(", ") + record.errors.add(attribute, :contains_unpermitted_values, wrong_values:) + end +end diff --git a/app/views/card_types/_form.html.erb b/app/views/card_types/_form.html.erb index 28113316e..103ef8562 100644 --- a/app/views/card_types/_form.html.erb +++ b/app/views/card_types/_form.html.erb @@ -24,7 +24,7 @@
<%= f.label :port_type, class: "form-label" %> <%= f.select :port_type_id, - PortType.sorted.map { |pt| [pt.name, pt.id] }, + CardTypeDecorator.port_types_options_for_select, { prompt: true }, { class: "form-select", diff --git a/app/views/card_types/index.html.erb b/app/views/card_types/index.html.erb index 8b9b0be9c..060d9d88a 100644 --- a/app/views/card_types/index.html.erb +++ b/app/views/card_types/index.html.erb @@ -24,14 +24,16 @@
- <%= f.collection_select(:port_type_ids, PortType.sorted, :id, :name, - { prompt: true, multiple: true }, - { class: "form-select", - data: { - controller: :select, - select_clear_button_title_value: t("action.clear_all") - } - }) %> + <%= f.select :port_type_ids, + CardTypeDecorator.port_types_options_for_select, + { prompt: true, multiple: true }, + { + class: "form-select", + data: { + controller: :select, + select_clear_button_title_value: t("action.clear_all"), + }, + } %> <%= f.label :port_type_ids %>
diff --git a/app/views/port_types/_form.html.erb b/app/views/port_types/_form.html.erb index 71d39f3bf..acd954486 100644 --- a/app/views/port_types/_form.html.erb +++ b/app/views/port_types/_form.html.erb @@ -23,10 +23,20 @@
- <%= f.check_box :power, class: "form-check-input" %> - <%= f.label :power, class: "form-check-label" %> + <%= f.check_box :is_power, class: "form-check-input" %> + <%= f.label :is_power, class: "form-check-label" %>
+ +
+ <%= f.label :usable_by, class: "form-check-label" %> + <%= f.select :usable_by, PortTypeDecorator.usable_by_options_for_select, + { prompt: true, multiple: true }, + { + class: "form-select", + data: { controller: :select, select_clear_button_title_value: t("action.clear_all"), } + } %> +
<% end %> diff --git a/app/views/port_types/index.html.erb b/app/views/port_types/index.html.erb index fc6973658..4ffcd8fae 100644 --- a/app/views/port_types/index.html.erb +++ b/app/views/port_types/index.html.erb @@ -30,7 +30,7 @@ <% end %> <% table.with_column(PortType.human_attribute_name(:power), sort_by: :power) do |port_type| %> - <%= t("boolean.#{port_type.power?}") %> + <%= t("boolean.#{port_type.is_power?}") %> <% end %> <% table.with_column(PortType.human_attribute_name(:usage), sort_by: :card_types_count) do |port_type| %> @@ -40,6 +40,10 @@ <% end %> <% end %> + <% table.with_column(PortType.human_attribute_name(:usable_by)) do |port_type| %> + <%= port_type.decorated.human_usable_by %> + <% end %> + <% table.with_column(style: "min-width: 70px; width: 70px;") do |port_type| %>
<% if allowed_to?(:update?, port_type) %> diff --git a/app/views/port_types/show.html.erb b/app/views/port_types/show.html.erb index 423eeda18..658f78282 100644 --- a/app/views/port_types/show.html.erb +++ b/app/views/port_types/show.html.erb @@ -17,7 +17,7 @@
<% %i[name].each do |attribute_name| %> -
<%= Architecture.human_attribute_name(attribute_name) %>
+
<%= PortType.human_attribute_name(attribute_name) %>
<%= @port_type.public_send(attribute_name) %>
<% end %>
@@ -31,10 +31,15 @@ <% end %>
- <% icon = @port_type.power ? "check" : "x" %> -
<%= Architecture.human_attribute_name(:power) %>
+ <% icon = @port_type.is_power? ? "check" : "x" %> +
<%= PortType.human_attribute_name(:power) %>
+ +
+
<%= PortType.human_attribute_name(:usable_by) %>
+
<%= @port_type.decorated.human_usable_by %>
+
<% end %>
diff --git a/app/views/power_distribution_units/_socket_fields.html.erb b/app/views/power_distribution_units/_socket_fields.html.erb index 4c4e98a16..eb98c2fa1 100644 --- a/app/views/power_distribution_units/_socket_fields.html.erb +++ b/app/views/power_distribution_units/_socket_fields.html.erb @@ -4,7 +4,7 @@
<%= f.select :port_type_id, - PowerDistributionUnitSocketDecorator.port_type_options_for_select, + PowerDistributionUnitSocketDecorator.port_types_options_for_select, { prompt: true }, { class: "form-select form-select-sm", diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index 5361c1fe2..176ed5168 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -17,6 +17,14 @@ en: one: Network hub cluster other: Network hub clusters color: Color + port_type: + name: Name + power: Power + usage: Usage + usable_by: Usable by + port_type/usable_by: + server: Server + pdu: Pdu composant: one: Component other: Components @@ -307,6 +315,9 @@ en: domain_ids: blank: can't be blank, or "All domains" must be checked + messages: + contains_unpermitted_values: "includes unauthorized values: %{wrong_values}" + # Common attributes attributes: bay_id: Bay diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml index 6ef08c983..45fe53456 100644 --- a/config/locales/activerecord.fr.yml +++ b/config/locales/activerecord.fr.yml @@ -253,6 +253,10 @@ fr: name: Nom power: Alimentation usage: Types de carte compatibles + usable_by: Utilisation + port_type/usable_by: + server: Serveur + pdu: Pdu category: name: Nom description: Description @@ -544,6 +548,9 @@ fr: domain_ids: blank: doit être rempli, ou "Tous les domaines" doit être coché + messages: + contains_unpermitted_values: "contient des valeurs non autorisées : %{wrong_values}" + # Common attributes attributes: bay_id: Baie diff --git a/db/migrate/20260728132556_rename_power_to_is_power_for_port_type.rb b/db/migrate/20260728132556_rename_power_to_is_power_for_port_type.rb new file mode 100644 index 000000000..46100a758 --- /dev/null +++ b/db/migrate/20260728132556_rename_power_to_is_power_for_port_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class PortTypeMigration < ApplicationRecord + self.table_name = :port_types +end + +class RenamePowerToIsPowerForPortType < ActiveRecord::Migration[8.1] + def change + rename_column :port_types, :power, :is_power + + create_enum :port_types_usable_by, %w[pdu server] + add_column :port_types, :usable_by, :enum, enum_type: :port_types_usable_by, array: true + + up_only do + PortTypeMigration.reset_column_information + PortTypeMigration.find_each do |port_type| + port_type.update!(is_power: port_type.name == "ALIM", usable_by: ["server"]) + end + end + + change_table :port_types, bulk: true do |t| + t.change_default :is_power, from: nil, to: false + t.change_null :is_power, false + + t.change_default :usable_by, from: nil, to: [] + t.change_null :usable_by, false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 98d14ce4a..f8c200240 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_20_131449) do +ActiveRecord::Schema[8.1].define(version: 2026_07_28_132556) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + # Custom types defined in this database. + # Note that some types may not work with other database engines. Be careful if changing database. + create_enum "port_types_usable_by", ["pdu", "server"] + create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", precision: nil, null: false @@ -449,8 +453,9 @@ create_table "port_types", id: :serial, force: :cascade do |t| t.integer "card_types_count", default: 0, null: false + t.boolean "is_power", default: false, null: false t.string "name" - t.boolean "power" + t.enum "usable_by", default: [], null: false, array: true, enum_type: "port_types_usable_by" end create_table "ports", id: :serial, force: :cascade do |t| diff --git a/spec/decorators/card_type_decorator_spec.rb b/spec/decorators/card_type_decorator_spec.rb index 22742981b..021e1cf5e 100644 --- a/spec/decorators/card_type_decorator_spec.rb +++ b/spec/decorators/card_type_decorator_spec.rb @@ -6,6 +6,10 @@ let(:card_type) { card_types(:one) } let(:decorated_card_type) { card_type.decorated } + describe ".port_types_options_for_select" do + it { expect(described_class.port_types_options_for_select).to contain_exactly(["ALIM", 4], ["FC", 1], ["Five", 5], ["IPMI", 3], ["RJ", 2], ["Six", 6]) } + end + describe ".grouped_by_port_type_options_for_select" do it do expect(described_class.grouped_by_port_type_options_for_select) diff --git a/spec/decorators/port_type_decorator_spec.rb b/spec/decorators/port_type_decorator_spec.rb index 0dae51208..f9905f484 100644 --- a/spec/decorators/port_type_decorator_spec.rb +++ b/spec/decorators/port_type_decorator_spec.rb @@ -6,13 +6,32 @@ let(:port_type) { port_types(:one) } let(:decorated_port_type) { port_type.decorated } - describe ".alim_options_for_select" do + describe ".options_for_select" do it do expect(described_class.options_for_select) .to contain_exactly(["ALIM", 4], ["FC", 1], ["Five", 5], ["IPMI", 3], ["RJ", 2], ["Six", 6]) end end + describe ".usable_by_options_for_select" do + it do + expect(described_class.usable_by_options_for_select) + .to contain_exactly(%w[Pdu pdu], %w[Serveur server]) + end + end + + describe "#human_usable_by" do + context "with one" do + it { expect(decorated_port_type.human_usable_by).to eq("Serveur") } + end + + context "with multiple" do + let(:port_type) { port_types(:four) } + + it { expect(decorated_port_type.human_usable_by).to eq("Serveur et Pdu") } + end + end + describe "#css_class_name" do subject(:css_class_name) { decorated_port_type.css_class_name } diff --git a/spec/decorators/power_distribution_unit_socket_decorator_spec.rb b/spec/decorators/power_distribution_unit_socket_decorator_spec.rb index 72744f598..ee99b60c6 100644 --- a/spec/decorators/power_distribution_unit_socket_decorator_spec.rb +++ b/spec/decorators/power_distribution_unit_socket_decorator_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PowerDistributionUnitSocketDecorator, type: :decorator do - describe ".port_type_options_for_select" do - it { expect(described_class.port_type_options_for_select).to contain_exactly(["ALIM", 4]) } + describe ".port_types_options_for_select" do + it { expect(described_class.port_types_options_for_select).to contain_exactly(["ALIM", 4]) } end end diff --git a/spec/models/port_type_spec.rb b/spec/models/port_type_spec.rb index 1c58081e8..607c29f26 100644 --- a/spec/models/port_type_spec.rb +++ b/spec/models/port_type_spec.rb @@ -12,7 +12,11 @@ it { is_expected.to have_many(:sockets).dependent(:restrict_with_error) } end - describe "#is_power_input?" do - it { expect(port_type.is_power_input?).to be true } + describe "validations" do + it { is_expected.to validate_array_inclusion_of(:usable_by).in(%w[pdu server]) } + end + + describe "#is_power?" do + it { expect(port_type.is_power?).to be false } end end diff --git a/spec/support/matchers/validate_array_inclusion_of.rb b/spec/support/matchers/validate_array_inclusion_of.rb new file mode 100644 index 000000000..020c8b67b --- /dev/null +++ b/spec/support/matchers/validate_array_inclusion_of.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module ModelMatchers + class ValidateArrayInclusionOf + def initialize(attribute) + @attribute = attribute + end + + def in(values) + @values = values + self + end + + def matches?(record) + raise ArgumentError, "Call `.in(values)` before using this matcher" unless @values + + @record = record + + @record.public_send("#{@attribute}=", @values) + return false unless @record.valid? + + @record.public_send("#{@attribute}=", @values + [invalid_value]) + return false if @record.valid? + + @record.errors.added?(@attribute, :contains_unpermitted_values, wrong_values: invalid_value) + end + + def description + "validate #{@attribute} contains only allowed values" + end + + def failure_message + "expected #{@record.class} to validate that #{@attribute} only includes allowed values" + end + + def failure_message_when_negated + "expected #{@record.class} not to validate that #{@attribute} only includes allowed values" + end + + private + + def invalid_value + @invalid_value ||= begin + value = "__invalid_value__" + value = "_#{value}_" while @values.include?(value) + value + end + end + end + + def validate_array_inclusion_of(attribute) + ValidateArrayInclusionOf.new(attribute) + end +end + +RSpec.configure do |config| + config.include ModelMatchers, type: :model +end diff --git a/test/fixtures/port_types.yml b/test/fixtures/port_types.yml index 344674c4c..fcfc1583b 100644 --- a/test/fixtures/port_types.yml +++ b/test/fixtures/port_types.yml @@ -1,24 +1,35 @@ one: id: 1 name: FC + is_power: false + usable_by: ["server"] two: id: 2 name: RJ + is_power: false + usable_by: ["server"] three: id: 3 name: IPMI + is_power: false + usable_by: ["server"] four: id: 4 name: ALIM - power: true + is_power: true + usable_by: ["server", "pdu"] five: id: 5 name: Five + is_power: false + usable_by: ["server"] six: id: 6 name: Six + is_power: false + usable_by: ["server"]