Katello/katello

View on GitHub
app/models/katello/content_view.rb

Summary

Maintainability
F
4 days
Test Coverage
module Katello
  # rubocop:disable Metrics/ClassLength
  class ContentView < Katello::Model
    audited :associations => [:repositories, :environments, :filters]
    has_associated_audits
    include Ext::LabelFromName
    include Katello::Authorization::ContentView
    include ForemanTasks::Concerns::ActionSubject
    include Foreman::ObservableModel

    CONTENT_DIR = "content_views".freeze
    IMPORT_LIBRARY = "Import-Library".freeze
    EXPORT_LIBRARY = "Export-Library".freeze
    belongs_to :organization, :inverse_of => :content_views, :class_name => "::Organization"

    has_many :content_view_environments, :class_name => "Katello::ContentViewEnvironment", :dependent => :destroy
    has_many :environments, :class_name => "Katello::KTEnvironment", :through => :content_view_environments

    has_many :content_view_versions, :class_name => "Katello::ContentViewVersion", :dependent => :destroy
    alias_method :versions, :content_view_versions
    has_one :latest_version_object, -> { latest }, :class_name => "Katello::ContentViewVersion", :dependent => :destroy
    # Note the difference between content_view_components and component_composites both refer to
    # ContentViewComponent but mean different things.
    # content_view_components -> Topdown, given I am a composite CV get the associated components belonging to me
    #
    # component_composites -> Bottom Up, given I am a component CV get the associated composites that I belong to
    #
    has_many :content_view_components, :class_name => "Katello::ContentViewComponent", :dependent => :destroy,
             :inverse_of => :composite_content_view, :foreign_key => :composite_content_view_id, autosave: true

    has_many :component_composites, :class_name => "Katello::ContentViewComponent",
             :dependent => :destroy, :inverse_of => :content_view

    has_many :content_view_repositories, :class_name => 'Katello::ContentViewRepository',
             :dependent => :destroy, :inverse_of => :content_view
    has_many :repositories, :through => :content_view_repositories, :class_name => "Katello::Repository",
             :after_remove => :remove_repository

    has_many :filters, :dependent => :destroy, :class_name => "Katello::ContentViewFilter"

    has_many :activation_keys, :class_name => "Katello::ActivationKey", :dependent => :restrict_with_exception

    has_many :content_view_environment_content_facets, :class_name => "Katello::ContentViewEnvironmentContentFacet",
             :through => :content_view_environments
    has_many :content_facets, :class_name => "Katello::Host::ContentFacet", :through => :content_view_environment_content_facets,
             :inverse_of => :content_views
    has_many :hosts, :class_name => "::Host::Managed", :through => :content_facets,
             :inverse_of => :content_views

    has_many :hostgroup_content_facets, :class_name => "Katello::Hostgroup::ContentFacet",
             :inverse_of => :content_view, :dependent => :nullify
    has_many :hostgroups, :class_name => "::Hostgroup", :through => :hostgroup_content_facets,
             :inverse_of => :content_view

    has_many :repository_references, :class_name => 'Katello::Pulp3::RepositoryReference',
             :dependent => :destroy, :inverse_of => :content_view

    validates_lengths_from_database :except => [:label]
    validates :label, :uniqueness => { :scope => :organization_id },
              :presence => true
    validates :name, :presence => true, :uniqueness => { :scope => :organization_id }
    validates :organization_id, :presence => true
    validate :check_non_composite_components
    validate :check_docker_conflicts
    validate :check_non_composite_auto_publish
    validate :check_default_label_name, if: :default?
    validates :composite, :inclusion => [true, false]
    validates :composite,
              inclusion: { in: [false], message: "Composite Content Views can not solve dependencies" },
              if: :solve_dependencies
    validates :import_only, :inclusion => [true, false]
    validates :import_only,
              inclusion: { in: [false], message: "Import-only Content Views can not be Composite" },
              if: :composite
    validates :import_only,
              inclusion: { in: [false], message: "Import-only Content Views can not solve dependencies" },
              if: :solve_dependencies
    validate :import_only_immutable
    validates :generated_for,
              exclusion: { in: [:none], message: "Generated Content Views can not be Composite" },
              if: :composite
    validates :generated_for,
              exclusion: { in: [:none], message: "Generated Content Views can not solve dependencies" },
              if: :solve_dependencies

    validates_with Validators::KatelloNameFormatValidator, :attributes => :name
    validates_with Validators::KatelloLabelFormatValidator, :attributes => :label

    scope :default, -> { where(:default => true) }
    scope :non_default, -> { where(:default => false) }
    scope :composite, -> { where(:composite => true) }
    scope :non_composite, -> { where(:composite => [nil, false]) }
    scope :generated, -> { where.not(:generated_for => :none) }
    scope :generated_for_repository, -> {
      where(:generated_for => [:repository_export,
                               :repository_import,
                               :repository_export_syncable])
    }
    scope :ignore_generated, ->(include_library_generated: false) {
      ignored_values = [:repository_export,
                        :repository_import,
                        :library_export_syncable,
                        :repository_export_syncable]
      ignored_values += [:library_export, :library_import] if include_library_generated
      where.not(generated_for: ignored_values)
    }
    scope :generated_for_library, -> { where(:generated_for => [:library_export, :library_import, :library_export_syncable]) }

    scoped_search :on => :name, :complete_value => true
    scoped_search :on => :organization_id, :complete_value => true, :only_explicit => true, :validator => ScopedSearch::Validators::INTEGER
    scoped_search :on => :label, :complete_value => true
    scoped_search :on => :composite, :complete_value => true
    scoped_search :on => :generated_for, :complete_value => true
    scoped_search :on => :default # just for ordering
    scoped_search :on => :name, :complete_value => true,
                  :rename => :content_views,
                  :operators => ['='],
                  :ext_method => :find_components_by_cv_name

    def self.find_components_by_cv_name(_key, operator, value)
      kcv = Katello::ContentView.table_name
      kcvc = Katello::ContentViewComponent.table_name
      { :conditions => "#{kcv}.composite = 't' AND #{kcv}.id IN (SELECT #{kcvc}.composite_content_view_id FROM #{kcvc} WHERE #{kcvc}.content_view_id IN (SELECT #{kcv}.id FROM #{kcv} WHERE #{kcv}.name #{operator} ?))",
        :parameter => [value]
      }
    end

    def self.completer_scope_options(search)
      if search.include?('content_views')
        # Don't autocomplete CCV names when searching for components
        { :value_filter => { :composite => false } }
      else
        {}
      end
    end

    enum generated_for: {
      none: 0,
      library_export: 1,
      repository_export: 2,
      library_import: 3,
      repository_import: 4,
      library_export_syncable: 5,
      repository_export_syncable: 6
    }, _prefix: true

    set_crud_hooks :content_view

    apipie :class, desc: "A class representing #{model_name.human} object" do
      name 'Content View'
      refs 'ContentView'
      sections only: %w[all additional]
      prop_group :katello_idname_props, Katello::Model, meta: { friendly_name: 'Content View' }
      property :label, String, desc: 'Returns label of the Content View'
      property :organization, 'Organization', desc: 'Returns organization object'
    end

    def self.in_environment(env)
      joins(:content_view_environments).
        where("#{Katello::ContentViewEnvironment.table_name}.environment_id = ?", env.id)
    end

    def self.published_with_repositories(root_repository)
      joins(:content_view_versions => :repositories).where("katello_repositories.root_id" => root_repository.id).uniq
    end

    def self.in_organization(org)
      where(organization_id: org.id) unless org.nil?
    end

    def to_s
      name
    end

    def library_import?
      name == IMPORT_LIBRARY
    end

    def library_export?
      name.start_with? EXPORT_LIBRARY
    end

    def generated_for_repository?
      generated_for_repository_export? || generated_for_repository_import? || generated_for_repository_export_syncable?
    end

    def generated_for_library?
      generated_for_library_export? || generated_for_library_import? || generated_for_library_export_syncable?
    end

    def content_host_count
      hosts.size
    end

    def component_ids
      components.map(&:id)
    end

    def components
      content_view_components.map(&:latest_version).compact.freeze
    end

    def sorted_versions
      versions.order('created_at DESC')
    end

    # Adds content view components based on the input
    # [{:content_view_version_id=>1, :latest=> false}, {:content_view_id=>1, :latest=> true} ..]
    def add_components(components_to_add)
      components_to_add.each do |cvc|
        content_view_components.build(cvc)
      end
    end

    # Removes selected content view components
    # [1,2,34] => content view component ids/
    def remove_components(components_to_remove)
      content_view_components.where(:id => components_to_remove).destroy_all
    end

    # Warning this call wipes out existing associations
    # And replaces them with the component version ids passed in.
    def component_ids=(component_version_ids_to_set)
      content_view_components.destroy_all
      component_version_ids_to_set.each do |content_view_version_id|
        cvv = ContentViewVersion.find(content_view_version_id)
        content_view_components.build(:content_view_version => cvv,
                                      :latest => false,
                                      :composite_content_view => self)
      end
    end

    def copy_components(new_view)
      self.content_view_components.each do |cvc|
        component = cvc.dup
        component.composite_content_view = new_view
        new_view.content_view_components << component
      end
    end

    def copy_filters(new_view)
      self.filters.each do |filter|
        new_filter = filter.dup
        new_filter.repositories = filter.repositories
        new_view.filters << new_filter

        case filter.type
        when ContentViewDebFilter.name
          filter.deb_rules.each do |rule|
            new_filter.deb_rules << rule.dup
          end
        when ContentViewPackageFilter.name
          filter.package_rules.each do |rule|
            new_filter.package_rules << rule.dup
          end
        when ContentViewPackageGroupFilter.name
          filter.package_group_rules.each do |rule|
            new_filter.package_group_rules << rule.dup
          end
        when ContentViewErratumFilter.name
          filter.erratum_rules.each do |rule|
            new_filter.erratum_rules << rule.dup
          end
        end
      end
    end

    def copy(new_name)
      new_view = ContentView.new
      new_view.name = new_name
      new_view.attributes = self.attributes.slice("description", "organization_id", "default", "composite", "solve_dependencies")
      new_view.save!
      new_view.repositories = self.repositories

      copy_components(new_view)

      copy_filters(new_view)
      new_view.save!
      new_view
    end

    def promoted?
      # if the view exists in more than 1 environment, it has been promoted
      self.environments.many?
    end

    def generated?
      !generated_for_none?
    end

    #NOTE: this function will most likely become obsolete once we drop api v1
    def as_json(options = {})
      result = self.attributes
      result['organization'] = self.organization.try(:name)
      result['environments'] = environments.map { |e| e.try(:name) }
      result['versions'] = versions.map(&:version)
      result['versions_details'] = versions.map do |v|
        {
          :version => v.version,
          :published => v.created_at.to_s,
          :environments => v.environments.map { |e| e.name }
        }
      end

      if options && options[:environment].present?
        result['repositories'] = repos(options[:environment]).map(&:name)
      end

      result
    end

    def total_package_count(env)
      Katello::Rpm.in_repositories(self.repos(env)).count
    end

    def total_deb_package_count(env)
      Katello::Deb.in_repositories(self.repos(env)).count
    end

    def in_environment?(env)
      environments.include?(env)
    end

    apipie :method, 'Returns the Katello::ContentViewVersion for a given Lifecycle Environment' do
      required :env, 'Katello::KTEnvironment', desc: 'a __Katello::KTEnvironment__ object for which we load the __Katello::ContentViewVersion__ object'
      returns 'Katello::ContentViewVersion'
    end

    def version(env)
      self.versions.in_environment(env).order("#{Katello::ContentViewVersion.table_name}.id ASC").readonly(false).last
    end

    def latest_version
      latest_version_object.try(:version)
    end

    def latest_version_id
      latest_version_object.try(:id)
    end

    def latest_version_env
      latest_version_object.try(:environments) || []
    end

    def last_task
      last_task_id = history.order(:created_at)&.last&.task_id
      last_task_id ? ForemanTasks::Task.find_by(id: last_task_id) : nil
    end

    def history
      Katello::ContentViewHistory.joins(:content_view_version).where(
        "#{Katello::ContentViewVersion.table_name}.content_view_id" => self.id)
    end

    def version_environment(env)
      # TODO: rewrite this into SQL or use content_view_environment when that
      # points to environment
      version(env).content_view_version_environments.select { |cvve| cvve.environment_id == env.id }
    end

    def resulting_products
      (self.repositories.collect { |r| r.product }).uniq
    end

    def repos(env = nil)
      if env
        repo_ids = versions.flat_map { |version| version.repositories.in_environment(env) }.map(&:id)
      else
        repo_ids = versions.flat_map { |version| version.repositories }.map(&:id)
      end
      Repository.where(:id => repo_ids)
    end

    def library_repos
      Repository.where(:id => library_repo_ids)
    end

    def library_repo_ids
      repos(self.organization.library).map { |r| r.library_instance_id }
    end

    def all_version_repos
      Repository.joins(:content_view_version).
        where("#{Katello::ContentViewVersion.table_name}.content_view_id" => self.id)
    end

    def repositories_to_publish(override_components = nil)
      if composite?
        components_to_publish = []
        components.each do |component|
          override_component = override_components&.detect do |override_cvv|
            override_cvv.content_view == component.content_view
          end

          if override_component
            components_to_publish << override_component
          else
            components_to_publish << component
          end
        end
        ids = components_to_publish.flat_map { |version| version.repositories.archived }.map(&:id)
        Repository.where(:id => ids)
      else
        repositories
      end
    end

    def repositories_to_publish_ids
      composite? ? repositories_to_publish.pluck(&:id) : repository_ids
    end

    def repositories_to_publish_by_library_instance(override_components = nil)
      # retrieve the list of repositories in a hash, where the key
      # is the library instance id, and the value is an array
      # of the repositories for that instance.
      repositories_to_publish(override_components).inject({}) do |result, repo|
        result[repo.library_instance] ||= []
        result[repo.library_instance] << repo
        result
      end
    end

    def duplicate_repositories_to_publish
      return [] unless composite?
      repositories_to_publish_by_library_instance.select { |key, val| val.count > 1 && key.present? }.keys
    end

    def components_with_repo(library_instance)
      components.select { |component| component.repositories.where(:library_instance => library_instance).any? }
    end

    def auto_publish_components
      component_composites.where(latest: true).joins(:composite_content_view).where(self.class.table_name => { auto_publish: true })
    end

    def publish_repositories(override_components = nil)
      repositories = composite? ? repositories_to_publish_by_library_instance(override_components).values : repositories_to_publish
      repositories.each do |repos|
        if repos.is_a? Array
          yield repos
        else
          yield [repos]
        end
      end
    end

    def update_host_statuses(environment)
      # update errata applicability counts for all hosts in the CV & LE
      Location.no_taxonomy_scope do
        User.as_anonymous_admin do
          ::Katello::Host::ContentFacet.in_content_views_and_environments(
            content_views: [self],
            lifecycle_environments: [environment]
          ).each do |facet|
            facet.update_applicability_counts
            facet.update_errata_status
          end
        end
      end
    end

    def component_repositories
      components.map(&:archived_repos).flatten
    end

    def component_repository_ids
      component_repositories.map(&:id)
    end

    def repos_in_product(env, product)
      version = version(env)
      if version
        version.repositories.in_environment(env).in_product(product)
      else
        []
      end
    end

    def products(env = nil)
      repos = repos(env)
      Product.joins(:repositories).where("#{Katello::Repository.table_name}.id" => repos.map(&:id)).distinct
    end

    #get the library instances of all repos within this view
    def all_version_library_instances
      all_repos = all_version_repos.where(:library_instance_id => nil).pluck("#{Katello::Repository.table_name}.id")
      all_repos += all_version_repos.pluck(:library_instance_id)
      Repository.where(:id => all_repos)
    end

    def get_repo_clone(env, repo)
      lib_id = repo.library_instance_id || repo.id
      Repository.in_environment(env).where(:library_instance_id => lib_id).
        joins(:content_view_version).
        where("#{Katello::ContentViewVersion.table_name}.content_view_id" => self.id)
    end

    def delete(from_env)
      if from_env.library? && in_non_library_environment?
        fail Errors::ChangesetContentException, _("Cannot delete view while it exists in environments")
      end

      version = self.version(from_env)
      if version.nil?
        fail Errors::ChangesetContentException, _("Cannot delete from %s, view does not exist there.") % from_env.name
      end
      version = ContentViewVersion.find(version.id)

      if (foreman_env = Environment.find_by_katello_id(self.organization, from_env, self))
        foreman_env.destroy
      end

      version.delete(from_env)
      self.destroy if self.versions.empty?
    end

    def in_non_library_environment?
      environments.where(:library => false).length > 0
    end

    def duplicate_repositories
      counts = repositories_to_publish.each_with_object(Hash.new(0)) do |repo, h|
        h[repo.library_instance_id] += 1
      end
      ids = counts.select { |_k, v| v > 1 }.keys
      Repository.where(:id => ids)
    end

    def duplicate_docker_repos
      duplicate_repositories.docker_type
    end

    def check_non_composite_components
      if !composite? && components.present?
        errors.add(:base, _("Cannot add component versions to a non-composite content view"))
      end
    end

    def check_non_composite_auto_publish
      if !composite? && auto_publish
        errors.add(:base, _("Cannot set auto publish to a non-composite content view"))
      end
    end

    def check_default_label_name
      if default? && !(name == 'Default Organization View' && label == 'Default_Organization_View')
        errors.add(:base, _("Name and label of default content view should not be changed"))
      end
    end

    def check_docker_conflicts
      duplicate_docker_repos.each do |repo|
        msg = _("Container Image repo '%{repo}' is present in multiple component content views.") % { repo: repo.name }
        errors.add(:base, msg)
      end
    end

    def content_view_environment(environment)
      self.content_view_environments.where(:environment_id => environment.try(:id)).first
    end

    def update_cp_content(env)
      view_env = content_view_environment(env)

      view_env&.update_cp_content
    end

    # Associate an environment with this content view.  This can occur whenever
    # a version of the view is promoted to an environment.  It is necessary for
    # candlepin to become aware that the view is available for consumers.
    def add_environment(env, version)
      if self.content_view_environments.where(:environment_id => env.id).empty?
        label = generate_cp_environment_label(env)
        ContentViewEnvironment.create!(:name => label,
                                       :label => label,
                                       :cp_id => generate_cp_environment_id(env),
                                       :environment_id => env.id,
                                       :content_view => self,
                                       :content_view_version => version
        )
      end
    end

    # Unassociate an environment from this content view. This can occur whenever
    # a view is deleted from an environment. It is necessary to make candlepin
    # aware that the view is no longer available for consumers.
    def remove_environment(env)
      # Do not remove the content view environment, if there is still a view
      # version in the environment.
      if self.versions.in_environment(env).blank?
        view_env = self.content_view_environments.where(:environment_id => env.id)
        view_env.first.destroy unless view_env.blank?
      end
    end

    def cp_environment_label(env)
      ContentViewEnvironment.where(:content_view_id => self, :environment_id => env).first.try(:label)
    end

    def cp_environment_id(env)
      ContentViewEnvironment.where(:content_view_id => self, :environment_id => env).first.try(:cp_id)
    end

    def create_new_version(major = next_version, minor = 0, components = self.components)
      version = ContentViewVersion.create!(:major => major,
                                           :minor => minor,
                                           :content_view => self,
                                           :components => components
      )

      # TODO: If a controller creates a new version and then uses latest_version_object, the old data is displayed.
      #       To prevent this, a 'reload' would currently be necessary, but this is not very performant.
      #       However, this is currently not a problem because after your create_new_version there is no immediate
      #       access to latest_version_object, but the ContentView object is first completely reloaded.
      #
      #       In Rails 7.1, individual connections can be reloaded:
      #       https://www.shakacode.com/blog/rails-7-1-allows-resetting-singular-associations/

      update(:next_version => major.to_i + 1) unless major.to_i < next_version
      version
    end

    def check_ready_to_import!
      fail _("Cannot import a composite content view") if composite?
      fail _("This Content View must be set to Import-only before performing an import") unless import_only?
      true
    end

    def check_ready_to_publish!(importing: false, syncable: false)
      fail _("User must be logged in.") if ::User.current.nil?
      fail _("Cannot publish default content view") if default?

      if importing
        check_ready_to_import!
      else
        fail _("Import-only content views can not be published directly") if import_only? && !syncable
        check_repositories_blocking_publish!
        check_composite_action_allowed!(organization.library)
        check_docker_repository_names!([organization.library])
        check_orphaned_content_facets!(environments: self.environments)
      end

      true
    end

    def check_repositories_blocking_publish!
      blocking_tasks = repositories&.map { |repo| repo.blocking_task }&.compact

      if blocking_tasks&.any?
        errored_tasks = blocking_tasks.uniq.map { |task| "- #{Setting['foreman_url']}/foreman_tasks/tasks/#{task&.id}" }.join("\n")
        fail _("Pending tasks detected in repositories of this content view. Please wait for the tasks: " +
                 errored_tasks + " before publishing.")
      end
    end

    def check_docker_repository_names!(environments)
      environments.each do |environment|
        repositories = []
        publish_repositories do |all_repositories|
          repositories += all_repositories.keep_if { |repository| repository.content_type == Katello::Repository::DOCKER_TYPE }
        end
        next if repositories.empty?

        error_messages = ::Katello::Validators::EnvironmentDockerRepositoriesValidator.validate_repositories(environment.registry_name_pattern, repositories)
        unless error_messages.empty?
          error_messages << _("Consider changing the Lifecycle Environment's Registry Name Pattern to something more specific.")
          fail error_messages.join("  ")
        end
      end
      true
    end

    def check_composite_action_allowed!(env)
      if composite? && Setting['restrict_composite_view']
        if components.size != content_view_components.size
          fail _("Make sure all the component content views are published before publishing/promoting the composite content view. "\
               "This restriction is optional and can be modified in the Administrator -> Settings -> Content "\
                "page using the restrict_composite_view flag.")
        end

        env_ids = env.try(:pluck, 'id') || []
        env_ids << env.id unless env_ids.size > 0
        components.each do |component|
          component_environment_ids = component.environments.pluck('id')
          unless (env_ids - component_environment_ids).empty?
            fail _("The action requested on this composite view cannot be performed until all of the "\
                   "component content view versions have been promoted to the target environment: %{env}.  "\
                   "This restriction is optional and can be modified in the Administrator -> Settings -> Content "\
                   "page using the restrict_composite_view flag.") %
                   { :env => env.try(:pluck, 'name') || env.name }
          end
        end
      end
      true
    end

    def check_orphaned_content_facets!(environments: [])
      Location.no_taxonomy_scope do
        User.as_anonymous_admin do
          ::Katello::Host::ContentFacet.in_content_views_and_environments(
            content_views: [self],
            lifecycle_environments: environments
          ).each do |facet|
            unless facet.host
              fail _("Orphaned content facets for deleted hosts exist for the content view and environment. Please run rake task : katello:clean_orphaned_facets and try again!")
            end
          end
        end
      end
    end

    def check_remove_from_environment!(env)
      errors = []

      dependencies = { hosts: _("hosts"),
                       activation_keys: _("activation keys")
      }

      dependencies.each do |key, name|
        if (models = self.association(key).scope.in_environment(env)).any?
          errors << _("Cannot remove '%{view}' from environment '%{env}' due to associated %{dependent}: %{names}.") %
            { view: self.name, env: env.name, dependent: name, names: models.map(&:name).join(", ") }
        end
      end

      fail errors.join(" ") if errors.any?
      return true
    end

    def check_ready_to_destroy!
      errors = []

      dependencies = { environments: _("environments"),
                       hosts: _("hosts"),
                       activation_keys: _("activation keys")
      }

      dependencies.each do |key, name|
        if (models = self.association(key).scope).any?
          errors << _("Cannot delete '%{view}' due to associated %{dependent}: %{names}.") %
            { view: self.name, dependent: name, names: models.map(&:name).join(", ") }
        end
      end

      fail errors.join(" ") if errors.any?
      return true
    end

    def self.humanize_class_name(_name = nil)
      _("Content Views")
    end

    def version_count
      content_view_versions.size
    end

    def on_demand_repositories
      repositories.on_demand
    end

    def related_cv_count
      if composite
        content_view_components.length
      else
        component_composites.length
      end
    end

    def related_composite_cvs
      content_views = []
      component_composites.each do |cv|
        cv_id = cv.composite_content_view_id
        cv_name = ContentView.find(cv_id).name
        content_views.push(
          {
            id: cv_id,
            name: cv_name
          }
        )
      end
      content_views
    end

    def composite_cv_components_changed?
      return true unless latest_version_object
      published_component_version_ids = latest_version_object.components.pluck(:id) || []
      unpublished_component_version_ids = content_view_components.where(latest: false).pluck(:content_view_version_id) || []
      content_view_components.where(latest: true).each do |latest_component|
        unpublished_component_version_ids << latest_component.content_view&.latest_version_object&.id
      end
      published_component_version_ids.compact.uniq.sort != unpublished_component_version_ids.compact.uniq.sort
    end

    def last_publish_task_success?
      last_publish_result = latest_version_object&.history&.publish&.first&.task&.result
      return last_publish_result.present? && last_publish_result == 'success'
    end

    def cv_repo_indexed_after_last_published?
      repositories.any? { |repo| repo.last_indexed && repo.last_indexed > latest_version_object.created_at }
    end

    def unpublishable?
      default? || import_only? || generated?
    end

    def needs_publish?
      #Returns
      # True:
      #     a) When content/repo/filter change audit records exist
      #     b) CV hasn't ever been published
      #     c) CV dependency_solving != latest_version.applied_filters.dependency_solving
      #     d) If repo was indexed after cv publish. This can happen under 3 cases:
      #       i) Index runs because last index(before publish) had failed and repo is picked up for index even if pulp publication hasn't changed.
      #       ii) Complete sync runs or sync adds/removes new content (Already true because new pulp publication/version gets created)
      #       iii) repo.index_content is run. (This doesn't necessarily indicate contents changed. Corner case where we play safe and return true)
      # nil:
      #     a) When CV version creation audit is missing(Indicating audit cleanup)
      #     b) Version doesn't have audited_filters set indicating
      #     it was published before 4.9 upgrade when we started auditing changes on the CV.
      #     c) Last publish task failed leaving us with no way of knowing if all content in the version is correct.
      # False:
      #     a) No changes were detected via audits *and*
      #        Audit for CV publish exists (Audits haven't been cleaned up)
      #        *and* applied_filters field is set(Published after upgrade)
      #     b) Default, import only and generated CVs can not be published, hence these will always return false.
      #
      return false if unpublishable?
      return true unless latest_version_object
      return nil unless last_publish_task_success?
      return composite_cv_components_changed? if composite?
      # return true if the audit records clearly show we have unpublished changes
      return true if audited_changes_present?
      # return true if the dependency solving changed for CV between last publish and now
      return true if dependency_solving_changed?
      # return true if any child repo's indexed_at > last_version.created_at
      return true if cv_repo_indexed_after_last_published?
      # if we didn't return `true` already, either the audit records show that we don't need to publish, or we may
      # have insufficient data to make the determination (either audits were cleaned, or never got created at all).
      # first, check for the `create` audit record; its absence indicates that audits were cleaned some time after
      # the cv version was created (i.e. the first indeterminate state) so we return `nil` in that case.
      return nil unless latest_version_object&.audits&.where(action: "create")&.exists?
      # even when the `create` audit exists, the other audits could still be absent due to the latest cv version
      # being created prior to the tracking of the other audits that were added in katello 4.9 (i.e. the second indeterminate state).
      # We determine that using the `applied_filters` field. This field was added in Katello 4.9 and is set to nil for
      # all versions published before that upgrade.
      # If `applied_filters` is nil we can not deterministically rule out changes before the upgrade
      # not captured by newer content change and filter change audits.
      # If that field is not nil, the version was published after upgrade, hence we have all the information to rule out
      # any audited changes to the CV and we can deterministically return false
      latest_version_object.applied_filters.nil? ? nil : false
    end

    def audited_changes_present?
      latest_version_created_at = latest_version_object.created_at
      cv_repository_ids = repositories.pluck(:id)

      audited_changes_like = ->(param) {
        Arel.sql("#{Audit.table_name}.audited_changes ilike '%#{param}%'")
      }

      table = Audit.arel_table
      repository_condition = table[:auditable_id].eq(id).and(audited_changes_like.call("repository_ids"))

      cv_repository_condition = table[:auditable_id].in(cv_repository_ids)
                                                    .and(table[:auditable_type].eq('Katello::Repository'))
                                                    .and(Arel.sql("(#{audited_changes_like.call("publication_href")} OR #{audited_changes_like.call("version_href")})"))

      content_view_filter_condition = table[:auditable_type].eq('Katello::ContentViewFilter').and(table[:associated_id].eq(id))

      filter_rule_condition = table[:associated_id].eq(id).and(table[:auditable_type].matches('%FilterRule%'))

      base_query = table[:created_at].gt(latest_version_created_at)

      final_query = base_query.and(repository_condition.or(cv_repository_condition).or(content_view_filter_condition).or(filter_rule_condition))

      Audit.where(final_query).exists?
    end

    def dependency_solving_changed?
      latest_version_object.applied_filters && solve_dependencies != latest_version_object.applied_filters['dependency_solving']
    end

    def filtered?
      filters.present?
    end

    def blocking_task
      blocking_task_labels = [
        ::Actions::Katello::ContentView::Publish.name
      ]
      ForemanTasks::Task::DynflowTask.where(:label => blocking_task_labels)
                                     .where.not(state: 'stopped')
                                     .for_resource(self)
                                     .order(:started_at)
                                     .last
    end

    protected

    def remove_repository(repository)
      filters.each do |filter_item|
        repo_exists = Repository.unscoped.joins(:filters).where(
          ContentViewFilter.table_name => { :id => filter_item.id }, :id => repository.id).count
        if repo_exists
          filter_item.repositories.delete(repository)
          filter_item.save!
        end
      end
    end

    private

    def import_only_immutable
      if import_only_changed? && self.persisted?
        errors.add(:import_only, _("Import-only can not be changed after creation"))
      end
    end

    def generate_cp_environment_label(env)
      # The label for a default view, will simply be the env label; otherwise, it
      # will be a combination of env and view label.  The reason being, the label
      # for a default view is internally generated (e.g. 'Default_View_for_dev')
      # and we do not need to expose it to the user.
      self.default ? env.label : [env.label, self.label].join('/')
    end

    def generate_cp_environment_id(env)
      # The id for a default view, will simply be the org label; otherwise, it
      # will be a combination of env id and view id.  The reason being,
      # for a default view, the same candlepin environment will be referenced
      # by the kt_environment and content_view_environment.
      value = self.default ? env.organization.label.to_s : [env.organization.label, env.label, self.label].join('-')
      Katello::Util::Data.hexdigest(value)
    end

    def confirm_not_promoted
      if promoted?
        errors.add(:base, _("cannot be deleted if it has been promoted."))
        return false
      end
      return true
    end

    def related_resources
      self.organization
    end

    class Jail < ::Safemode::Jail
      allow :id, :name, :label, :version, :organization
    end
  end
end