openjaf/cenit

View on GitHub
app/models/setup/cross_shared_collection.rb

Summary

Maintainability
D
2 days
Test Coverage
module Setup
  class CrossSharedCollection
    include CenitUnscoped
    include CrossOrigin::CenitDocument
    include CollectionBehavior
    include Taggable
    include AsynchronousPersistence::Model

    origins -> { Cenit::MultiTenancy.tenant_model.current && :owner }, :shared

    default_origin :owner

    build_in_data_type.with(
      :title,
      :origin,
      :name,
      :shared_version,
      :authors,
      :summary,
      :categories,
      :pull_parameters,
      :pull_asynchronous,
      :pull_count,
      :dependencies,
      :readme,
      :image,
      :pull_data,
      :data,
      :swagger_spec,
      *COLLECTING_PROPERTIES
    )
    build_in_data_type.embedding(:categories).and(with_origin: true)
    build_in_data_type.discarding(:pull_data, *COLLECTING_PROPERTIES)
    build_in_data_type.referenced_by(:name, :shared_version)

    deny :create

    belongs_to :owner, class_name: Cenit::MultiTenancy.user_model_name, inverse_of: nil

    field :shared_version, type: String
    embeds_many :authors, class_name: Setup::CrossCollectionAuthor.to_s, inverse_of: :shared_collection

    field :category, type: String
    field :summary, type: String
    has_and_belongs_to_many :categories, class_name: Setup::Category.to_s, inverse_of: nil

    embeds_many :pull_parameters, class_name: Setup::CrossCollectionPullParameter.to_s, inverse_of: :shared_collection
    has_and_belongs_to_many :dependencies, class_name: Setup::CrossSharedCollection.to_s, inverse_of: nil

    field :pull_count, type: Integer, default: 0

    hash_field :data, :pull_data, :swagger_spec

    image_with ImageUploader
    field :logo_background, type: String

    field :installed, type: Mongoid::Boolean, default: false
    field :pull_asynchronous, type: Mongoid::Boolean, default: true

    before_validation do
      self.shared_version ||= '0.0.1'
      if authors.blank?
        authors.new(name: User.current.name, email: User.current.email)
      end
    end

    validates_format_of :shared_version, with: /\A(0|[1-9]\d*)(\.(0|[1-9]\d*))*\Z/
    validates_length_of :shared_version, maximum: 255
    validates_presence_of :authors, :summary

    accepts_nested_attributes_for :authors, allow_destroy: true
    accepts_nested_attributes_for :pull_parameters, allow_destroy: true

    default_scope -> { desc(:pull_count) }

    def installed?
      installed && persisted?
    end

    def shared?
      true
    end

    def hash_attribute_read(name, value)
      case name
        when 'data'
          installed? ? generate_data : value
        when 'pull_data'
          installed? ? value : data
        else
          value
      end
    end

    def check_before_save
      super &&
        ensure_shared_name &&
        check_dependencies &&
        validates_pull_parameters &&
        begin
          if installed
            self.data = {}
          end
          true
        end
    end

    def ensure_shared_name
      # TODO Include a validation logic for shared collections names
      errors.blank?
    end

    def check_dependencies
      for_each_dependence([self]) do |dependence, stack|
        next unless stack.count { |d| d.name == dependence.name } > 1
        errors.add(:dependencies, "with circular reference #{stack.collect(&:versioned_name).join(' -> ')}")
        return false
      end
      true
    end

    def for_each_dependence(stack = [], &block)
      dependencies.each do |dependence|
        stack << dependence
        dependence.for_each_dependence(stack, &block)
        block.call(dependence, stack)
        stack.pop
      end if block
    end

    def validates_pull_parameters
      with_errors = false
      data =
        if installed
          if new_record?
            attributes[:pull_data] || attributes['pull_data']
          else
            self.pull_data
          end
        else
          self.data
        end
      data =
        begin
          JSON.parse(data.to_s)
        rescue
          {}
        end unless data.is_a?(Hash)
      pull_parameters.each do |pull_parameter|
        pull_parameter.process_on(data)
        with_errors ||= pull_parameter.errors.present?
      end
      if with_errors
        errors.add(:pull_parameters, 'is not valid')
        false
      else
        true
      end
    end

    def pull_model(name = 'pull_parameters')
      @pull_model ||= Mongoff::Model.for(
        data_type: self.class.data_type,
        schema: pull_schema,
        name: name,
        cache: false
      )
    end

    def pull_schema
      schema =
        {
          type: 'object',
          properties: properties = {},
          required: required = []
        }
      each_pull_parameter do |p|
        properties[p.id] = p.schema
        required << p.id if p.required
      end
      schema.stringify_keys
    end

    def each_pull_parameter(&block)
      pull_parameters.each { |p| block.call(p) }
      config_pull_parameters.each { |p| block.call(p) }
    end

    def config_pull_parameters
      unless @config_pull_parameters
        @config_pull_parameters = []
        [Setup::RemoteOauthClient, Setup::GenericAuthorizationClient].each do |client_model|
          clients = COLLECTING_PROPERTIES.detect { |p| reflect_on_association(p).klass == client_model }.to_s
          (pull_data[clients] || []).each do |client_data|
            client = client_model.new_from_json(client_data, add_only: true)
            Cenit::Utility.bind_references(client)
            if client.new_record? || ((current_user = User.current) && current_user.owns?(client.tenant))
              %w(identifier secret).each do |property|
                if client.send("get_#{property}").blank?
                  p = Setup::CrossCollectionPullParameter.new(
                    _id: "config_#{@config_pull_parameters.size}",
                    label: "Client '#{client.name}' #{property.capitalize}",
                    type: 'string',
                    required: false,
                    description: I18n.t('admin.actions.pull.client_property_description', client_name: client.name, property: property)
                  )
                  p.properties_locations << Setup::PropertyLocation.new(
                    property_name: property,
                    location: { clients => client_data }
                  )
                  @config_pull_parameters << p
                end
              end
            end
          end
        end
      end
      @config_pull_parameters
    end

    def pull_parameters?
      pull_parameters.present? || config_pull_parameters.present?
    end

    def generate_data
      hash = {}
      if installed?
        visited = Set.new
        COLLECTING_PROPERTIES.each do |property|
          r = reflect_on_association(property)
          next unless (items = pull_data[r.name.to_s])
          hash[r.name] = items.collect do |item|
            record = r.klass.data_type.new_from_json(item, add_only: true)
            visited << record
            record
          end
        end
        hash.each do |r, records|
          hash[r] = records.collect do |record|
            Cenit::Utility.bind_references(record, visited: visited)
            record.share_hash
          end
        end
      else
        hash = collecting_data
      end
      hash.delete('readme')
      clean_ids(hash)
    end

    def data_with(parameters = {})
      hash_data = dependencies_data.deep_merge(pull_data) { |_, val1, val2| Cenit::Utility.array_hash_merge(val1, val2) }
      parametrize(hash_data, parameters)
      hash_data['metadata'] = metadata unless hash_data.key?('metadata') && metadata.blank?
      hash_data
    end

    def parametrize(hash_data, parameters, options = {})
      each_pull_parameter do |pull_parameter|
        value = parameters[pull_parameter.id] || parameters[pull_parameter.id.to_s]
        pull_parameter.process_on(hash_data, options.merge(value: value))
      end
    end

    def dependencies_data(parameters = {})
      dependencies.inject({}) { |hash_data, dependency| hash_data.deep_merge(dependency.data_with(parameters)) { |_, val1, val2| Cenit::Utility.array_hash_merge(val1, val2) } }
    end

    def pulled(options = {})
      self.class.collection.find(_id: id).update_one('$inc' => { pull_count: 1 })
      install(options) if !installed && options[:install] && ::User.current_installer?
    end

    def reinstall(options = {})
      options[:collection] ||= self
      options[:add_dependencies] = true unless options.key?(:add_dependencies)
      install(options)
    end

    def install(options)
      collection = options[:collection]
      origin = options[:origin] || (self.origin == :default ? self.class.default_origin : self.origin)
      collection.add_dependencies if options[:add_dependencies]

      if collection.warnings.present?
        collection.save(add_dependencies: false) if collection.changed?
        collection.warnings.each do |warning|
          errors.add(:base, warning)
        end
        return false
      end

      pull_data = {}
      [:title, :readme].each do |field|
        if (value = send(field))
          pull_data[field] = value
        end
      end

      collection.cross_to(origin, origin: :default)

      attributes = {}
      COLLECTING_PROPERTIES.each do |property|
        r = reflect_on_association(property)
        opts = { polymorphic: true }
        opts[:include_id] = lambda do |record|
          record.is_a?(Setup::CrossOriginShared) && record.shared?
        end
        pull_data[r.name] =
          if r.klass < Setup::CrossOriginShared
            if (ids = collection.send(r.foreign_key).dup).present?
              attributes[r.foreign_key] = ids
            end
            if r.klass.include?(Setup::SharedConfigurable)
              configuring_fields = r.klass.data_type.get_referenced_by + r.klass.configuring_fields.to_a
              configuring_fields = configuring_fields.collect(&:to_s)
              collection.send(r.name).collect do |record|
                { _id: record.id.to_s }.merge(
                  with_reset(
                    record.share_hash(opts).reject { |k, _| configuring_fields.exclude?(k) },
                    record.orm_model
                  )
                )
              end
            else
              collection.send(r.name).collect { |record| record.shared? ? { _id: record.id.to_s } : with_reset(record.share_hash(opts), record.orm_model) }
            end
          else
            collection.send(r.name).collect { |record| with_reset(record.share_hash(opts), record.orm_model) }
          end
        pull_data.delete_if { |_, value| value.blank? }
        send("#{property}=", [])
      end

      assign_attributes(attributes)
      self.metadata = collection.metadata
      pull_data.deep_stringify_keys!
      self.pull_data = pull_data

      self.installed = true
      self.skip_reinstall_callback = true
      save(add_dependencies: false)
    end

    def versioned_name
      "#{name}-#{shared_version}"
    end

    def save(options = {})
      if (result = super)
        reinstall(add_dependencies: false) unless !installed? || skip_reinstall_callback
        self.skip_reinstall_callback = false
      end
      result
    end

    def method_missing(symbol, *args)
      if (match = /\Adata_(.+)\Z/.match(symbol.to_s)) &&
         COLLECTING_PROPERTIES.include?(relation_name = match[1].to_sym) &&
         ((args.length.zero? && (options = {})) || args.length == 1 && (options = args[0]).is_a?(Hash))
        if (items = send(relation_name)).present?
          items
        else
          relation = reflect_on_association(relation_name)
          items_data = pull_data[relation.name] || []
          limit = options[:limit] || items_data.length
          c = 0
          items_data.collect do |item_data|
            if c > limit
              nil
            else
              c += 1
              relation.klass.new_from_json(item_data)
            end
          end
        end
      else
        super
      end
    end

    class << self
      def index_ignore_properties
        super + [:data]
      end
    end

    protected

    attr_accessor :skip_reinstall_callback

    def clean_ids(value)
      case value
        when Hash
          if value['_reference']
            Cenit::Utility.deep_remove(value, 'id')
          else
            h = {}
            value.each do |key, sub_value|
              h[key] = clean_ids(sub_value)
            end
            h
          end
        when Array
          value.collect(&method(:clean_ids))
        else
          value
      end
    end

    def with_reset(hash, model)
      unless (_reset = hash['_reset'])
        _reset = hash['_reset'] = []
      end
      hash.keys.each do |property|
        if !_reset.include?(property) && model.relations[property]&.many?
          _reset << property
        end
      end
      hash.delete('_reset') if _reset.empty?
      hash
    end
  end
end