lib/cms/behaviors/attaching.rb
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