ArtOfCode-/qpixel

View on GitHub
app/models/post.rb

Summary

Maintainability
B
5 hrs
Test Coverage
class Post < ApplicationRecord
  include CommunityRelated

  belongs_to :user
  belongs_to :post_type
  belongs_to :parent, class_name: 'Post', optional: true
  belongs_to :closed_by, class_name: 'User', optional: true
  belongs_to :deleted_by, class_name: 'User', optional: true
  belongs_to :last_activity_by, class_name: 'User', optional: true
  belongs_to :locked_by, class_name: 'User', optional: true
  belongs_to :last_edited_by, class_name: 'User', optional: true
  belongs_to :category, optional: true
  belongs_to :license, optional: true
  belongs_to :close_reason, optional: true
  belongs_to :duplicate_post, class_name: 'Question', optional: true
  has_and_belongs_to_many :tags, dependent: :destroy
  has_many :votes, dependent: :destroy
  has_many :comments, dependent: :destroy
  has_many :post_histories, dependent: :destroy
  has_many :flags, dependent: :destroy
  has_many :children, class_name: 'Post', foreign_key: 'parent_id', dependent: :destroy
  has_many :suggested_edits, dependent: :destroy

  counter_culture :parent, column_name: proc { |model| model.deleted? ? nil : 'answer_count' }

  serialize :tags_cache, Array

  validates :body, presence: true, length: { minimum: 30, maximum: 30_000 }
  validates :doc_slug, uniqueness: { scope: [:community_id] }, if: -> { doc_slug.present? }
  validates :title, :body, :tags_cache, presence: true, if: -> { question? || article? }
  validate :tags_in_tag_set, if: -> { question? || article? }
  validate :maximum_tags, if: -> { question? || article? }
  validate :maximum_tag_length, if: -> { question? || article? }
  validate :no_spaces_in_tags, if: -> { question? || article? }
  validate :stripped_minimum, if: -> { question? || article? }
  validate :category_allows_post_type
  validate :license_available
  validate :required_tags?, if: -> { question? || article? }
  validate :moderator_tags, if: -> { question? || article? }

  scope :undeleted, -> { where(deleted: false) }
  scope :deleted, -> { where(deleted: true) }
  scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id, Article.post_type_id]) }
  scope :list_includes, -> { includes(:user, :tags, user: :avatar_attachment) }

  before_validation :update_tag_associations, if: -> { question? || article? }
  after_create :create_initial_revision
  after_create :add_license_if_nil
  after_save :check_attribution_notice
  after_save :modify_author_reputation
  after_save :copy_last_activity_to_parent
  after_save :break_description_cache
  after_save :update_category_activity, if: -> { question? || article? }
  after_save :recalc_score

  def self.search(term)
    match_search term, posts: :body_markdown
  end

  # Double-define: initial definitions are less efficient, so if we have a record of the post type we'll
  # override them later with more efficient methods.
  ['Question', 'Answer', 'PolicyDoc', 'HelpDoc', 'Article'].each do |pt|
    define_method "#{pt.underscore}?" do
      post_type_id == pt.constantize.post_type_id
    end
  end

  PostType.all.find_each do |pt|
    define_method "#{pt.name.underscore}?" do
      post_type_id == pt.id
    end
  end

  def tag_set
    parent.nil? ? category.tag_set : parent.category.tag_set
  end

  def meta?
    false
  end

  def reassign_user(new_user)
    new_user.ensure_community_user!

    # Three updates: one to remove rep from previous user, one to reassign, one to re-grant rep to new user
    update!(deleted: true, deleted_at: DateTime.now, deleted_by: User.find(-1))
    update!(user: new_user)
    votes.update_all(recv_user_id: new_user.id)
    update!(deleted: false, deleted_at: nil, deleted_by: nil)
  end

  def remove_attribution_notice!
    update(att_source: nil, att_license_link: nil, att_license_name: nil)
  end

  def body_plain
    ApplicationController.helpers.strip_markdown(body_markdown)
  end

  def question?
    post_type_id == Question.post_type_id
  end

  def answer?
    post_type_id == Answer.post_type_id
  end

  def article?
    post_type_id == Article.post_type_id
  end

  def pending_suggested_edit?
    SuggestedEdit.where(post_id: id, active: true).any?
  end

  def pending_suggested_edit
    SuggestedEdit.where(post_id: id, active: true).last
  end

  def recalc_score
    variable = SiteSetting['ScoringVariable'] || 2
    sql = 'UPDATE posts SET score = (upvote_count + ?) / (upvote_count + downvote_count + (2 * ?)) WHERE id = ?'
    sanitized = ActiveRecord::Base.sanitize_sql_array([sql, variable, variable, id])
    ActiveRecord::Base.connection.execute sanitized
  end

  def locked?
    return true if locked && locked_until.nil? # permanent lock
    return true if locked && !locked_until.past?

    if locked
      update(locked: false, locked_by: nil, locked_at: nil, locked_until: nil)
    end
  end

  private

  def update_tag_associations
    tags_cache.each do |tag_name|
      tag = Tag.find_or_create_by name: tag_name, tag_set: category.tag_set
      unless tags.include? tag
        tags << tag
      end
    end
    tags.each do |tag|
      unless tags_cache.include? tag.name
        tags.delete tag
      end
    end
  end

  def attribution_text(source = nil, name = nil, url = nil)
    "Source: #{source || att_source}\nLicense name: #{name || att_license_name}\n" \
      "License URL: #{url || att_license_link}"
  end

  def check_attribution_notice
    sc = saved_changes
    attributes = ['att_source', 'att_license_name', 'att_license_link']
    if attributes.any? { |x| sc.include?(x) && sc[x][0] != sc[x][1] }
      if attributes.all? { |x| sc[x]&.try(:[], 0).nil? }
        PostHistory.attribution_notice_added(self, User.find(-1), after: attribution_text)
      elsif attributes.all? { |x| sc[x]&.try(:[], 1).nil? }
        PostHistory.attribution_notice_removed(self, User.find(-1),
                                               before: attribution_text(*attributes.map { |a| sc[a]&.try(:[], 0) }))
      else
        PostHistory.attribution_notice_changed(self, User.find(-1),
                                               before: attribution_text(*attributes.map { |a| sc[a]&.try(:[], 0) }),
                                               after: attribution_text(*attributes.map { |a| sc[a]&.try(:[], 1) }))
      end
    end
  end

  def copy_last_activity_to_parent
    sc = saved_changes
    if parent.present? && (sc.include?('last_activity') || sc.include?('last_activity_by_id')) \
       && !parent.update(last_activity: last_activity, last_activity_by: last_activity_by)
      Rails.logger.error "Parent failed copy_last_activity update (#{parent.errors.full_messages.join(';')})"
    end
  end

  def modify_author_reputation
    sc = saved_changes
    if sc.include?('deleted') && sc['deleted'][0] != sc['deleted'][1] && created_at >= 60.days.ago
      deleted = !!saved_changes['deleted']&.last
      if deleted
        user.update(reputation: user.reputation - Vote.total_rep_change(votes))
      else
        user.update(reputation: user.reputation + Vote.total_rep_change(votes))
      end
    end
  end

  def create_initial_revision
    PostHistory.initial_revision(self, user, after: body_markdown, after_title: title, after_tags: tags)
  end

  def category_allows_post_type
    return if category.nil?

    unless category&.post_types&.include? post_type
      errors.add(:base, "The #{post_type.name} post type is not allowed in the #{category&.name} category.")
    end
  end

  def break_description_cache
    Rails.cache.delete "posts/#{id}/description"
    if parent_id.present?
      Rails.cache.delete "posts/#{parent_id}/description"
    end
  end

  def license_available
    # Don't validate license on edits
    return unless id.nil?

    unless license.nil? || license.enabled?
      errors.add(:license, 'is not available for use')
    end
  end

  def maximum_tags
    if tags_cache.length > 5
      errors.add(:tags, "can't have more than 5 tags")
    elsif tags_cache.empty?
      errors.add(:tags, 'must have at least one tag')
    end
  end

  def maximum_tag_length
    tags_cache.each do |tag|
      max_len = SiteSetting['MaxTagLength']
      if tag.length > max_len
        errors.add(:tags, "can't be more than #{max_len} characters long each")
      end
    end
  end

  def no_spaces_in_tags
    tags_cache.each do |tag|
      if tag.include?(' ') || tag.include?('_')
        errors.add(:tags, 'may not include spaces or underscores - use hyphens for multiple-word tags')
      end
    end
  end

  def stripped_minimum
    if (body&.squeeze('  ')&.length || 0) < 30
      errors.add(:body, 'must be more than 30 non-whitespace characters long')
    end
    if (title&.squeeze('  ')&.length || 0) < 15
      errors.add(:title, 'must be more than 15 non-whitespace characters long')
    end
  end

  def tags_in_tag_set
    tag_set = category.tag_set
    unless tags.all? { |t| t.tag_set_id == tag_set.id }
      errors.add(:base, "Not all of this question's tags are in the correct tag set.")
    end
  end

  def add_license_if_nil
    if license.nil?
      update(license: License.site_default)
    end
  end

  def required_tags?
    required = category&.required_tag_ids
    return unless required.present? && !required.empty?

    unless tag_ids.any? { |t| required.include? t }
      errors.add(:tags, "must contain at least one required tag (#{category.required_tags.pluck(:name).join(', ')})")
    end
  end

  def moderator_tags
    mod_tags = category&.moderator_tags&.map(&:name)
    return unless mod_tags.present? && !mod_tags.empty?
    return if RequestContext.user&.is_moderator

    sc = changes
    return unless sc.include? 'tags_cache'

    if (sc['tags_cache'][0] || []) & mod_tags != (sc['tags_cache'][1] || []) & mod_tags
      errors.add(:base, "You don't have permission to change moderator-only tags.")
    end
  end

  def update_category_activity
    if saved_changes.include? 'last_activity'
      category.update_activity(last_activity)
    end
  end
end