# frozen_string_literal: true class Reviewable < ActiveRecord::Base class UpdateConflict < StandardError; end class InvalidAction < StandardError def initialize(action_id, klass) @action_id, @klass = action_id, klass super("Can't perform `#{action_id}` on #{klass.name}") end end before_save :apply_review_group attr_accessor :created_new validates_presence_of :type, :status, :created_by_id belongs_to :target, polymorphic: true belongs_to :created_by, class_name: 'User' belongs_to :target_created_by, class_name: 'User' belongs_to :reviewable_by_group, class_name: 'Group' # Optional, for filtering belongs_to :topic belongs_to :category has_many :reviewable_histories has_many :reviewable_scores, -> { order(created_at: :desc) } after_create do log_history(:created, created_by) end after_commit(on: :create) do DiscourseEvent.trigger(:reviewable_created, self) end after_commit(on: [:create, :update]) do Jobs.enqueue(:notify_reviewable, reviewable_id: self.id) if pending? end # Can be used if several actions are equivalent def self.action_aliases {} end # The gaps are in case we want more precision in the future def self.priorities @priorities ||= Enum.new( low: 0, medium: 5, high: 10 ) end # The gaps are in case we want more precision in the future def self.sensitivity @sensitivity ||= Enum.new( disabled: 0, low: 9, medium: 6, high: 3 ) end def self.statuses @statuses ||= Enum.new( pending: 0, approved: 1, rejected: 2, ignored: 3, deleted: 4 ) end # This number comes from looking at forums in the wild and what numbers work. # As the site accumulates real data it'll be based on the site activity instead. def self.typical_sensitivity 12.5 end # Generate `pending?`, `rejected?`, etc helper methods statuses.each do |name, id| define_method("#{name}?") { status == id } self.class.define_method(name) { where(status: id) } end def self.default_visible where("score >= ?", min_score_for_priority) end def self.valid_type?(type) return false unless type =~ /^Reviewable[A-Za-z]+$/ type.constantize <= Reviewable rescue NameError false end def self.types %w[ReviewableFlaggedPost ReviewableQueuedPost ReviewableUser ReviewablePost] end def self.custom_filters @reviewable_filters ||= [] end def self.add_custom_filter(new_filter) custom_filters << new_filter end def self.clear_custom_filters! @reviewable_filters = [] end def created_new! self.created_new = true self.topic = target.topic if topic.blank? && target.is_a?(Post) self.target_created_by_id = target.is_a?(Post) ? target.user_id : nil self.category_id = topic.category_id if category_id.blank? && topic.present? end # Create a new reviewable, or if the target has already been reviewed return it to the # pending state and re-use it. # # You probably want to call this to create your reviewable rather than `.create`. def self.needs_review!( target: nil, topic: nil, created_by:, payload: nil, reviewable_by_moderator: false, potential_spam: true ) reviewable = new( target: target, topic: topic, created_by: created_by, reviewable_by_moderator: reviewable_by_moderator, payload: payload, potential_spam: potential_spam ) reviewable.created_new! if target.blank? # If there is no target there's no chance of a conflict reviewable.save! else # In this case, a reviewable might already exist for this (type, target_id) index. # ActiveRecord can only validate indexes using a SELECT before the INSERT which # is not safe under concurrency. Instead, we perform an UPDATE on the status, and return # the previous value. We then know: # # a) if a previous row existed # b) if it was changed # # And that allows us to complete our logic. update_args = { status: statuses[:pending], id: target.id, type: target.class.name, potential_spam: potential_spam == true ? true : nil } row = DB.query_single(<<~SQL, update_args) UPDATE reviewables SET status = :status, potential_spam = COALESCE(:potential_spam, reviewables.potential_spam) FROM reviewables AS old_reviewables WHERE reviewables.target_id = :id AND reviewables.target_type = :type RETURNING old_reviewables.status SQL old_status = row[0] if old_status.blank? reviewable.save! else reviewable = find_by(target: target) reviewable.log_history(:transitioned, created_by) if old_status != statuses[:pending] end end reviewable end def add_score( user, reviewable_score_type, reason: nil, created_at: nil, take_action: false, meta_topic_id: nil, force_review: false ) type_bonus = PostActionType.where(id: reviewable_score_type).pluck(:score_bonus)[0] || 0 take_action_bonus = take_action ? 5.0 : 0.0 user_accuracy_bonus = ReviewableScore.user_accuracy_bonus(user) sub_total = ReviewableScore.calculate_score(user, type_bonus, take_action_bonus) rs = reviewable_scores.new( user: user, status: ReviewableScore.statuses[:pending], reviewable_score_type: reviewable_score_type, score: sub_total, user_accuracy_bonus: user_accuracy_bonus, meta_topic_id: meta_topic_id, take_action_bonus: take_action_bonus, created_at: created_at || Time.zone.now ) rs.reason = reason.to_s if reason rs.save! update(score: self.score + rs.score, latest_score: rs.created_at, force_review: force_review) topic.update(reviewable_score: topic.reviewable_score + rs.score) if topic DiscourseEvent.trigger(:reviewable_score_updated, self) rs end def self.set_priorities(values) values.each do |k, v| id = Reviewable.priorities[k] PluginStore.set('reviewables', "priority_#{id}", v) unless id.nil? end end def self.sensitivity_score_value(sensitivity, scale) return Float::MAX if sensitivity == 0 ratio = sensitivity / Reviewable.sensitivity[:low].to_f high = ( PluginStore.get('reviewables', "priority_#{Reviewable.priorities[:high]}") || typical_sensitivity ).to_f # We want this to be hard to reach ((high.to_f * ratio) * scale).truncate(2) end def self.sensitivity_score(sensitivity, scale: 1.0) # If the score is less than the default visibility, bring it up to that level. # Otherwise we have the confusing situation where a post might be hidden and # moderators would never see it! [sensitivity_score_value(sensitivity, scale), min_score_for_priority].max end def self.score_to_auto_close_topic sensitivity_score(SiteSetting.auto_close_topic_sensitivity, scale: 2.5) end def self.spam_score_to_silence_new_user sensitivity_score(SiteSetting.silence_new_user_sensitivity, scale: 0.6) end def self.score_required_to_hide_post sensitivity_score(SiteSetting.hide_post_sensitivity) end def self.min_score_for_priority(priority = nil) priority ||= SiteSetting.reviewable_default_visibility id = Reviewable.priorities[priority.to_sym] return 0.0 if id.nil? PluginStore.get('reviewables', "priority_#{id}").to_f end def history reviewable_histories.order(:created_at) end def log_history(reviewable_history_type, performed_by, edited: nil) reviewable_histories.create!( reviewable_history_type: ReviewableHistory.types[reviewable_history_type], status: status, created_by: performed_by, edited: edited ) end def apply_review_group return unless SiteSetting.enable_category_group_moderation? && category.present? && category.reviewable_by_group_id self.reviewable_by_group_id = category.reviewable_by_group_id end def actions_for(guardian, args = nil) args ||= {} Actions.new(self, guardian).tap do |actions| build_actions(actions, guardian, args) end end def editable_for(guardian, args = nil) args ||= {} EditableFields.new(self, guardian, args).tap do |fields| build_editable_fields(fields, guardian, args) end end # subclasses must implement "build_actions" to list the actions they're capable of def build_actions(actions, guardian, args) raise NotImplementedError end # subclasses can implement "build_editable_fields" to list stuff that can be edited def build_editable_fields(actions, guardian, args) end def update_fields(params, performed_by, version: nil) return true if params.blank? (params[:payload] || {}).each { |k, v| self.payload[k] = v } self.category_id = params[:category_id] if params.has_key?(:category_id) result = false Reviewable.transaction do increment_version!(version) changes_json = changes.as_json changes_json.delete('version') result = save log_history(:edited, performed_by, edited: changes_json) if result end result end # Delegates to a `perform_#{action_id}` method, which returns a `PerformResult` with # the result of the operation and whether the status of the reviewable changed. def perform(performed_by, action_id, args = nil) args ||= {} # Support this action or any aliases aliases = self.class.action_aliases valid = [ action_id, aliases.to_a.select { |k, v| v == action_id }.map(&:first) ].flatten # Ensure the user has access to the action actions = actions_for(Guardian.new(performed_by), args) raise InvalidAction.new(action_id, self.class) unless valid.any? { |a| actions.has?(a) } perform_method = "perform_#{aliases[action_id] || action_id}".to_sym raise InvalidAction.new(action_id, self.class) unless respond_to?(perform_method) result = nil update_count = false Reviewable.transaction do increment_version!(args[:version]) result = public_send(perform_method, performed_by, args) raise ActiveRecord::Rollback unless result.success? update_count = transition_to(result.transition_to, performed_by) if result.transition_to update_flag_stats(**result.update_flag_stats) if result.update_flag_stats recalculate_score if result.recalculate_score end if result && result.after_commit result.after_commit.call end Jobs.enqueue(:notify_reviewable, reviewable_id: self.id) if update_count result end def transition_to(status_symbol, performed_by) was_pending = pending? self.status = Reviewable.statuses[status_symbol] save! log_history(:transitioned, performed_by) DiscourseEvent.trigger(:reviewable_transitioned_to, status_symbol, self) if score_status = ReviewableScore.score_transitions[status_symbol] reviewable_scores.pending.update_all( status: score_status, reviewed_by_id: performed_by.id, reviewed_at: Time.zone.now ) end was_pending end def post_options Discourse.deprecate( "Reviewable#post_options is deprecated. Please use #payload instead.", output_in_test: true ) end def self.bulk_perform_targets(performed_by, action, type, target_ids, args = nil) args ||= {} viewable_by(performed_by).where(type: type, target_id: target_ids).each do |r| r.perform(performed_by, action, args) end end def self.viewable_by(user, order: nil, preload: true) return none unless user.present? result = self.order(order || 'reviewables.score desc, reviewables.created_at desc') if preload result = result.includes( { created_by: :user_stat }, :topic, :target, :target_created_by, :reviewable_histories ).includes(reviewable_scores: { user: :user_stat, meta_topic: :posts }) end return result if user.admin? group_ids = SiteSetting.enable_category_group_moderation? ? user.group_users.pluck(:group_id) : [] result.where( '(reviewables.reviewable_by_moderator AND :staff) OR (reviewables.reviewable_by_group_id IN (:group_ids))', staff: user.staff?, group_ids: group_ids ).where("reviewables.category_id IS NULL OR reviewables.category_id IN (?)", Guardian.new(user).allowed_category_ids) end def self.pending_count(user) list_for(user).count end def self.list_for( user, ids: nil, status: :pending, category_id: nil, topic_id: nil, type: nil, limit: nil, offset: nil, priority: nil, username: nil, reviewed_by: nil, sort_order: nil, from_date: nil, to_date: nil, additional_filters: {} ) order = case sort_order when 'score_asc' 'reviewables.score ASC, reviewables.created_at DESC' when 'created_at' 'reviewables.created_at DESC, reviewables.score DESC' when 'created_at_asc' 'reviewables.created_at ASC, reviewables.score DESC' else 'reviewables.score DESC, reviewables.created_at DESC' end if username.present? user_id = User.find_by_username(username)&.id return [] if user_id.blank? end return [] if user.blank? result = viewable_by(user, order: order) result = by_status(result, status) result = result.where(id: ids) if ids result = result.where('reviewables.type = ?', type) if type result = result.where('reviewables.category_id = ?', category_id) if category_id result = result.where('reviewables.topic_id = ?', topic_id) if topic_id result = result.where("reviewables.created_at >= ?", from_date) if from_date result = result.where("reviewables.created_at <= ?", to_date) if to_date if reviewed_by reviewed_by_id = User.find_by_username(reviewed_by)&.id return [] if reviewed_by_id.nil? result = result.joins(<<~SQL INNER JOIN( SELECT reviewable_id FROM reviewable_histories WHERE reviewable_history_type = #{ReviewableHistory.types[:transitioned]} AND status <> #{Reviewable.statuses[:pending]} AND created_by_id = #{reviewed_by_id} ) AS rh ON rh.reviewable_id = reviewables.id SQL ) end min_score = Reviewable.min_score_for_priority(priority) if min_score > 0 && status == :pending result = result.where("reviewables.score >= ? OR reviewables.force_review", min_score) elsif min_score > 0 result = result.where("reviewables.score >= ?", min_score) end if !custom_filters.empty? result = custom_filters.reduce(result) do |memo, filter| key = filter.first filter_query = filter.last next(memo) unless additional_filters[key] filter_query.call(result, additional_filters[key]) end end # If a reviewable doesn't have a target, allow us to filter on who created that reviewable. if user_id result = result.where( "(reviewables.target_created_by_id IS NULL AND reviewables.created_by_id = :user_id) OR (reviewables.target_created_by_id = :user_id)", user_id: user_id ) end result = result.limit(limit) if limit result = result.offset(offset) if offset result end def serializer self.class.serializer_for(self) end def self.lookup_serializer_for(type) "#{type}Serializer".constantize rescue NameError ReviewableSerializer end def self.serializer_for(reviewable) type = reviewable.type @@serializers ||= {} @@serializers[type] ||= lookup_serializer_for(type) end def create_result(status, transition_to = nil) result = PerformResult.new(self, status) result.transition_to = transition_to yield result if block_given? result end def self.scores_with_topics ReviewableScore.joins(reviewable: :topic).where("reviewables.type = ?", name) end def self.count_by_date(start_date, end_date, category_id = nil, include_subcategories = false) query = scores_with_topics.where('reviewable_scores.created_at BETWEEN ? AND ?', start_date, end_date) if category_id if include_subcategories query = query.where("topics.category_id IN (?)", Category.subcategory_ids(category_id)) else query = query.where("topics.category_id = ?", category_id) end end query .group("date(reviewable_scores.created_at)") .order('date(reviewable_scores.created_at)') .count end def explain_score DB.query(<<~SQL, reviewable_id: id) SELECT rs.reviewable_id, rs.user_id, CASE WHEN (u.admin OR u.moderator) THEN 5.0 ELSE u.trust_level END AS trust_level_bonus, us.flags_agreed, us.flags_disagreed, us.flags_ignored, rs.score, rs.user_accuracy_bonus, rs.take_action_bonus, COALESCE(pat.score_bonus, 0.0) AS type_bonus FROM reviewable_scores AS rs INNER JOIN users AS u ON u.id = rs.user_id LEFT OUTER JOIN user_stats AS us ON us.user_id = rs.user_id LEFT OUTER JOIN post_action_types AS pat ON pat.id = rs.reviewable_score_type WHERE rs.reviewable_id = :reviewable_id SQL end protected def recalculate_score # pending/agreed scores count sql = <<~SQL UPDATE reviewables SET score = COALESCE(( SELECT sum(score) FROM reviewable_scores AS rs WHERE rs.reviewable_id = :id AND rs.status IN (:pending, :agreed) ), 0.0) WHERE id = :id RETURNING score SQL result = DB.query( sql, id: self.id, pending: ReviewableScore.statuses[:pending], agreed: ReviewableScore.statuses[:agreed] ) # Update topic score sql = <<~SQL UPDATE topics SET reviewable_score = COALESCE(( SELECT SUM(score) FROM reviewables AS r WHERE r.topic_id = :topic_id AND r.status IN (:pending, :approved) ), 0.0) WHERE id = :topic_id SQL DB.query( sql, topic_id: topic_id, pending: Reviewable.statuses[:pending], approved: Reviewable.statuses[:approved] ) self.score = result[0].score DiscourseEvent.trigger(:reviewable_score_updated, self) self.score end def increment_version!(version = nil) version_result = nil if version version_result = DB.query_single( "UPDATE reviewables SET version = version + 1 WHERE id = :id AND version = :version RETURNING version", version: version, id: self.id ) else # We didn't supply a version to update safely, so just increase it version_result = DB.query_single( "UPDATE reviewables SET version = version + 1 WHERE id = :id RETURNING version", id: self.id ) end if version_result && version_result[0] self.version = version_result[0] else raise UpdateConflict.new end end def self.by_status(partial_result, status) return partial_result if status == :all if status == :reviewed partial_result.where(status: statuses.except(:pending).values) else partial_result.where(status: statuses[status]) end end private def update_flag_stats(status:, user_ids:) return unless [:agreed, :disagreed, :ignored].include?(status) # Don't count self-flags user_ids -= [post&.user_id] return if user_ids.blank? result = DB.query(<<~SQL, user_ids: user_ids) UPDATE user_stats SET flags_#{status} = flags_#{status} + 1 WHERE user_id IN (:user_ids) RETURNING user_id, flags_agreed + flags_disagreed + flags_ignored AS total SQL user_ids = result.select { |r| r.total > Jobs::TruncateUserFlagStats.truncate_to }.map(&:user_id) return if user_ids.blank? Jobs.enqueue(:truncate_user_flag_stats, user_ids: user_ids) end end # == Schema Information # # Table name: reviewables # # id :bigint not null, primary key # type :string not null # status :integer default(0), not null # created_by_id :integer not null # reviewable_by_moderator :boolean default(FALSE), not null # reviewable_by_group_id :integer # category_id :integer # topic_id :integer # score :float default(0.0), not null # potential_spam :boolean default(FALSE), not null # target_id :integer # target_type :string # target_created_by_id :integer # payload :json # version :integer default(0), not null # latest_score :datetime # created_at :datetime not null # updated_at :datetime not null # force_review :boolean default(FALSE), not null # reject_reason :text # # Indexes # # index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id) # index_reviewables_on_status_and_created_at (status,created_at) # index_reviewables_on_status_and_score (status,score) # index_reviewables_on_status_and_type (status,type) # index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text) # index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id) # index_reviewables_on_type_and_target_id (type,target_id) UNIQUE #