# 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_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, :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 has_many :legacy_urls, class_name: "Alchemy::LegacyPageUrl" 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? } 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 PageScopes include PageNatures include PageNaming include PageElements # site_name accessor delegate :name, to: :site, prefix: true, allow_nil: true # Old public_on and public_until attributes for historical reasons # # These attributes now exist on the page versions # attr_readonly :legacy_public_on, :legacy_public_until deprecate :legacy_public_on, deprecator: Alchemy::Deprecation deprecate :legacy_public_until, deprecator: Alchemy::Deprecation # 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 # Used to store the current page id previewed in the edit page template. # def current_preview=(page) RequestStore.store[:alchemy_current_preview] = page&.id end # Returns the current page id previewed in the edit page template. # def current_preview RequestStore.store[:alchemy_current_preview] 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 = {}) transaction do page = Alchemy::Page.new(attributes_from_source_for_copy(source, differences)) page.tag_list = source.tag_list if page.save! copy_elements(source, page) page end end end def copy_and_paste(source, new_parent, new_name) page = copy(source, { 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, layoutpage = false) return [] if clipboard.blank? clipboard_pages = all_from_clipboard(clipboard) allowed_page_layouts = Alchemy::PageLayout.selectable_layouts(language_id, layoutpage) 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| options << [Alchemy.t(option, scope: "link_target_options", default: option.to_s.humanize), option] end options end private # Aggregates the attributes from given source for copy of page. # # @param [Alchemy::Page] # The source page # @param [Hash] # A optional hash with attributes that take precedence over the source attributes # def attributes_from_source_for_copy(source, differences = {}) source.attributes.stringify_keys! differences.stringify_keys! attributes = source.attributes.merge(differences) attributes.merge!(DEFAULT_ATTRIBUTES_FOR_COPY) attributes["name"] = new_name_for_copy(differences["name"], source.name) attributes.except(*SKIPPED_ATTRIBUTES_ON_COPY) end # Returns a new name for copy of page. # # If the differences hash includes a new name this is taken. # Otherwise +source.name+ # # @param [String] # The differences hash that contains a new name # @param [String] # The name of the source # def new_name_for_copy(custom_name, source_name) return custom_name if custom_name.present? "#{source_name} (#{Alchemy.t("Copy")})" 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] :only # Returns only elements with given names # @option options [Array|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) Page.current_preview = 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 = Page.copy(child, { 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 # Updates an Alchemy::Page based on a new ordering to be applied to it # # Note: Page's urls should not be updated (and a legacy URL created) if nesting is OFF # or if the URL is the same # # @param [TreeNode] # A tree node with new lft, rgt, depth, url, parent_id and restricted indexes to be updated # def update_node!(node) hash = { lft: node.left, rgt: node.right, parent_id: node.parent, depth: node.depth, restricted: node.restricted } if urlname != node.url LegacyPageUrl.create(page_id: id, urlname: urlname) hash[:urlname] = node.url end update_columns(hash) end # 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(: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(: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(: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 || Language.current 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