discourse/app/models/reviewable.rb

762 lines
22 KiB
Ruby

# 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? || !Reviewable.where(target: target, type: reviewable.type).exists?
# If there is no target, or no existing reviewable with matching target and type, 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)
if old_status != statuses[:pending]
# If we're transitioning back from reviewed to pending, we should recalculate
# the score to prevent posts from being hidden.
reviewable.recalculate_score
reviewable.log_history(:transitioned, created_by)
end
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
if update_count || result.remove_reviewable_ids.present?
Jobs.enqueue(
:notify_reviewable,
reviewable_id: self.id,
updated_reviewable_ids: result.remove_reviewable_ids,
)
end
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
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 delete_user_actions(actions, require_reject_reason: false)
reject = actions.add_bundle(
'reject_user',
icon: 'user-times',
label: 'reviewables.actions.reject_user.title'
)
actions.add(:delete_user, bundle: reject) do |a|
a.icon = 'user-times'
a.label = "reviewables.actions.reject_user.delete.title"
a.require_reject_reason = require_reject_reason
a.description = "reviewables.actions.reject_user.delete.description"
end
actions.add(:delete_user_block, bundle: reject) do |a|
a.icon = 'ban'
a.label = "reviewables.actions.reject_user.block.title"
a.require_reject_reason = require_reject_reason
a.description = "reviewables.actions.reject_user.block.description"
end
end
protected
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
#