artirix/browsercms

View on GitHub
lib/cms/behaviors/attaching.rb

Summary

Maintainability
A
2 hrs
Test Coverage
module Cms
  # @todo Comments need to be cleaned up to get rid of 'uses_paperclip'
  module Behaviors
    # Allows one or more files to be attached to content blocks.
    #
    # class Book < ActiveRecord::Base
    #   acts_as_content_block
    #   has_attachment :cover
    # end
    #
    #
    # To add a set of multiple attachments:
    #
    # class Book
    #   acts_as_content_block
    #
    #   has_attachment :cover
    #   has_many_attachments :drafts
    # end
    #
    #  Adds the following methods to Book:
    #  - Book#cover @return [Cms::Attachment]
    #  - Book#drafts @return [Array<Cms::Attachment>]
    #
    module Attaching

      def self.included(base)
        base.extend MacroMethods
      end

      module MacroMethods

        # Adds additional behavior to a model which allows it to have attachments.
        # Typically, clients will not need to call this directly. Enabling attachments is normally done via:
        #
        #   acts_as_content_block :allow_attachments => false
        #
        ## By default, blocks can have attachments.
        def allow_attachments
          extend ClassMethods
          extend Validations
          include InstanceMethods

          # Allows a block to be associated with a list of uploaded attachments (done via AJAX)
          attr_accessor :attachment_id_list, :attachments_changed

          Cms::Attachment.definitions[self.name] = {}
          has_many :attachments, :as => :attachable, :dependent => :destroy, :class_name => 'Cms::Attachment', :autosave => false

          accepts_nested_attributes_for :attachments,
                                        :allow_destroy => true,
                                        # New attachments must have an uploaded file
                                        :reject_if => lambda { |a| a[:data].blank? && a[:id].blank? }

          validates_associated :attachments
          before_validation :initialize_attachments, :check_for_updated_attachments
          after_validation :filter_generic_attachment_errors

          before_create :associate_new_attachments
          before_save :ensure_status_matches_attachable

          after_save :save_associated_attachments

        end
      end

      #NOTE: Assets should be validated when created individually.
      module Validations
        def validates_attachment_size(name, options = {})

          min = options[:greater_than] || (options[:in] && options[:in].first) || 0
          max = options[:less_than] || (options[:in] && options[:in].last) || (1.0/0)
          range = (min..max)
          message = options[:message] || "#{name.to_s.capitalize} file size must be between :min and :max bytes."
          message = message.gsub(/:min/, min.to_s).gsub(/:max/, max.to_s)

          #options[:unless] = Proc.new {|r| r.a.asset_name != name.to_s}

          validate(options) do |record|
            record.attachments.each do |attachment|
              next unless attachment.attachment_name == name.to_s
              record.errors.add_to_base(message) unless range.include?(attachment.data_file_size)
            end
          end
        end

        def validates_attachment_presence(name, options = {})
          message = options.delete(:message) || "Must provide at least one #{name}"
          validate(options) do |record|
            return if record.deleted?
            unless record.attachments.any? { |a| a.attachment_name == name.to_s }
              record.errors.add(:attachment, message)
            end
          end
        end

        def validates_attachment_content_type(name, options = {})
          validation_options = options.dup
          allowed_types = [validation_options[:content_type]].flatten
          validate(validation_options) do |record|
            attachments.each do |a|
              if !allowed_types.any? { |t| t === a.data_content_type } && !(a.data_content_type.nil? || a.data_content_type.blank?)
                record.add_to_base(options[:message] || "is not one of #{allowed_types.join(', ')}")
              end
            end

          end
        end

        # Define the #set_attachment_path method if you would like to override the way file_path is set.
        # A path input will be rendered for content types having #set_attachment_path.
        def handle_setting_attachment_path
          if self.respond_to? :set_attachment_path
            set_attachment_path
          else
            use_default_attachment_path
          end
        end
      end

      module ClassMethods

        # Finds all instances of this Attaching content that exist in a given section.
        # @param [Cms::Section] section
        # @return [ActiveRecord::Relation] A relation that will return Attaching instances.
        def by_section(section)
          where(["#{SectionNode.table_name}.ancestry = ?", section.node.ancestry_path])
            .includes(:attachments => :section_node)
            .references(:section_nodes)
        end

        # Defines an single attachement with a given name.
        #
        # @param [Symbol] name The name of the attachment
        # @param [Hash] options Accepts most Paperclip options for Paperclip::ClassMethods.has_attached_file
        # @see http://rubydoc.info/gems/paperclip/Paperclip/ClassMethods:has_attached_file
        def has_attachment(name, options = {})
          options[:type] = :single
          options[:index] = Cms::Attachment.definitions[self.name].size
          Cms::Attachment.definitions[self.name][name] = options

          define_method name do
            attachment_named(name)
          end
          define_method "#{name}?" do
            (attachment_named(name) != nil)
          end
        end

        # Allows multiple attachments under a specific name.
        #
        # @param [Symbol] name The name of the attachments.
        # @param [Hash] options Accepts most Paperclip options for Paperclip::ClassMethods.has_attached_file
        # @see http://rubydoc.info/gems/paperclip/Paperclip/ClassMethods:has_attached_file
        def has_many_attachments(name, options = {})
          options[:type] = :multiple
          Cms::Attachment.definitions[self.name][name] = options

          define_method name do
            attachments.named name
          end

          define_method "#{name}?" do
            !attachments.named(name).empty?
          end
        end

        # Find all attachments as of the given version for the specified block. Excludes attachments that were
        # deleted as of a version.
        #
        # @param [Integer] version_number
        # @param [Attaching] attachable The object with attachments
        # @return [Array<Cms::Attachment>]
        def attachments_as_of_version(version_number, attachable)
          found_versions = Cms::Attachment::Version.where(:attachable_id => attachable.id).
              where(:attachable_type => attachable.attachable_type).
              where(:attachable_version => version_number).
              order(:version).load
          found_attachments = []

          found_versions.each do |av|
            record = av.build_object_from_version
            found_attachments << record
          end
          found_attachments.delete_if { |value| value.deleted? }
          found_attachments
        end

        # Return all definitions for a given class.
        def definitions_for(name)
          Cms::Attachment.definitions[self.name][name]
        end
      end

      module InstanceMethods

        # This ensures that if a change is made to an attachment, that this model is also marked as changed.
        # Otherwise, if the change isn't detected, this record won't save a new version (since updates are rejected if no changes were made)
        def check_for_updated_attachments
          if attachments_changed == "true" || attachments_were_updated?
            changed_attributes['attachments'] = "Uploaded new files"
          end
        end

        def attachments_were_updated?
          attachments.each do |a|
            if a.changed?
              return true
            end
          end
          false
        end
        # Returns a list of all attachments this content type has defined.
        # @return [Array<String>] Names
        def attachment_names
          Cms::Attachment.definitions[self.class.name].keys
        end

        def after_publish
          attachments.each &:publish
        end

        # Locates the attachment with a given name
        # @param [Symbol] name The name of the attachment
        def attachment_named(name)
          attachments.select { |item| item.attachment_name.to_sym == name }.first
        end

        def unassigned_attachments
          return [] if attachment_id_list.blank?
          Cms::Attachment.find attachment_id_list.split(',').map(&:to_i)
        end

        def all_attachments
          attachments << unassigned_attachments
        end

        def attachable_type
          self.class.name
        end

        # Versioning Callback - This will result in a new version of attachments being created every time the attachable is updated.
        #   Allows a complete version history to be reconstructed.
        # @param [Versionable] new_version
        def after_build_new_version(new_version)
          attachments.each do |a|
            a.attachable_version = new_version.version
          end
        end

        # Version Callback - Reconstruct this object exactly as it was as of a particularly version
        # Called after the object is 'reset' to the specific version in question.
        def after_as_of_version()
          @attachments_as_of = self.class.attachments_as_of_version(version, self)


          # Override #attachments to return the original attachments for the current version.
          metaclass = class << self;
            self;
          end
          metaclass.send :define_method, :attachments do
            @attachments_as_of
          end
        end

        # Callback - Ensure attachments get reverted whenver a block does.
        def after_revert(version)
          version_number = version.version
          attachments.each do |a|
            a.revert_to(version_number, {:attachable_version => self.version+1})
          end
        end

        # Ensures that attachments exist for form, since it uses attachments.each to iterate over them.
        #   Design Qualm: I don't like that this method has to exist, since its basically obscuring the fact that
        #   individual attachments don't exist when an object is created.
        def ensure_attachment_exists
          attachment_names.each do |n|
            unless attachment_named(n.to_sym)
              # Can't use attachments.build because sometimes its an array
              attachments << Attachment.new(:attachment_name => n, :attachable => self)
            end
          end
        end

        # @return [Array<Cms::Attachment>]
        def multiple_attachments
          attachments.select { |a| a.cardinality == Attachment::MULTIPLE }
        end

        private

        # Saves associated attachments if they were updated. (Used in place of :autosave=>true, since the CMS Versioning API seems to break that)
        #
        # ActiveRecord Callback
        def save_associated_attachments
          logger.warn "save_associated_attachments #{attachments}"
          attachments.each do |a|
            a.save if a.changed?
          end
        end


        # Filter - Ensures that the status of all attachments matches the this block
        def ensure_status_matches_attachable
          if self.class.archivable?
            attachments.each do |a|
              a.archived = self.archived
            end
          end

          if self.class.publishable?
            attachments.each do |a|
              a.publish_on_save = self.publish_on_save
            end
          end
        end

        # Handles assigning attachments that were created via use of
        # the cms_asset manager.
        #
        # Since Attachments are created via AJAX, we need to go back and associate those with this Attaching object.
        def associate_new_attachments
          unless attachment_id_list.blank?
            ids = attachment_id_list.split(',').map(&:to_i)
            ids.each do |i|
              begin
                attachment = Cms::Attachment.find(i)
              rescue ActiveRecord::RecordNotFound
              end

              # Previously saved attachments shouldn't have an attachable_version or attachable_id yet.
              if attachment
                attachment.attachable_version = self.version
                attachments << attachment
              end

            end
          end
        end

        # We don't want errors like: Attachments is invalid showing up, since they are duplicates
        def filter_generic_attachment_errors
          filter_errors_named([:attachments])
        end

        def initialize_attachments
          attachments.each { |a| a.attachable_class = self.attachable_type }
        end


        private
        def filter_errors_named(filter_list)
          filtered_errors = self.errors.reject { |err| filter_list.include?(err.first) }

          # reset the errors collection and repopulate it with the filtered errors.
          self.errors.clear
          filtered_errors.each { |err| self.errors.add(*err) }
        end
      end
    end
  end
end