AlchemyCMS/alchemy_cms

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

Summary

Maintainability
C
1 day
Test Coverage
A
98%
# frozen_string_literal: true

# == Schema Information
#
# Table name: alchemy_pages
#
#  id               :integer          not null, primary key
#  name             :string
#  urlname          :string
#  title            :string
#  language_code    :string
#  language_root    :boolean
#  page_layout      :string
#  meta_keywords    :text
#  meta_description :text
#  lft              :integer
#  rgt              :integer
#  parent_id        :integer
#  depth            :integer
#  locked_by        :integer
#  restricted       :boolean          default(FALSE)
#  robot_index      :boolean          default(TRUE)
#  robot_follow     :boolean          default(TRUE)
#  sitemap          :boolean          default(TRUE)
#  layoutpage       :boolean          default(FALSE)
#  created_at       :datetime         not null
#  updated_at       :datetime         not null
#  creator_id       :integer
#  updater_id       :integer
#  language_id      :integer
#  cached_tag_list  :text
#  published_at     :datetime
#  public_on        :datetime
#  public_until     :datetime
#  locked_at        :datetime
#

require_dependency "alchemy/page/fixed_attributes"
require_dependency "alchemy/page/page_layouts"
require_dependency "alchemy/page/page_scopes"
require_dependency "alchemy/page/page_natures"
require_dependency "alchemy/page/page_naming"
require_dependency "alchemy/page/page_elements"

module Alchemy
  class Page < BaseRecord
    include Alchemy::Hints
    include Alchemy::Logger
    include Alchemy::Taggable

    DEFAULT_ATTRIBUTES_FOR_COPY = {
      autogenerate_elements: false,
      public_on: nil,
      public_until: nil,
      locked_at: nil,
      locked_by: nil
    }

    SKIPPED_ATTRIBUTES_ON_COPY = %w[
      id
      updated_at
      created_at
      creator_id
      updater_id
      lft
      rgt
      depth
      urlname
      cached_tag_list
    ]

    PERMITTED_ATTRIBUTES = [
      :meta_description,
      :meta_keywords,
      :name,
      :page_layout,
      :public_on,
      :public_until,
      :restricted,
      :robot_index,
      :robot_follow,
      :searchable,
      :sitemap,
      :tag_list,
      :title,
      :urlname,
      :layoutpage,
      :menu_id
    ]

    acts_as_nested_set(dependent: :destroy, scope: [:layoutpage, :language_id])

    stampable stamper_class_name: Alchemy.user_class.name

    belongs_to :language

    belongs_to :creator,
      primary_key: Alchemy.user_class_primary_key,
      class_name: Alchemy.user_class_name,
      foreign_key: :creator_id,
      optional: true

    belongs_to :updater,
      primary_key: Alchemy.user_class_primary_key,
      class_name: Alchemy.user_class_name,
      foreign_key: :updater_id,
      optional: true

    belongs_to :locker,
      primary_key: Alchemy.user_class_primary_key,
      class_name: Alchemy.user_class_name,
      foreign_key: :locked_by,
      optional: true

    has_one :site, through: :language
    has_many :site_languages, through: :site, source: :languages
    has_many :folded_pages, dependent: :destroy
    has_many :legacy_urls, class_name: "Alchemy::LegacyPageUrl", dependent: :destroy
    has_many :nodes, class_name: "Alchemy::Node", inverse_of: :page
    has_many :versions, class_name: "Alchemy::PageVersion", inverse_of: :page, dependent: :destroy
    has_one :draft_version, -> { drafts }, class_name: "Alchemy::PageVersion"
    has_one :public_version, -> { published }, class_name: "Alchemy::PageVersion", autosave: -> { persisted? }

    has_many :page_ingredients, class_name: "Alchemy::Ingredients::Page", foreign_key: :related_object_id, dependent: :nullify

    before_validation :set_language,
      if: -> { language.nil? }

    validates_presence_of :page_layout
    validates_format_of :page_layout, with: /\A[a-z0-9_-]+\z/, unless: -> { page_layout.blank? }
    validates_presence_of :parent, unless: -> { layoutpage? || language_root? }

    before_create -> { versions.build },
      if: -> { versions.none? }

    before_destroy if: -> { nodes.any? } do
      errors.add(:nodes, :still_present)
      throw(:abort)
    end

    before_save :set_language_code,
      if: -> { language.present? }

    before_save :set_restrictions_to_child_pages,
      if: :restricted_changed?

    before_save :inherit_restricted_status,
      if: -> { parent && parent.restricted? }

    before_save :set_fixed_attributes,
      if: -> { fixed_attributes.any? }

    after_update :create_legacy_url,
      if: :saved_change_to_urlname?

    after_update :touch_nodes

    # Concerns
    include PageLayouts
    include PageScopes
    include PageNatures
    include PageNaming
    include PageElements

    # site_name accessor
    delegate :name, to: :site, prefix: true, allow_nil: true

    # Class methods
    #
    class << self
      # The url_path class
      # @see Alchemy::Page::UrlPath
      def url_path_class
        @_url_path_class ||= Alchemy::Page::UrlPath
      end

      # Set a custom url path class
      #
      #     # config/initializers/alchemy.rb
      #     Alchemy::Page.url_path_class = MyPageUrlPathClass
      #
      def url_path_class=(klass)
        @_url_path_class = klass
      end

      def alchemy_resource_filters
        [
          {
            name: :by_page_layout,
            values: PageLayout.all.map { |p| [Alchemy.t(p["name"], scope: "page_layout_names"), p["name"]] }
          },
          {
            name: :status,
            values: %w[published not_public restricted]
          }
        ]
      end

      def searchable_alchemy_resource_attributes
        %w[name urlname title]
      end

      # @return the language root page for given language id.
      # @param language_id [Fixnum]
      #
      def language_root_for(language_id)
        language_roots.find_by_language_id(language_id)
      end

      # Creates a copy of given source.
      #
      # Also copies all elements included in source.
      #
      # === Note:
      #
      # It prevents the element auto generator from running.
      #
      # @param source [Alchemy::Page]
      #   The source page the copy is taken from
      # @param differences [Hash]
      #   A optional hash with attributes that take precedence over the source attributes
      #
      # @return [Alchemy::Page]
      #
      def copy(source, differences = {})
        Alchemy::CopyPage.new(page: source).call(changed_attributes: differences)
      end

      def copy_and_paste(source, new_parent, new_name)
        page = Alchemy::CopyPage.new(page: source)
          .call(changed_attributes: {
            parent: new_parent,
            language: new_parent&.language,
            name: new_name,
            title: new_name
          })
        if source.children.any?
          source.copy_children_to(page)
        end
        page
      end

      def all_from_clipboard(clipboard)
        return [] if clipboard.blank?

        where(id: clipboard.collect { |p| p["id"] })
      end

      def all_from_clipboard_for_select(clipboard, language_id, layoutpages: false)
        return [] if clipboard.blank?

        clipboard_pages = all_from_clipboard(clipboard)
        allowed_page_layouts = Alchemy::Page.selectable_layouts(language_id, layoutpages: layoutpages)
        allowed_page_layout_names = allowed_page_layouts.collect { |p| p["name"] }
        clipboard_pages.select { |cp| allowed_page_layout_names.include?(cp.page_layout) }
      end

      def link_target_options
        options = [[Alchemy.t(:default, scope: "link_target_options"), ""]]
        link_target_options = Config.get(:link_target_options)
        link_target_options.each do |option|
          # add an underscore to the options to provide the default syntax
          # @link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target
          options << [Alchemy.t(option, scope: "link_target_options",
            default: option.to_s.humanize), "_#{option}"]
        end
        options
      end
    end

    # Instance methods
    #

    # Returns elements from pages public version.
    #
    # You can pass another page_version to load elements from in the options.
    #
    # @option options [Array<String>|String] :only
    #   Returns only elements with given names
    # @option options [Array<String>|String] :except
    #   Returns all elements except the ones with given names
    # @option options [Integer] :count
    #   Limit the count of returned elements
    # @option options [Integer] :offset
    #   Starts with an offset while returning elements
    # @option options [Boolean] :include_hidden (false)
    #   Return hidden elements as well
    # @option options [Boolean] :random (false)
    #   Return elements randomly shuffled
    # @option options [Boolean] :reverse (false)
    #   Reverse the load order
    # @option options [Class] :finder (Alchemy::ElementsFinder)
    #   A class that will return elements from page.
    #   Use this for your custom element loading logic.
    # @option options [Alchemy::PageVersion] :page_version
    #   A page version to load elements from.
    #   Uses the pages public_version by default.
    #
    # @return [ActiveRecord::Relation]
    def find_elements(options = {})
      finder = options[:finder] || Alchemy::ElementsFinder.new(options)
      finder.elements(page_version: options[:page_version] || public_version)
    end

    # = The url_path for this page
    #
    # @see Alchemy::Page::UrlPath#call
    def url_path
      self.class.url_path_class.new(self).call
    end

    # The page's view partial is dependent from its page layout
    #
    # == Define page layouts
    #
    # Page layouts are defined in the +config/alchemy/page_layouts.yml+ file
    #
    #     - name: contact
    #       elements: [contactform]
    #       ...
    #
    # == Override the view
    #
    # Page layout partials live in +app/views/alchemy/page_layouts+
    #
    def to_partial_path
      "alchemy/page_layouts/#{layout_partial_name}"
    end

    # Returns the previous page on the same level or nil.
    #
    # @option options [Boolean] :restricted (false)
    #   only restricted pages (true), skip restricted pages (false)
    # @option options [Boolean] :public (true)
    #   only public pages (true), skip public pages (false)
    #
    def previous(options = {})
      pages = self_and_siblings.where("lft < ?", lft)
      select_page(pages, options.merge(order: :desc))
    end

    alias_method :previous_page, :previous

    # Returns the next page on the same level or nil.
    #
    # @option options [Boolean] :restricted (false)
    #   only restricted pages (true), skip restricted pages (false)
    # @option options [Boolean] :public (true)
    #   only public pages (true), skip public pages (false)
    #
    def next(options = {})
      pages = self_and_siblings.where("lft > ?", lft)
      select_page(pages, options.merge(order: :asc))
    end

    alias_method :next_page, :next

    # Locks the page to given user
    #
    def lock_to!(user)
      update_columns(locked_at: Time.current, locked_by: user.id)
    end

    # Unlocks the page without updating the timestamps
    #
    def unlock!
      if update_columns(locked_at: nil, locked_by: nil)
        Current.preview_page = nil
      end
    end

    def fold!(user_id, status)
      folded_page = folded_pages.find_or_create_by(user_id: user_id)
      folded_page.folded = status
      folded_page.save!
    end

    def set_restrictions_to_child_pages
      descendants.each do |child|
        child.update(restricted: restricted?)
      end
    end

    def inherit_restricted_status
      self.restricted = parent.restricted?
    end

    # Returns the first published child
    def first_public_child
      children.published.first
    end

    # Gets the language_root page for page
    def get_language_root
      self_and_ancestors.find_by(language_root: true)
    end

    def copy_children_to(new_parent)
      children.each do |child|
        next if child == new_parent

        new_child = Alchemy::CopyPage.new(page: child).call(changed_attributes: {
          parent_id: new_parent.id,
          language_id: new_parent.language_id,
          language_code: new_parent.language_code
        })
        new_child.move_to_child_of(new_parent)
        child.copy_children_to(new_child) unless child.children.blank?
      end
    end

    # Creates a public version of the page in the background.
    #
    def publish!(current_time = Time.current)
      PublishPageJob.perform_later(id, public_on: current_time)
    end

    # Sets the public_on date on the published version
    #
    # Builds a new version if none exists yet.
    # Destroys public version if empty time is set
    #
    def public_on=(time)
      if public_version && time.blank?
        public_version.destroy!
        # Need to reset the public version on the instance so we do not need to reload
        self.public_version = nil
      elsif public_version
        public_version.public_on = time
      elsif time.present?
        versions.build(public_on: time)
      end
    end

    delegate :public_until=, to: :public_version, allow_nil: true

    # Holds an instance of +FixedAttributes+
    def fixed_attributes
      @_fixed_attributes ||= FixedAttributes.new(self)
    end

    # True if given attribute name is defined as fixed
    def attribute_fixed?(name)
      fixed_attributes.fixed?(name)
    end

    # Checks the current page's list of editors, if defined.
    #
    # This allows us to pass in a user and see if any of their roles are enable
    # them to make edits
    #
    def editable_by?(user)
      return true unless has_limited_editors?

      (editor_roles & user.alchemy_roles).any?
    end

    # Returns the value of +public_on+ attribute from public version
    #
    # If it's a fixed attribute then the fixed value is returned instead
    #
    def public_on
      attribute_fixed?(:public_on) ? fixed_attributes[:public_on] : public_version&.public_on
    end

    # Returns the value of +public_until+ attribute
    #
    # If it's a fixed attribute then the fixed value is returned instead
    #
    def public_until
      attribute_fixed?(:public_until) ? fixed_attributes[:public_until] : public_version&.public_until
    end

    # Returns the name of the creator of this page.
    #
    # If no creator could be found or associated user model
    # does not respond to +#name+ it returns +'unknown'+
    #
    def creator_name
      creator.try(:alchemy_display_name) || Alchemy.t("unknown")
    end

    # Returns the name of the last updater of this page.
    #
    # If no updater could be found or associated user model
    # does not respond to +#name+ it returns +'unknown'+
    #
    def updater_name
      updater.try(:alchemy_display_name) || Alchemy.t("unknown")
    end

    # Returns the name of the user currently editing this page.
    #
    # If no locker could be found or associated user model
    # does not respond to +#name+ it returns +'unknown'+
    #
    def locker_name
      locker.try(:alchemy_display_name) || Alchemy.t("unknown")
    end

    # Key hint translations by page layout, rather than the default name.
    #
    def hint_translation_attribute
      page_layout
    end

    # Menus (aka. root nodes) this page is attached to
    #
    def menus
      @_menus ||= nodes.map(&:root)
    end

    private

    def set_fixed_attributes
      fixed_attributes.all.each do |attribute, value|
        send(:"#{attribute}=", value)
      end
    end

    def select_page(pages, options = {})
      pages = options.fetch(:public, true) ? pages.published : pages.not_public
      pages.where(restricted: options.fetch(:restricted, false))
        .reorder(lft: options.fetch(:order))
        .limit(1).first
    end

    def set_language
      self.language = parent&.language || Current.language
      set_language_code
    end

    def set_language_code
      self.language_code = language.code
    end

    # Stores the old urlname in a LegacyPageUrl
    def create_legacy_url
      legacy_urls.find_or_create_by(urlname: urlname_before_last_save)
    end

    def touch_nodes
      ids = node_ids + nodes.flat_map { |n| n.ancestors.pluck(:id) }
      Node.where(id: ids).touch_all
    end
  end
end