ManageIQ/manageiq

View on GitHub
app/models/classification.rb

Summary

Maintainability
B
4 hrs
Test Coverage
C
75%
class Classification < ApplicationRecord
  acts_as_tree
  include ReadOnlyMixin

  belongs_to :tag

  virtual_column :name, :type => :string
  virtual_column :ns, :type => :string

  # from acts_as_tree
  alias entries children

  before_save    :save_tag
  before_destroy :validate_tag_mapping, :delete_tags_and_entries

  validates :description, :presence => true, :length => {:maximum => 255}, :unique_within_region => {:scope => :parent_id}

  NAME_MAX_LENGTH = 50
  validates :name, :presence => true, :length => {:maximum => NAME_MAX_LENGTH}
  validate :validate_format_of_name

  validate :validate_uniqueness_on_tag_name

  validates :syntax, :inclusion => {:in      => %w[string integer boolean],
                                    :message => "should be one of 'string', 'integer' or 'boolean'"}

  scope :visible,    -> { where(:show => true) }
  scope :read_only,  -> { where(:read_only => true) }
  scope :writeable,  -> { where(:read_only => false) }

  scope :is_category, -> { where(:parent_id => nil) }
  scope :is_entry,    -> { where.not(:parent_id => nil) }

  scope :with_writable_parents, -> { includes(:parent).where(:parents_classifications => {:read_only => false}) }

  DEFAULT_NAMESPACE = "/managed".freeze

  default_value_for :read_only,    false
  default_value_for :syntax,       "string"
  default_value_for :single_value, false
  default_value_for :show,         true

  FIXTURE_FILE = FIXTURE_DIR.join("classifications.yml")

  def self.hash_all_by_type_and_name(conditions = {})
    ret = {}

    where(conditions).is_category.includes(:tag).each do |c|
      ret.store_path(c.name, :category, c)
    end

    where(conditions).is_entry.includes(:tag, :parent => :tag).each do |e|
      ret.store_path(e.parent.name, :entry, e.name, e) unless e.parent.nil?
    end

    ret
  end

  def self.parent_ids(parent_ids)
    where(:parent_id => parent_ids)
  end

  def self.tags_arel
    Tag.arel_table
  end

  def self.with_tag_name
    select(arel_table[Arel.star], tags_arel[:name].as('tag_name'))
      .joins(:tag)
  end

  def self.managed
    with_tag_name.where(tags_arel[:name].matches_regexp("/managed/[^\\/]+$"))
  end

  attr_writer :ns

  def ns
    @ns ||= DEFAULT_NAMESPACE if new_record?

    return @ns if tag.nil?

    return @ns unless @ns.nil?

    if category?
      @ns = tag2ns(tag.name)
    else
      @ns = tag2ns(parent.tag.name) unless parent_id.nil?
    end
  end

  # Disable certain rubocop rules that don't really work here. Namely the
  # find_by warnings and .zero? warnings because of custom methods and
  # cases where objects could be zero or a real object.

  # rubocop:disable Rails/DynamicFindBy
  # rubocop:disable Style/NumericPredicate

  def self.classify(obj, category_name, entry_name, is_request = true)
    cat = Classification.lookup_by_name(category_name, obj.region_id)
    return " - FAILED. Tag category '#{category_name}' not found in region #{obj.region_id}" if cat.nil?

    ent = cat.find_entry_by_name(entry_name, obj.region_id)
    return " - FAILED. Tag name '#{entry_name}' not found  in region #{obj.region_id}" if ent.nil?

    return " - FAILED. Object already tagged with tag namespace set to 'none'" if obj.is_tagged_with?(ent.to_tag, :ns => "none")

    ent.assign_entry_to(obj, is_request)
    " - SUCCESS."
  end

  def self.unclassify(obj, category_name, entry_name, is_request = true)
    cat = Classification.lookup_by_name(category_name, obj.region_id)
    unless cat.nil?
      ent = cat.find_entry_by_name(entry_name, obj.region_id)
      ent.remove_entry_from(obj, is_request) unless ent.nil? || !obj.is_tagged_with?(ent.to_tag, :ns => "none")
    end
  end

  def self.classify_by_tag(obj, tag, is_request = true)
    parts = tag.split("/")
    raise _("Tag %{tag} is not a category entry") % {:tag => tag} unless parts[1] == "managed"

    entry_name = parts.pop
    category_name = parts.pop

    classify(obj, category_name, entry_name, is_request)
  end

  def self.unclassify_by_tag(obj, tag, is_request = true)
    parts = tag.split("/")
    raise _("Tag %{tag} is not a category entry") % {:tag => tag} unless parts[1] == "managed"

    entry_name = parts.pop
    category_name = parts.pop

    unclassify(obj, category_name, entry_name, is_request)
  end

  def self.bulk_reassignment(options = {})
    # options = {
    #   :model      => Target class name
    #   :object_ids => Array of target ids
    #   :add_ids    => Array of entry ids to be assigned to targets
    #   :delete_ids => Array of entry ids to be unassigned from targets
    # }

    model = options[:model].constantize
    targets = model.where(:id => options[:object_ids]).includes(:taggings, :tags)

    adds = where(:id => options[:add_ids]).includes(:tag)
    adds.each { |a| raise _("Classification add id: [%{id}] is not an entry") % {:id => a.id} if a.category? }

    deletes = where(:id => options[:delete_ids]).includes(:tag)
    deletes.each { |d| raise _("Classification delete id: [%{id}] is not an entry") % {:id => d.id} if d.category? }

    failed_deletes = Hash.new { |h, k| h[k] = [] }
    failed_adds    = Hash.new { |h, k| h[k] = [] }

    targets.each do |t|
      deletes.each do |d|
        _log.info("Removing entry name: [#{d.name}] from #{options[:model]} name: #{t.name}")

        begin
          d.remove_entry_from(t)
        rescue StandardError => err
          _log.error("Error occurred while removing entry name: [#{d.name}] from #{options[:model]} name: #{t.name}")
          _log.error("#{err.class} - #{err}")
          failed_deletes[t] << d
        end
      end

      adds.each do |a|
        _log.info("Adding entry name: [#{a.name}] to #{options[:model]} name: #{t.name}")

        begin
          a.assign_entry_to(t)
        rescue StandardError => err
          _log.error("Error occurred while adding entry name: [#{a.name}] to #{options[:model]} name: #{t.name}")
          _log.error("#{err.class} - #{err}")
          failed_adds[t] << a
        end
      end
    end

    if failed_deletes.any? || failed_adds.any?
      msg = _("Failures occurred during bulk reassignment.")
      failed_deletes.each do |target, deletes|
        names = deletes.collect(&:name).sort
        msg += _("  Unable to remove the following tags from %{class_name} %{id}: %{names}.") %
               {:class_name => target.class.name, :id => target.id, :names => names.join(", ")}
      end
      failed_adds.each do |target, adds|
        names = adds.collect(&:name).sort
        msg += _("  Unable to add the following tags to %{class_name} %{id}: %{names}.") %
               {:class_name => target.class.name, :id => target.id, :names => names.join(", ")}
      end
      raise msg
    end

    true
  end

  def self.get_tags_from_object(obj)
    tags = obj.tag_list(:ns => "/managed").split
    tags.delete_if { |t| t =~ /^\/folder_path_/ }
  end

  def self.create_category!(options)
    is_category.create!(options)
  end

  def self.categories(region_id = my_region_number, ns = DEFAULT_NAMESPACE)
    cats = is_category.in_region(region_id).includes(:tag, :children)
    cats.select { |c| c.ns == ns }
  end

  def self.category_names_for_perf_by_tag(region_id = my_region_number, ns = DEFAULT_NAMESPACE)
    in_region(region_id).is_category.where(:perf_by_tag => true)
      .includes(:tag)
      .collect { |c| c.name if c.tag2ns(c.tag.name) == ns }
      .compact
  end

  def self.find_assigned_entries(obj, ns = DEFAULT_NAMESPACE)
    unless obj.respond_to?(:tag_with)
      raise _("Class '%{name}' is not eligible for classification") % {:name => obj.class}
    end

    tag_ids = obj.tagged_with(:ns => ns).collect(&:id)
    where(:tag_id => tag_ids)
  end

  def self.first_cat_entry(name, obj)
    cat = lookup_by_name(name, obj.region_id)
    return nil unless cat

    find_assigned_entries(obj).each do |e|
      return e if e.parent_id == cat.id
    end
    nil
  end

  # Splits a fully qualified tag into the namespace, category, and entry
  def self.tag_name_split(tag_name)
    parts = tag_name.split("/")
    parts.shift
    parts
  end

  # Splits a fully qualified tag into the namespace, category object, and entry object
  def self.tag_name_to_objects(tag_name)
    ns, cat, entry = tag_name_split(tag_name)
    cat_obj = lookup_by_name(cat)
    entry_obj = cat_obj && cat_obj.find_entry_by_name(entry)
    return ns, cat_obj, entry_obj
  end

  # Builds the given tag into a format usable when calling to_model_hash.
  def self.tag_to_model_hash(tag)
    ns, cat, entry = tag_name_to_objects(tag.name)

    h = {:id => tag.id, :name => tag.name, :namespace => ns}
    %w[id name description single_value].each { |m| h[:"category_#{m}"] = cat.send(m) } unless cat.nil?
    %w[id name description].each { |m| h[:"entry_#{m}"] = entry.send(m) } unless entry.nil?
    h
  end

  def add_entry(options)
    raise _("entries can only be added to classifications") unless category?

    # Inherit from parent classification
    options.merge!(:read_only => read_only, :syntax => syntax, :single_value => single_value, :ns => ns)
    children.create!(options)
  end

  def lookup_by_entry(type)
    raise _("method is only available for an entry") if category?

    klass = type.constantize
    unless klass.respond_to?(:find_tagged_with)
      raise _("Class '%{type}' is not eligible for classification") % {:type => type}
    end

    klass.find_tagged_with(:any => name, :ns => ns, :cat => parent.name)
  end

  alias find_by_entry lookup_by_entry
  Vmdb::Deprecation.deprecate_methods(self, :find_by_entry => :lookup_by_entry)

  def assign_entry_to(obj, is_request = true)
    raise _("method is only available for an entry") if category?
    unless obj.respond_to?(:tag_with)
      raise _("Class '%{name}' is not eligible for classification") % {:name => obj.class}
    end

    enforce_policy(obj, :request_assign_company_tag) if is_request
    if parent.single_value?
      obj.tag_with(name, :ns => ns, :cat => parent.name)
    else
      obj.tag_add(name, :ns => ns, :cat => parent.name)
    end
    obj.reload
    enforce_policy(obj, :assigned_company_tag)
  end

  def remove_entry_from(obj, is_request = true)
    enforce_policy(obj, :request_unassign_company_tag) if is_request
    tags = obj.tag_list(:ns => ns, :cat => parent.name).split
    tags.delete(name)
    obj.tag_with(tags.join(" "), :ns => ns, :cat => parent.name)
    obj.reload
    enforce_policy(obj, :unassigned_company_tag)
  end

  def to_tag
    tag.name unless tag.nil?
  end

  def category?
    parent_id.nil?
  end

  def category
    parent.try(:name)
  end

  def tag_name
    attribute(:tag_name)
  end

  def name
    @name ||= tag2name(tag_name || tag.name)
  end

  attr_writer :name

  def self.lookup_category_by_description(description, region_id = my_region_number)
    is_category.in_region(region_id).find_by(:description => description)
  end

  def find_entry_by_name(name, region_id = my_region_number)
    self.class.lookup_by_name(name, region_id, ns, self)
  end

  # @param parent_id [Integer|Parent] node for the parent. This is only passed in for find_entry_by_name
  def self.lookup_by_name(name, region_id = my_region_number, name_space = DEFAULT_NAMESPACE, parent_id = nil)
    lookup_by_names([name], region_id, name_space, parent_id).first
  end

  singleton_class.send(:alias_method, :find_by_name, :lookup_by_name)
  Vmdb::Deprecation.deprecate_methods(singleton_class, :find_by_name => :lookup_by_name)

  # @param parent_id [Integer|Parent] node for the parent. This is only passed in for find_entry_by_name
  def self.lookup_by_names(names, region_id = my_region_number, name_space = DEFAULT_NAMESPACE, parent_id = nil)
    tag_names = names.map { |name| name2tag(name, parent_id, name_space) }
    # NOTE: tags is a subselect - not an array of ids
    tags = Tag.in_region(region_id).where(:name => tag_names).select(:id)
    where(:tag_id => tags)
  end

  singleton_class.send(:alias_method, :find_by_names, :lookup_by_names)
  Vmdb::Deprecation.deprecate_methods(singleton_class, :find_by_names => :lookup_by_names)

  def tag2ns(tag)
    unless tag.nil?
      ta = tag.split("/")
      ta[0..(ta.length - 2)].join("/")
    end
  end

  def enforce_policy(obj, event)
    return unless MiqEvent::SUPPORTED_POLICY_AND_ALERT_CLASSES.include?(obj.class.base_class)
    return if parent.name == "power_state" # special case for old power state classifications - don't enforce policy since this is being changed by the system

    mode = event.to_s.split("_").first # request/after
    begin
      MiqEvent.raise_evm_event(obj, event)
    rescue MiqException::PolicyPreventAction => err
      if mode == "request"
        # if it's the "before_..." event we can still prevent it from proceeding. Otherwise it's too late.
        _log.info("Event: [#{event}], #{err.message}")
        raise
      end
    rescue Exception => err
      _log.log_backtrace(err)
      raise
    end
  end

  def self.export_to_array
    categories.inject([]) do |a, c|
      a.concat(c.export_to_array)
    end
  end

  def self.export_to_yaml
    export_to_array.to_yaml
  end

  def export_to_array
    h = attributes.except(*%w[id tag_id reserved parent_id])
    h["name"] = name
    h["entries"] = entries.collect(&:export_to_array).flatten if category?
    [h]
  end

  def export_to_yaml
    export_to_array.to_yaml
  end

  def self.import_from_hash(classification, parent = nil)
    raise _("No Classification to Import") if classification.nil?

    stats = {"categories" => 0, "entries" => 0}

    if parent.nil? # category
      cat = lookup_by_name(classification["name"])
      if cat
        _log.info("Skipping Classification (already in DB): Category: name=[#{classification["name"]}]")
        return stats
      end

      _log.info("Importing Classification: Category: name=[#{classification["name"]}]")

      classification.delete("parent_id")
      entries = classification.delete("entries")
      cat = create(classification)
      stats["categories"] += 1
      entries.each do |e|
        stat, _e = import_from_hash(e, cat)
        stats.each_key { |k| stats[k] += stat[k] }
      end

      return stats, cat
    else
      entry = parent.find_entry_by_name(classification["name"])
      if entry
        _log.info("Skipping Classification (already in DB): Category: name: [#{parent.name}], Entry: name=[#{classification["name"]}]")
        return stats
      end

      _log.info("Importing Classification: Category: name: [#{parent.name}], Entry: name=[#{classification["name"]}]")
      entry = create(classification.merge("parent_id" => parent.id))
      stats["entries"] += 1

      return stats, entry
    end
  end

  def self.import_from_yaml(fd)
    stats = {"categories" => 0, "entries" => 0}

    input = YAML.load(fd)
    input.each do |c|
      stat, _c = import_from_hash(c)
      stats.each_key { |k| stats[k] += stat[k] }
    end

    stats
  end

  # rubocop:disable Rails/SkipsModelValidations

  def self.seed
    YAML.load_file(FIXTURE_FILE).each do |c|
      category = lookup_by_name(c[:name], my_region_number, (c[:ns] || DEFAULT_NAMESPACE))
      next if category

      category = is_category.new(c.except(:entries))
      next unless category.valid? # HACK: Skip seeding if categories aren't valid/unique

      _log.info("Creating category #{c[:name]}")
      category.save!
      add_entries_from_hash(category, c[:entries])
    end
  end

  # rubocop:enable Rails/SkipsModelValidations

  def self.sanitize_name(name)
    name.downcase.tr('^a-z0-9_:', '_')[0, NAME_MAX_LENGTH]
  end

  def self.display_name(number = 1)
    n_('Category', 'Categories', number)
  end

  def self.tag2human(tag)
    c, e = tag.split("/")[2..-1]

    cat = lookup_by_name(c)
    cname = cat.nil? ? c.titleize : cat.description

    ename = e.titleize
    unless cat.nil?
      ent = cat.find_entry_by_name(e)
      ename = ent.description unless ent.nil?
    end

    "#{cname}: #{ename}"
  end

  def self.add_entries_from_hash(cat, entries)
    entries.each do |entry|
      ent = cat.find_entry_by_name(entry[:name])
      ent ? ent.update!(entry) : cat.add_entry(entry)
    end
  end

  private_class_method :add_entries_from_hash

  def validate_uniqueness_on_tag_name
    tag_name = Classification.name2tag(name, parent, ns)
    exist_scope = Classification.default_scoped
                                .includes(:tag)
                                .where(:tags => {:name => tag_name})
                                .merge(Tag.in_region(region_id))
    exist_scope = exist_scope.where.not(:id => id) unless new_record?

    errors.add("name", "has already been taken") if exist_scope.exists?
  end

  def self.name2tag(name, parent_id = nil, ns = DEFAULT_NAMESPACE)
    if parent_id.nil?
      File.join(ns, name)
    else
      c = parent_id.kind_of?(Classification) ? parent_id : Classification.find(parent_id)
      File.join(ns, c.name, name) if c
    end
  end

  private

  def validate_format_of_name
    unless (name =~ /[^a-z0-9_:]/).nil?
      errors.add("name", "must be lowercase alphanumeric characters, colons and underscores without spaces")
    end
  end

  def tag2name(tag)
    File.split(tag).last unless tag.nil?
  end

  def find_tag
    tag_name = Classification.name2tag(name, parent, ns)
    Tag.in_region(region_id).find_by(:name => tag_name)
  end

  def save_tag
    tag_name = Classification.name2tag(name, parent, ns)
    if tag_id.present? || tag.present?
      tag.update(:name => tag_name) unless tag.name == tag_name
    else
      self.tag = Tag.in_region(region_id).find_or_create_by(:name => tag_name)
    end
  end

  def delete_all_entries
    entries.each do |e|
      e.delete_assignments
      e.delete_tag_and_taggings
    end
  end

  def delete_assignments
    AssignmentMixin.all_assignments(tag.name).destroy_all
  end

  def delete_tag_and_taggings
    tag = find_tag
    return if tag.nil?

    tag.destroy
  end

  def delete_tags_and_entries
    if category?
      delete_all_entries
    else # entry
      delete_assignments
    end

    delete_tag_and_taggings
  end

  def validate_tag_mapping
    if tag&.provider_tag_mappings&.any?
      errors.add("", _("A Tag Mapping exists for this category and must be removed before deleting"))
      throw :abort
    end
  end

  # rubocop:enable Style/NumericPredicate
  # rubocop:enable Rails/DynamicFindBy
end