AlchemyCMS/alchemy_cms

View on GitHub
app/models/alchemy/picture.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
96%
# frozen_string_literal: true

# == Schema Information
#
# Table name: alchemy_pictures
#
#  id                :integer          not null, primary key
#  name              :string
#  image_file_name   :string
#  image_file_width  :integer
#  image_file_height :integer
#  created_at        :datetime         not null
#  updated_at        :datetime         not null
#  creator_id        :integer
#  updater_id        :integer
#  upload_hash       :string
#  cached_tag_list   :text
#  image_file_uid    :string
#  image_file_size   :integer
#  image_file_format :string
#

module Alchemy
  class Picture < BaseRecord
    THUMBNAIL_SIZES = {
      small: "80x60",
      medium: "160x120",
      large: "240x180"
    }.with_indifferent_access.freeze

    CONVERTIBLE_FILE_FORMATS = %w[gif jpg jpeg png webp].freeze

    TRANSFORMATION_OPTIONS = [
      :crop,
      :crop_from,
      :crop_size,
      :flatten,
      :format,
      :quality,
      :size,
      :upsample
    ]

    include Alchemy::Logger
    include Alchemy::NameConversions
    include Alchemy::Taggable
    include Alchemy::TouchElements
    include Calculations

    has_many :picture_ingredients,
      class_name: "Alchemy::Ingredients::Picture",
      foreign_key: "related_object_id",
      inverse_of: :related_object

    has_many :elements, through: :picture_ingredients
    has_many :pages, through: :elements
    has_many :thumbs, class_name: "Alchemy::PictureThumb", dependent: :destroy
    has_many :descriptions, class_name: "Alchemy::PictureDescription", dependent: :destroy

    accepts_nested_attributes_for :descriptions, allow_destroy: true, reject_if: ->(attr) { attr[:text].blank? }

    # Raise error, if picture is in use (aka. assigned to an Picture ingredient)
    #
    # === CAUTION
    #
    # This HAS to be placed for Dragonfly's class methods,
    # to ensure this runs before Dragonfly's before_destroy callback.
    #
    before_destroy unless: :deletable? do
      raise PictureInUseError, Alchemy.t(:cannot_delete_picture_notice) % {name: name}
    end

    # Image preprocessing class
    def self.preprocessor_class
      @_preprocessor_class ||= Preprocessor
    end

    # Set a image preprocessing class
    #
    #     # config/initializers/alchemy.rb
    #     Alchemy::Picture.preprocessor_class = My::ImagePreprocessor
    #
    def self.preprocessor_class=(klass)
      @_preprocessor_class = klass
    end

    # Enables Dragonfly image processing
    dragonfly_accessor :image_file, app: :alchemy_pictures do
      # Preprocess after uploading the picture
      after_assign do |image|
        if has_convertible_format?
          self.class.preprocessor_class.new(image).call
        end
      end
    end

    # Create important thumbnails upfront
    after_create -> { PictureThumb.generate_thumbs!(self) if has_convertible_format? }

    # We need to define this method here to have it available in the validations below.
    class << self
      def allowed_filetypes
        Config.get(:uploader).fetch("allowed_filetypes", {}).fetch("alchemy/pictures", [])
      end
    end

    validates_presence_of :image_file
    validates_size_of :image_file, maximum: Config.get(:uploader)["file_size_limit"].megabytes
    validates_property :format,
      of: :image_file,
      in: allowed_filetypes,
      case_sensitive: false,
      message: Alchemy.t("not a valid image")

    stampable stamper_class_name: Alchemy.user_class.name

    scope :named, ->(name) { where("#{table_name}.name LIKE ?", "%#{name}%") }
    scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
    scope :deletable,
      -> {
        where("#{table_name}.id NOT IN (SELECT related_object_id FROM alchemy_ingredients WHERE related_object_type = 'Alchemy::Picture')")
      }
    scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) }
    scope :by_file_format, ->(format) { where(image_file_format: format) }

    # Class methods

    class << self
      # The class used to generate URLs for pictures
      #
      # @see Alchemy::Picture::Url
      def url_class
        @_url_class ||= Alchemy::Picture::Url
      end

      # Set a different picture url class
      #
      # @see Alchemy::Picture::Url
      def url_class=(klass)
        @_url_class = klass
      end

      def alchemy_resource_filters
        @_file_formats ||= distinct.pluck(:image_file_format).compact.presence || []
        [
          {
            name: :by_file_format,
            values: @_file_formats
          },
          {
            name: :misc,
            values: %w[recent last_upload without_tag deletable]
          }
        ]
      end

      def searchable_alchemy_resource_attributes
        %w[name image_file_name]
      end

      def last_upload
        last_picture = Picture.last
        return Picture.all unless last_picture

        Picture.where(upload_hash: last_picture.upload_hash)
      end
    end

    # Instance methods

    # Returns an url (or relative path) to a processed image for use inside an image_tag helper.
    #
    # Any additional options are passed to the url method, so you can add params to your url.
    #
    # Example:
    #
    #   <%= image_tag picture.url(size: '320x200', format: 'png') %>
    #
    # @see Alchemy::PictureVariant#call for transformation options
    # @see Alchemy::Picture::Url#call for url options
    # @return [String|Nil]
    def url(options = {})
      return unless image_file

      variant = PictureVariant.new(self, options.slice(*TRANSFORMATION_OPTIONS))
      self.class.url_class.new(variant).call(
        options.except(*TRANSFORMATION_OPTIONS).merge(
          basename: name,
          ext: variant.render_format,
          name: name
        )
      )
    rescue ::Dragonfly::Job::Fetch::NotFound => e
      log_warning(e.message)
      nil
    end

    # Returns an url for the thumbnail representation of the picture
    #
    # @param [String] size - The size of the thumbnail
    #
    # @return [String]
    def thumbnail_url(size: "160x120")
      return if image_file.nil?

      url(
        flatten: true,
        format: image_file_format || "jpg",
        size: size
      )
    end

    # Updates name and tag_list attributes.
    #
    # Used by +Admin::PicturesController#update_multiple+
    #
    # Note: Does not delete name value, if the form field is blank.
    #
    def update_name_and_tag_list!(params)
      if params[:pictures_name].present?
        self.name = params[:pictures_name]
      end
      self.tag_list = params[:pictures_tag_list]
      save!
    end

    # Returns the picture description for a given language.
    def description_for(language)
      descriptions.find_by(language: language)&.text
    end

    # Returns an uri escaped name.
    #
    def urlname
      if name.blank?
        "image_#{id}"
      else
        ::CGI.escape(name.gsub(/\.(gif|png|jpe?g|tiff?)/i, "").tr(".", " "))
      end
    end

    # Returns the suffix of the filename.
    #
    def suffix
      image_file.ext
    end

    # Returns a humanized, readable name from image filename.
    #
    def humanized_name
      return "" if image_file_name.blank?

      convert_to_humanized_name(image_file_name, suffix)
    end

    # Returns the format the image should be rendered with
    #
    # Only returns a format differing from original if an +image_output_format+
    # is set in config and the image has a convertible file format.
    #
    def default_render_format
      if convertible?
        Config.get(:image_output_format)
      else
        image_file_format
      end
    end

    # Returns true if the image can be converted
    #
    # If the +image_output_format+ is set to +nil+ or +original+ or the
    # image has not a convertible file format (i.e. SVG) this returns +false+
    #
    def convertible?
      Config.get(:image_output_format) &&
        Config.get(:image_output_format) != "original" &&
        has_convertible_format?
    end

    # Returns true if the image can be converted into other formats
    #
    def has_convertible_format?
      image_file_format.in?(CONVERTIBLE_FILE_FORMATS)
    end

    # Checks if the picture is restricted.
    #
    # A picture is only restricted if it's assigned on restricted pages only.
    #
    # Once a picture is assigned on a not restricted page,
    # it is considered public and therefore not restricted any more,
    # even if it is also assigned on a restricted page.
    #
    def restricted?
      pages.any? && pages.not_restricted.blank?
    end

    # Returns true if picture is not assigned to any Picture ingredient.
    #
    def deletable?
      picture_ingredients.empty?
    end

    # A size String from original image file values.
    #
    # == Example
    #
    # 200 x 100
    #
    def image_file_dimensions
      "#{image_file_width}x#{image_file_height}"
    end
  end
end