Noosfero/noosfero

View on GitHub
app/models/article.rb

Summary

Maintainability
F
4 days
Test Coverage
class Article < ApplicationRecord
  module Editor
    TEXTILE = "textile"
    TINY_MCE = "tiny_mce"
    RAW_HTML = "raw_html"
  end

  include SanitizeHelper
  include SanitizeTags
  include Entitlement::SliderHelper
  include Entitlement::ArticleJudge

  attr_accessible :name, :body, :abstract, :profile, :tag_list, :parent,
                  :allow_members_to_edit, :translation_of_id, :language,
                  :license_id, :parent_id, :display_posts_in_current_language,
                  :category_ids, :posts_per_page, :moderate_comments,
                  :accept_comments, :feed, :published, :source, :source_name,
                  :highlighted, :notify_comments, :display_hits, :slug,
                  :external_feed_builder, :display_versions, :external_link,
                  :image_builder, :show_to_followers, :archived,
                  :author, :display_preview, :published_at, :person_followers,
                  :editor, :metadata, :position, :access

  extend ActsAsHavingImage::ClassMethods
  acts_as_having_image
  acts_as_list scope: :profile

  include Noosfero::Plugin::HotSpot

  SEARCHABLE_FIELDS = {
    name: { label: _("Name"), weight: 10 },
    abstract: { label: _("Abstract"), weight: 3 },
    body: { label: _("Content"), weight: 2 },
    slug: { label: _("Slug"), weight: 1 },
    filename: { label: _("Filename"), weight: 1 },
  }

  SEARCH_FILTERS = {
    order: %w[more_recent more_popular more_comments more_relevant],
    display: %w[full]
  }

  N_("article")

  def self.inherited(subclass)
    subclass.prepend StringTemplate
    super
  end

  def initialize(*params)
    super
    if params.present? && params.first.present?
      if params.first.symbolize_keys.has_key?(:published)
        self.published = params.first.symbolize_keys[:published]
      end
    end
  end

  def self.default_search_display
    "full"
  end

  # FIXME This is necessary because html is being generated on the model...
  include ActionView::Helpers::TagHelper

  # use for internationalizable human type names in search facets
  # reimplement on subclasses
  def self.type_name
    _("Content")
  end

  track_actions :create_article, :after_create, keep_params: [:name, :title, :url, :lead, :first_image], if: Proc.new { |a| a.notifiable? }

  before_create do |article|
    if article.author
      article.author_name = article.author.name
    end
  end

  belongs_to :profile, optional: true
  validates_presence_of :profile_id, :name
  validates_presence_of :slug, :path, if: lambda { |article| !article.name.blank? }

  validates_length_of :name, maximum: 150
  validate :validate_custom_fields

  before_save :sanitize_custom_field_keys

  validates_uniqueness_of :slug, scope: ["profile_id", "parent_id"], message: N_("The title (article name) is already being used by another article, please use another title."), if: lambda { |article| !article.slug.blank? }

  belongs_to :author, class_name: "Person", optional: true
  belongs_to :last_changed_by, class_name: "Person", foreign_key: "last_changed_by_id", optional: true
  belongs_to :created_by, class_name: "Person", foreign_key: "created_by_id", optional: true

  has_many :comments, -> { order "created_at asc" }, class_name: "Comment", as: "source", dependent: :destroy

  has_many :article_followers, dependent: :destroy
  has_many :person_followers, class_name: "Person", through: :article_followers, source: :person
  has_many :person_followers_emails, -> { select :email }, class_name: "User", through: :person_followers, source: :user

  has_many :article_categorizations, -> { where "articles_categories.virtual = ?", false }
  has_many :categories, through: :article_categorizations

  has_many :article_categorizations_including_virtual, class_name: "ArticleCategorization"
  has_many :categories_including_virtual, through: :article_categorizations_including_virtual, source: :category

  extend ActsAsHavingSettings::ClassMethods
  acts_as_having_settings field: :setting

  store_accessor :metadata
  include MetadataScopes

  settings_items :display_hits, type: :boolean, default: true
  settings_items :author_name, type: :string, default: ""
  settings_items :allow_members_to_edit, type: :boolean, default: false
  settings_items :moderate_comments, type: :boolean, default: false
  has_and_belongs_to_many :article_privacy_exceptions, class_name: "Person", join_table: "article_privacy_exceptions"

  belongs_to :reference_article, class_name: "Article", foreign_key: "reference_article_id", optional: true

  belongs_to :license, optional: true

  has_many :translations, class_name: "Article", foreign_key: :translation_of_id
  belongs_to :translation_of, class_name: "Article", foreign_key: :translation_of_id, optional: true
  before_destroy :rotate_translations

  acts_as_voteable

  before_create do |article|
    article.published_at ||= Time.now
    if article.reference_article && !article.parent
      parent = article.reference_article.parent
      if parent && parent.blog? && article.profile.has_blog?
        article.parent = article.profile.blog
      end
    end

    if article.created_by
      article.author_name = article.created_by.name
    end
  end

  after_destroy :destroy_activity
  def destroy_activity
    self.activity.destroy if self.activity
  end

  after_destroy :destroy_link_article
  def destroy_link_article
    Article.where(reference_article_id: self.id, type: "LinkArticle").destroy_all
  end

  xss_terminate only: [:name], on: :validation, with: :white_list

  scope :in_category, ->category {
    includes("categories_including_virtual").where("categories.id" => category.id)
  }

  scope :relevant_as_recent, -> {
    where "(articles.type != 'UploadedFile' and articles.type != 'RssFeed' and
            articles.type != 'Gallery' and articles.type != 'Blog') OR
           articles.type is NULL"
  }

  include TimeScopes

  scope :by_range, ->range {
    where "articles.published_at BETWEEN :start_date AND :end_date", start_date: range.first, end_date: range.last
  }

  URL_FORMAT = /\A(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(([0-9]{1,5})?\/.*)?\Z/ix

  validates_format_of :external_link, with: URL_FORMAT, if: lambda { |article| !article.external_link.blank? }
  validate :known_language
  validate :used_translation
  validate :native_translation_must_have_language
  validate :translation_must_have_language

  validate :no_self_reference
  validate :no_cyclical_reference, if: "parent_id.present?"

  validate :parent_archived?

  validate :valid_slug
  validate :access_value

  RESERVED_SLUGS = %w[
    about
    activities
  ]

  def valid_slug
    errors.add(:title, _("is not available as article name.")) unless Article.is_slug_available?(slug)
  end

  def access_value
    if profile.present? && access < profile.access
      self.errors.add(:access, _("can not be less restrictive than this profile access which is: %s.") % Entitlement::Levels.label(profile.access, profile))
    end
  end

  def self.is_slug_available?(slug)
    !RESERVED_SLUGS.include?(slug)
  end

  def no_self_reference
    errors.add(:parent_id, _("self-reference is not allowed.")) if id && parent_id == id
  end

  def no_cyclical_reference
    current_parent = Article.find(parent_id)
    while current_parent
      if current_parent == self
        errors.add(:parent_id, _("cyclical reference is not allowed."))
        break
      end
      current_parent = current_parent.parent
    end
  end

  def external_link=(link)
    if !link.blank? && link !~ /^[a-z]+:\/\//i
      link = "http://" + link
    end
    self[:external_link] = link
  end

  def action_tracker_target
    self.profile
  end

  def self.human_attribute_name_with_customization(attrib, options = {})
    case attrib.to_sym
    when :name
      _("Title")
    else
      _(self.human_attribute_name_without_customization(attrib))
    end
  end
  class << self
    alias_method :human_attribute_name_without_customization, :human_attribute_name
    alias_method :human_attribute_name, :human_attribute_name_with_customization
  end

  def css_class_list
    [self.class.name.to_css_class]
  end

  def css_class_name
    [css_class_list].flatten.compact.join(" ")
  end

  def pending_categorizations
    @pending_categorizations ||= []
  end

  def add_category(c)
    if new_record?
      pending_categorizations << c
    else
      ArticleCategorization.add_category_to_article(c, self)
      self.categories
    end
  end

  def category_ids=(ids)
    ArticleCategorization.remove_all_for(self)
    ids.uniq.each do |item|
      add_category(Category.find(item)) unless item.to_i.zero?
    end
    self.categories
  end

  after_create :create_pending_categorizations
  def create_pending_categorizations
    pending_categorizations.each do |item|
      ArticleCategorization.add_category_to_article(item, self)
    end
    self.categories
    pending_categorizations.clear
  end

  acts_as_taggable
  N_("Tag list")

  extend ActsAsFilesystem::ActsMethods
  acts_as_filesystem

  acts_as_versioned
  self.non_versioned_columns << "setting"

  def version_condition_met?
    (["name", "body", "abstract", "filename", "start_date", "end_date", "image_id", "license_id"] & changed).length > 0
  end

  def comment_data
    comments.map { |item| [item.title, item.body].join(" ") }.join(" ")
  end

  before_update do |article|
    article.advertise = true
  end

  before_save do |article|
    article.parent = article.parent_id ? Article.find(article.parent_id) : nil
    parent_path = article.parent ? article.parent.path : nil
    article.path = [parent_path, article.slug].compact.join("/")
  end

  # retrieves all articles belonging to the given +profile+ that are not
  # sub-articles of any other article.
  scope :top_level_for, ->profile {
    where "parent_id is null and profile_id = ?", profile.id
  }

  # retrives the most commented articles, sorted by the comment count (largest
  # first)
  def self.most_commented(limit)
    order("comments_count DESC").paginate(page: 1, per_page: limit)
  end

  # produces the HTML code that is to be displayed as this article's contents.
  #
  # The implementation in this class just provides the +body+ attribute as the
  # HTML.  Other article types can override this method to provide customized
  # views of themselves.
  # (To override short format representation, override the lead method)
  def to_html(options = {})
    if options[:format] == "short"
      article = self
      proc do
        display_short_format(article)
      end
    else
      body || ""
    end
  end

  # returns the data of the article. Must be overridden in each subclass to
  # provide the correct content for the article.
  def data
    body
  end

  # provides the icon name to be used for this article. In this class this
  # method just returns 'text-html', but subclasses may (and should) override
  # to return their specific icons.
  #
  # FIXME use mime_type and generate this name dinamically
  def self.icon_name(article = nil)
    "text-html"
  end

  # TODO Migrate the class method icon_name to instance methods.
  def icon_name
    self.class.icon_name(self)
  end

  def mime_type
    "text/html"
  end

  def mime_type_description
    _("HTML Text document")
  end

  def self.description
    raise NotImplementedError, "#{self} does not implement #description"
  end

  def self.short_description
    raise NotImplementedError, "#{self} does not implement #short_description"
  end

  def title
    name
  end

  include ActionView::Helpers::TextHelper
  def short_title
    truncate self.title, length: 15, omission: "..."
  end

  def belongs_to_blog?
    self.parent && self.parent.blog?
  end

  def belongs_to_forum?
    self.parent && self.parent.forum?
  end

  def person_followers_email_list
    person_followers_emails.map { |p| p.email }
  end

  def info_from_last_update
    last_comment = comments.last
    if last_comment
      { date: last_comment.created_at, author_name: last_comment.author_name, author_url: last_comment.author_url }
    else
      { date: updated_at, author_name: author_name, author_url: author_url }
    end
  end

  def full_path
    profile.hostname.blank? ? "/#{profile.identifier}/#{path}" : "/#{path}"
  end

  def url
    @url ||= self.profile.url.merge(page: path.split("/"))
  end

  def page_path
    path.split("/")
  end

  def view_url
    @view_url ||= is_a?(UploadedFile) ? url.merge(view: true) : url
  end

  def comment_url_structure(comment, action = :edit)
    if comment.new_record?
      profile.url.merge(page: path.split("/"), controller: :comment, action: :create)
    else
      profile.url.merge(page: path.split("/"), controller: :comment, action: action || :edit, id: comment.id)
    end
  end

  def allow_children?
    true
  end

  def has_posts?
    false
  end

  def download?(view = nil)
    false
  end

  def is_followed_by?(user)
    self.person_followers.include? user
  end

  def download_disposition
    "inline"
  end

  def download_headers
    { filename: filename, type: mime_type, disposition: download_disposition }
  end

  def alternate_languages
    self.translations.map(&:language)
  end

  scope :native_translations, -> { where translation_of_id: nil }

  def translatable?
    false
  end

  def native_translation
    self.translation_of.nil? ? self : self.translation_of
  end

  def possible_translations
    possibilities = environment.locales.keys - self.native_translation.translations.map(&:language) - [self.native_translation.language]
    possibilities << self.language unless self.language_changed?
    possibilities
  end

  def known_language
    unless self.language.blank?
      errors.add(:language, N_("Language not supported by the environment.")) unless environment.locales.key?(self.language)
    end
  end

  def used_translation
    unless self.language.blank? || self.translation_of.nil?
      errors.add(:language, N_("Language is already used")) unless self.possible_translations.include?(self.language)
    end
  end

  def translation_must_have_language
    unless self.translation_of.nil?
      errors.add(:language, N_("Language must be chosen")) if self.language.blank?
    end
  end

  def native_translation_must_have_language
    unless self.translation_of.nil?
      errors.add(:base, N_("A language must be chosen for the native article")) if self.translation_of.language.blank?
    end
  end

  def rotate_translations
    unless self.translations.empty?
      rotate = self.translations.to_a
      root = rotate.shift
      root.update_attribute(:translation_of_id, nil)
      root.translations = rotate
    end
  end

  def get_translation_to(locale)
    if self.language.nil? || self.language.blank? || self.language == locale
      self
    elsif self.native_translation.language == locale
      self.native_translation
    else
      self.native_translation.translations.where(language: locale).first
    end
  end

  def published?
    published && (parent.blank? || parent.published?)
  end

  def archived?
    (self.parent && self.parent.archived) || self.archived
  end

  def self.folder_types
    ["Folder", "Blog", "Forum", "Gallery"]
  end

  scope :published, -> { where "articles.published = ?", true }
  scope :folders, ->profile { where "articles.type IN (?)", profile.folder_types }
  scope :no_folders, ->profile { where "articles.type NOT IN (?)", profile.folder_types }
  scope :top_folders, ->profile {
                        where "articles.type IN (?) and profile_id = ? and " +
                              "parent_id IS NULL", profile.folder_types, profile
                      }
  scope :subfolders, ->profile, parent {
                       where "articles.type IN (?) and profile_id = ? and " +
                             "parent_id = ?", profile.folder_types, profile, parent
                     }
  scope :galleries, -> { where "articles.type IN ('Gallery')" }
  scope :images, -> { where is_image: true }
  scope :no_images, -> { where is_image: false }
  scope :files, -> { where type: "UploadedFile" }
  scope :no_files, -> { where "type != 'UploadedFile'" }
  scope :with_types, ->types { where "articles.type IN (?)", types }

  scope :more_popular, -> { order "articles.hits DESC" }
  scope :more_comments, -> { order "articles.comments_count DESC" }
  scope :more_recent, -> { order "articles.created_at DESC, articles.id DESC" }

  scope :news, ->profile, limit, highlight {
    no_folders(profile)
      .no_files
      .where(highlighted: highlight)
      .limit(limit)
      .order("articles.metadata->'order' NULLS FIRST, published_at DESC")
  }

  def allow_post_content?(user = nil)
    return true if allow_edit_topic?(user)

    user && profile.allow_post_content?(user)
  end

  def allow_view_private_content?(user = nil)
    user && user.has_permission?("view_private_content", profile)
  end

  alias :allow_delete? :allow_post_content?

  def allow_spread?(user = nil)
    user
  end

  def allow_create?(user)
    allow_post_content?(user)
  end

  def allow_edit?(user)
    return true if allow_edit_topic?(user)

    allow_post_content?(user) || user && allow_members_to_edit && user.is_member_of?(profile)
  end

  def allow_edit_topic?(user)
    self.belongs_to_forum? && (user == author) && user.present? && user.is_member_of?(profile)
  end

  def moderate_comments?
    moderate_comments == true
  end

  def comments_updated
    solr_save
  end

  def accept_category?(cat)
    true
  end

  def copy_without_save(options = {})
    attrs = attributes.reject! { |key, value| ATTRIBUTES_NOT_COPIED.include?(key.to_sym) }
    attrs.merge!(options)
    object = self.class.new
    attrs.each do |key, value|
      object.send(key.to_s + "=", value)
    end
    object
  end

  def copy(options = {})
    object = copy_without_save(options)
    object.save
    object
  end

  def copy!(options = {})
    object = copy_without_save(options)
    object.save!
    object
  end

  ATTRIBUTES_NOT_COPIED = [
    :id,
    :profile_id,
    :parent_id,
    :path,
    :slug,
    :updated_at,
    :created_at,
    :version,
    :lock_version,
    :type,
    :children_count,
    :comments_count,
    :hits,
    :translation_of_id,
  ]

  def self.find_by_old_path(old_path)
    self.includes(:versions).where("article_versions.path = ?", old_path).order("article_versions.id DESC").first
  end

  def hit
    if !archived?
      self.class.connection.execute("update articles set hits = hits + 1 where id = %d" % self.id.to_i)
      self.hits += 1
    end
  end

  def self.hit(articles)
    ids = []
    articles.each do |article|
      if !article.archived?
        ids << article.id
        article.hits += 1
      end
    end
    Article.where(id: ids).update_all("hits = hits + 1") if !ids.empty?
  end

  def can_display_hits?
    true
  end

  def display_hits?
    can_display_hits? && display_hits
  end

  def display_media_panel?
    can_display_media_panel? && environment.enabled?("media_panel")
  end

  def can_display_media_panel?
    false
  end

  settings_items :display_preview, type: :boolean, default: false

  def display_preview?
    false
  end

  def image?
    false
  end

  def event?
    false
  end

  def gallery?
    false
  end

  def folder?
    false
  end

  def blog?
    false
  end

  def forum?
    false
  end

  def uploaded_file?
    false
  end

  settings_items :display_versions, type: :boolean, default: false

  def can_display_versions?
    false
  end

  def display_versions?
    can_display_versions? && display_versions
  end

  def get_version(version_number = nil)
    if version_number then self.versions.order("version").offset(version_number - 1).first else self.versions.earliest end
  end

  def author_by_version(version_number = nil)
    return author unless version_number

    author_id = get_version(version_number).last_changed_by_id
    profile.environment.people.where(id: author_id).first
  end

  def author_name(version_number = nil)
    person = author_by_version(version_number)
    if version_number
      person ? person.name : environment.name
    else
      person ? person.name : (setting[:author_name] || environment.name)
    end
  end

  def author_url(version_number = nil)
    person = author_by_version(version_number)
    person ? person.url : nil
  end

  def author_id(version_number = nil)
    person = author_by_version(version_number)
    person ? person.id : nil
  end

  # FIXME make this test
  def author_custom_image(size = :icon)
    author ? author.profile_custom_image(size) : nil
  end

  def version_license(version_number = nil)
    return license if version_number.nil?

    profile.environment.licenses.find_by(id: get_version(version_number).license_id)
  end

  alias :active_record_cache_key :cache_key
  def cache_key(params = {}, the_profile = nil, language = "en")
    active_record_cache_key + "-" + language +
      (allow_post_content?(the_profile) ? "-owner" : "") +
      (params[:npage] ? "-npage-#{params[:npage]}" : "") +
      (params[:year] ? "-year-#{params[:year]}" : "") +
      (params[:month] ? "-month-#{params[:month]}" : "") +
      (params[:version] ? "-version-#{params[:version]}" : "")
  end

  def first_paragraph
    paragraphs = Nokogiri::HTML.fragment(to_html).css("p")
    paragraphs.empty? ? "" : paragraphs.first.to_html
  end

  def lead(length = nil)
    content = abstract.blank? ? first_paragraph.html_safe : abstract.html_safe
    length.present? ? content.truncate(length) : content
  end

  def short_lead
    truncate sanitize_html(self.lead), length: 170, omission: "..."
  end

  def notifiable?
    false
  end

  def accept_uploads?
    self.parent && self.parent.accept_uploads?
  end

  def body_images_paths
    paths = Nokogiri::HTML.fragment(self.body.to_s).css("img[src]").collect do |i|
      src = i["src"]
      src = URI.escape src if self.new_record? # xss_terminate runs on save
      (self.profile && self.profile.environment) ? URI.join(self.profile.environment.top_url, src).to_s : src
    end
    paths.unshift(URI.join(self.profile.environment.top_url, self.image.public_filename).to_s) if self.image.present?
    paths
  end

  def more_comments_label
    amount = self.comments_count
    {
      0 => _("no comments"),
      1 => _("one comment")
    }[amount] || _("%s comments") % amount
  end

  def more_popular_label
    amount = self.hits
    {
      0 => _("no views"),
      1 => _("one view")
    }[amount] || _("%s views") % amount
  end

  def more_recent_label
    _("Created at: ")
  end

  def activity
    ActionTracker::Record.where(target_type: "Article", target_id: self.id).first
  end

  def create_activity
    if notifiable? && !image?
      save_action_for_verb "create_article", [:name, :title, :url, :lead, :first_image], Proc.new {}, :author
    end
  end

  def first_image
    img = (image.present? && { "src" => File.join([Noosfero.root, image.public_filename(:uploaded)].join) }) ||
          Nokogiri::HTML.fragment(self.lead.to_s).css("img[src]").first ||
          Nokogiri::HTML.fragment(self.body.to_s).search("img").first
    img.nil? ? "" : img["src"]
  end

  delegate :lat, :lng, :region, :region_id, :environment, :environment_id, to: :profile, allow_nil: true

  def has_macro?
    true
  end

  def to_liquid
    HashWithIndifferentAccess.new name: name, abstract: abstract, body: body, id: id, parent_id: parent_id, author: author
  end

  def self.can_display_blocks?
    true
  end

  def editor?(editor)
    self.editor == editor
  end

  def icon
    "file"
  end

  def custom_title
    false
  end

  def self.switch_orders(first_article, second_article)
    return unless first_article.profile == second_article.profile &&
                  first_article.position >= second_article.position

    ActiveRecord::Base.transaction do
      first_order = first_article.position
      where("profile_id = ?", first_article.profile_id)
        .where("position > ? OR (position = ? AND published_at > ?)",
               first_order, first_order, first_article.published_at)
        .update_all("position = (position + 1)")

      first_article.update!(position: first_order)
      second_article.update!(position: first_order + 1)
    end
  end

  private

    def parent_archived?
      if self.parent_id_changed? && self.parent && self.parent.archived?
        errors.add(:parent_folder, N_("is archived!!"))
      end
    end

    def validate_custom_fields
      if metadata.has_key?("custom_fields")
        custom_fields = metadata["custom_fields"]
        if custom_fields.present?
          custom_fields.each do |key, field|
            if field["value"].blank?
              errors.add(:metadata, _("Custom fields must have values"))
            end
          end
        end
      end
    end

    def sanitize_custom_field_keys
      if metadata.has_key?("custom_fields")
        custom_fields = metadata["custom_fields"] || {}
        metadata["custom_fields"] = custom_fields.keys.map do |field|
          [field.to_slug, metadata["custom_fields"][field]]
        end.to_h
      end
    end
end