mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 18:09:26 +08:00
For these steps: 1. Set a watched word. 2. A user triggered watched word, and the topic fall into the reviewables. 3. A staff approved the topic. 4. The topic is flagged by a user. Then the original reviewables of "Approving this topic" will change its status from "Approved" back to "Pending", and there will be two reviewables in the queue (one for approving this topic, which approve/reject won't make any difference, and another for dealing with the flag), which shouldn't be that case. This commit add logic to prevent that from being changed back, keeping only the flagged reviewables, and some specs are also added to it.
954 lines
29 KiB
Ruby
Vendored
954 lines
29 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class Reviewable < ActiveRecord::Base
|
|
TYPE_TO_BASIC_SERIALIZER = {
|
|
ReviewableFlaggedPost: BasicReviewableFlaggedPostSerializer,
|
|
ReviewableQueuedPost: BasicReviewableQueuedPostSerializer,
|
|
ReviewableUser: BasicReviewableUserSerializer,
|
|
}
|
|
|
|
UNKNOWN_TYPE_SOURCE = "unknown"
|
|
|
|
self.ignored_columns = [:reviewable_by_group_id]
|
|
|
|
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
|
|
|
|
attr_accessor :created_new
|
|
validates :type, :status, :created_by_id, presence: true
|
|
belongs_to :target, polymorphic: true
|
|
belongs_to :created_by, class_name: "User"
|
|
belongs_to :target_created_by, class_name: "User"
|
|
|
|
# Optional, for filtering
|
|
belongs_to :topic
|
|
belongs_to :category
|
|
|
|
has_many :reviewable_histories, dependent: :destroy
|
|
has_many :reviewable_scores, -> { order(created_at: :desc) }, dependent: :destroy
|
|
has_many :reviewable_notes, -> { order(created_at: :asc) }, dependent: :destroy
|
|
|
|
enum :status, { pending: 0, approved: 1, rejected: 2, ignored: 3, deleted: 4 }
|
|
|
|
attribute :sensitivity, :integer
|
|
enum :sensitivity, { disabled: 0, low: 9, medium: 6, high: 3 }, scopes: false, suffix: true
|
|
|
|
attribute :priority, :integer
|
|
enum :priority, { low: 0, medium: 5, high: 10 }, scopes: false, suffix: true
|
|
|
|
validates :reject_reason, length: { maximum: 2000 }
|
|
|
|
before_save :set_type_source
|
|
|
|
after_create { log_history(:created, created_by) }
|
|
|
|
after_commit(on: :create) { DiscourseEvent.trigger(:reviewable_created, self) }
|
|
|
|
after_commit(on: %i[create update]) do
|
|
Jobs.enqueue(:notify_reviewable, reviewable_id: id) if pending?
|
|
end
|
|
|
|
# Can be used if several actions are equivalent
|
|
def self.action_aliases
|
|
{}
|
|
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
|
|
|
|
def self.default_visible
|
|
where("score >= ?", min_score_for_priority)
|
|
end
|
|
|
|
def self.valid_type?(type)
|
|
type.to_s.safe_constantize.in?(types)
|
|
end
|
|
|
|
def self.types
|
|
[ReviewableFlaggedPost, ReviewableQueuedPost, ReviewableUser, ReviewablePost]
|
|
end
|
|
|
|
def self.scrubbable_types
|
|
[ReviewableUser]
|
|
end
|
|
|
|
def self.sti_names
|
|
types.map(&:sti_name)
|
|
end
|
|
|
|
def self.source_for(type)
|
|
type = type.sti_name if type.is_a?(Class)
|
|
return UNKNOWN_TYPE_SOURCE if Reviewable.sti_names.exclude?(type)
|
|
|
|
DiscoursePluginRegistry
|
|
.reviewable_types_lookup
|
|
.find { |r| r[:klass].sti_name == type }
|
|
&.dig(:plugin) || "core"
|
|
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 set_type_source
|
|
self.type_source = Reviewable.source_for(type)
|
|
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,
|
|
potentially_illegal: false,
|
|
target_created_by: nil
|
|
)
|
|
reviewable =
|
|
new(
|
|
target: target,
|
|
topic: topic,
|
|
created_by: created_by,
|
|
reviewable_by_moderator: reviewable_by_moderator,
|
|
payload: payload,
|
|
potential_spam: potential_spam,
|
|
potentially_illegal: potentially_illegal,
|
|
target_created_by: target_created_by,
|
|
)
|
|
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,
|
|
target_type: target.class.polymorphic_name,
|
|
reviewable_type: reviewable.type,
|
|
potential_spam: potential_spam == true ? true : nil,
|
|
potentially_illegal: potentially_illegal == true ? true : nil,
|
|
}
|
|
|
|
row = DB.query_single(<<~SQL, update_args)
|
|
UPDATE reviewables
|
|
SET status = :status,
|
|
potential_spam = COALESCE(:potential_spam, reviewables.potential_spam),
|
|
potentially_illegal = COALESCE(:potentially_illegal, reviewables.potentially_illegal)
|
|
FROM reviewables AS old_reviewables
|
|
WHERE reviewables.target_id = :id
|
|
AND reviewables.target_type = :target_type
|
|
AND reviewables.type = :reviewable_type
|
|
AND old_reviewables.id = reviewables.id
|
|
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,
|
|
context: nil
|
|
)
|
|
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: :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,
|
|
context: context,
|
|
)
|
|
rs.reason = reason.to_s if reason
|
|
rs.save!
|
|
|
|
update(
|
|
score: score + rs.score,
|
|
latest_score: rs.created_at,
|
|
force_review: self.force_review || force_review,
|
|
)
|
|
topic.update(reviewable_score: topic.reviewable_score + rs.score) if topic
|
|
|
|
# Flags are cached for performance reasons.
|
|
# However, when the reviewable item is created, we need to clear the cache to mark flag as used.
|
|
# Used flags cannot be deleted or update by admins, only disabled.
|
|
Flag.reset_flag_settings! if PostActionType.notify_flag_type_ids.include?(reviewable_score_type)
|
|
|
|
DiscourseEvent.trigger(:reviewable_score_updated, self)
|
|
|
|
rs
|
|
end
|
|
|
|
def self.set_priorities(values)
|
|
values.each do |k, v|
|
|
id = 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 / sensitivities[:low].to_f
|
|
high =
|
|
(PluginStore.get("reviewables", "priority_#{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 = priorities[priority]
|
|
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: reviewable_history_type,
|
|
status: status,
|
|
created_by: performed_by,
|
|
edited: edited,
|
|
)
|
|
end
|
|
|
|
def actions_for(guardian, args = nil)
|
|
args ||= {}
|
|
built_actions =
|
|
Actions.new(self, guardian).tap { |actions| build_actions(actions, guardian, args) }
|
|
|
|
# Empty bundles can cause big issues on the client side, so we remove them
|
|
# here. It's not valid anyway to have a bundle with no actions, but you can
|
|
# add a bundle via actions.add_bundle and then not add any actions to it.
|
|
built_actions.bundles.reject!(&:empty?)
|
|
|
|
built_actions
|
|
end
|
|
|
|
def editable_for(guardian, args = nil)
|
|
args ||= {}
|
|
EditableFields
|
|
.new(self, guardian, args)
|
|
.tap { |fields| build_editable_fields(fields, guardian, args) }
|
|
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| 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 ||= {}
|
|
perform_method = :"perform_#{aliases[action_id] || action_id}"
|
|
guardian = args[:guardian] || Guardian.new(performed_by)
|
|
|
|
validate_action!(guardian, action_id, perform_method, args)
|
|
|
|
affected_candidate_ids =
|
|
delete_user_action?(action_id) ? pending_reviewable_ids_for_target_user : []
|
|
|
|
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
|
|
|
|
result.after_commit.call if result && result.after_commit
|
|
|
|
if result&.success? && affected_candidate_ids.present?
|
|
result.affected_reviewable_ids |= resolved_reviewable_ids(affected_candidate_ids)
|
|
end
|
|
|
|
unless status == :pending
|
|
if update_count || result.remove_reviewable_ids.present?
|
|
Jobs.enqueue(
|
|
:notify_reviewable,
|
|
reviewable_id: id,
|
|
performing_username: performed_by.username,
|
|
updated_reviewable_ids: result.remove_reviewable_ids,
|
|
)
|
|
end
|
|
|
|
notify_users(result, guardian)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
# Includes pending scores plus disagreed ones, so re-approving a previously
|
|
# rejected reviewable correctly flips those scores back to "agreed".
|
|
def updatable_reviewable_scores
|
|
reviewable_scores.pending.or(reviewable_scores.disagreed)
|
|
end
|
|
|
|
def transition_to(status_symbol, performed_by)
|
|
self.status = status_symbol
|
|
save!
|
|
|
|
log_history(:transitioned, performed_by)
|
|
DiscourseEvent.trigger(:reviewable_transitioned_to, status_symbol, self)
|
|
|
|
if score_status = ReviewableScore.score_transitions[status_symbol]
|
|
updatable_reviewable_scores.update_all(
|
|
status: score_status,
|
|
reviewed_by_id: performed_by.id,
|
|
reviewed_at: Time.zone.now,
|
|
)
|
|
end
|
|
|
|
status_previously_changed?(from: "pending")
|
|
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 { |r| r.perform(performed_by, action, args) }
|
|
end
|
|
|
|
def self.viewable_by(user, order: nil, preload: true)
|
|
return none if user.blank?
|
|
|
|
result = self.order(order || "reviewables.score desc, reviewables.created_at desc")
|
|
|
|
if preload
|
|
target_associations = [
|
|
:user_stat,
|
|
:primary_email,
|
|
{ topic: :category },
|
|
:user_histories,
|
|
:user_custom_fields,
|
|
]
|
|
target_associations << :localizations if SiteSetting.content_localization_enabled
|
|
|
|
result =
|
|
result
|
|
.includes(
|
|
{ created_by: :user_stat },
|
|
:topic,
|
|
{ target: target_associations },
|
|
{ target_created_by: [:user_custom_fields] },
|
|
:reviewable_histories,
|
|
)
|
|
.includes(reviewable_scores: { user: :user_stat, meta_topic: :posts })
|
|
.includes(reviewable_notes: { user: :user_stat })
|
|
end
|
|
return result if user.admin?
|
|
|
|
group_ids =
|
|
SiteSetting.enable_category_group_moderation? ? user.group_users.pluck(:group_id) : []
|
|
|
|
result
|
|
.left_joins(category: :category_moderation_groups)
|
|
.where(
|
|
"(reviewables.reviewable_by_moderator AND :moderator) OR (category_moderation_groups.group_id IN (:group_ids))",
|
|
moderator: user.moderator?,
|
|
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.unseen_reviewable_count(user)
|
|
unseen_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,
|
|
claimed_by: nil,
|
|
sort_order: nil,
|
|
from_date: nil,
|
|
to_date: nil,
|
|
additional_filters: {},
|
|
preload: true,
|
|
include_claimed_by_others: true,
|
|
flagged_by: nil,
|
|
score_type: nil
|
|
)
|
|
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 none if user_id.blank?
|
|
end
|
|
return none if user.blank?
|
|
|
|
result = viewable_by(user, order: order, preload: preload)
|
|
result = by_status(result, status)
|
|
result = result.where(id: ids) if ids
|
|
result = result.where("reviewables.type = ?", Reviewable.sti_class_for(type).sti_name) 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 flagged_by
|
|
flagged_by_id = User.find_by_username(flagged_by)&.id
|
|
return none if flagged_by_id.nil?
|
|
result = result.where(<<~SQL, flagged_by_id: flagged_by_id)
|
|
EXISTS(
|
|
SELECT 1 FROM reviewable_scores
|
|
WHERE reviewable_scores.reviewable_id = reviewables.id AND reviewable_scores.user_id = :flagged_by_id
|
|
)
|
|
SQL
|
|
end
|
|
|
|
if score_type
|
|
score_type = score_type.to_i
|
|
result = result.where(<<~SQL, score_type: score_type)
|
|
EXISTS(
|
|
SELECT 1 FROM reviewable_scores
|
|
WHERE reviewable_scores.reviewable_id = reviewables.id AND reviewable_scores.reviewable_score_type = :score_type
|
|
)
|
|
SQL
|
|
end
|
|
|
|
if reviewed_by
|
|
reviewed_by_id = User.find_by_username(reviewed_by)&.id
|
|
return none 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 <> #{statuses[:pending]} AND created_by_id = #{reviewed_by_id}
|
|
) AS rh ON rh.reviewable_id = reviewables.id
|
|
SQL
|
|
end
|
|
|
|
if claimed_by
|
|
claimed_by_id = User.find_by_username(claimed_by)&.id
|
|
return none if claimed_by_id.nil?
|
|
|
|
result = result.joins(<<~SQL)
|
|
INNER JOIN reviewable_claimed_topics rct_filter
|
|
ON rct_filter.topic_id = reviewables.topic_id
|
|
SQL
|
|
result = result.where("rct_filter.user_id = ?", claimed_by_id)
|
|
end
|
|
|
|
min_score = 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.
|
|
# A ReviewableQueuedPost may have a target_created_by_id even before a target get's assigned
|
|
if user_id
|
|
result =
|
|
result.where(
|
|
"(reviewables.target_id IS NULL AND reviewables.created_by_id = :user_id)
|
|
OR (reviewables.target_created_by_id = :user_id)",
|
|
user_id: user_id,
|
|
)
|
|
end
|
|
|
|
if !include_claimed_by_others
|
|
result =
|
|
result.joins(
|
|
"LEFT JOIN reviewable_claimed_topics rct ON reviewables.topic_id = rct.topic_id",
|
|
).where("rct.user_id IS NULL OR rct.user_id = ?", user.id)
|
|
end
|
|
result = result.where(type: Reviewable.sti_names)
|
|
result = result.limit(limit) if limit
|
|
result = result.offset(offset) if offset
|
|
result
|
|
end
|
|
|
|
def self.unseen_list_for(user, preload: true, limit: nil)
|
|
results = list_for(user, preload: preload, limit: limit, include_claimed_by_others: false)
|
|
if user.last_seen_reviewable_id
|
|
results = results.where("reviewables.id > ?", user.last_seen_reviewable_id)
|
|
end
|
|
results
|
|
end
|
|
|
|
def self.user_menu_list_for(user, limit: 30)
|
|
list_for(user, limit: limit, status: :pending, include_claimed_by_others: false).to_a
|
|
end
|
|
|
|
def self.basic_serializers_for_list(reviewables, user)
|
|
reviewables.map { |r| r.basic_serializer.new(r, scope: user.guardian, root: nil) }
|
|
end
|
|
|
|
def serializer
|
|
self.class.serializer_for(self)
|
|
end
|
|
|
|
def basic_serializer
|
|
TYPE_TO_BASIC_SERIALIZER[type.to_sym] || BasicReviewableSerializer
|
|
end
|
|
|
|
def type_class
|
|
Reviewable.sti_class_for(type)
|
|
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 notify_users(result, guardian)
|
|
group_ids = Set.new([Group::AUTO_GROUPS[:staff]])
|
|
|
|
if SiteSetting.enable_category_group_moderation? && category
|
|
group_ids.merge(category.moderating_group_ids)
|
|
end
|
|
|
|
data = ReviewablePerformResultSerializer.new(result, root: false, scope: guardian).as_json
|
|
|
|
MessageBus.publish("/reviewable_action", data, group_ids: group_ids.to_a)
|
|
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: 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: self.class.statuses[:pending],
|
|
approved: self.class.statuses[:approved],
|
|
)
|
|
|
|
self.score = result[0].score
|
|
|
|
DiscourseEvent.trigger(:reviewable_score_updated, self)
|
|
|
|
score
|
|
end
|
|
|
|
def delete_user_actions(actions, bundle = nil, require_reject_reason: false)
|
|
bundle ||=
|
|
actions.add_bundle(
|
|
"reject_user",
|
|
icon: "user-xmark",
|
|
label: "reviewables.actions.reject_user.title",
|
|
)
|
|
|
|
actions.add(:delete_user, bundle: bundle) do |a|
|
|
a.icon = "user-xmark"
|
|
a.label = "reviewables.actions.reject_user.delete.title"
|
|
a.description = "reviewables.actions.reject_user.delete.description"
|
|
a.require_reject_reason = require_reject_reason
|
|
end
|
|
|
|
actions.add(:delete_user_block, bundle: bundle) 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: 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: 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
|
|
|
|
def self.find_by_flagger_or_queued_post_creator(id:, user_id:)
|
|
Reviewable.find_by(
|
|
"id = :id AND (created_by_id = :user_id
|
|
OR (target_created_by_id = :user_id AND type = 'ReviewableQueuedPost'))",
|
|
id: id,
|
|
user_id: user_id,
|
|
)
|
|
end
|
|
|
|
def self.unknown_types_and_sources
|
|
@known_sources ||= Reviewable.sti_names.map { |n| [n, Reviewable.source_for(n)] }
|
|
|
|
known_unknowns = Reviewable.pending.distinct.pluck(:type, :type_source) - @known_sources
|
|
|
|
known_unknowns
|
|
.map { |type, source| { type: type, source: source } }
|
|
.sort_by { |e| [e[:source] == UNKNOWN_TYPE_SOURCE ? 1 : 0, e[:source], e[:type]] }
|
|
end
|
|
|
|
def self.destroy_unknown_types!
|
|
Reviewable.pending.where.not(type: Reviewable.sti_names).delete_all
|
|
end
|
|
|
|
private
|
|
|
|
def delete_user_action?(action_id)
|
|
resolved_action = (aliases[action_id] || action_id).to_sym
|
|
%i[delete_user delete_and_block_user delete_user_block].include?(resolved_action)
|
|
end
|
|
|
|
def pending_reviewable_ids_for_target_user
|
|
user = target_created_by || (target if target_type == "User")
|
|
return [] if user.blank?
|
|
|
|
Reviewable
|
|
.pending
|
|
.where(target_created_by: user)
|
|
.or(Reviewable.pending.where(target: user))
|
|
.where.not(id: id)
|
|
.pluck(:id)
|
|
end
|
|
|
|
def resolved_reviewable_ids(candidate_ids)
|
|
candidate_ids - Reviewable.pending.where(id: candidate_ids).pluck(:id)
|
|
end
|
|
|
|
def aliases
|
|
self.class.action_aliases
|
|
end
|
|
|
|
def validate_action!(guardian, action_id, perform_method, args)
|
|
# Support this action or any aliases
|
|
action_aliases = [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, args)
|
|
|
|
if action_aliases.none? { |a| actions.has?(a) } || !respond_to?(perform_method)
|
|
raise InvalidAction.new(action_id, self.class)
|
|
end
|
|
end
|
|
|
|
def update_flag_stats(status:, user_ids:)
|
|
return if %i[agreed disagreed ignored].exclude?(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
|
|
# force_review :boolean default(FALSE), not null
|
|
# latest_score :datetime
|
|
# payload :json
|
|
# potential_spam :boolean default(FALSE), not null
|
|
# potentially_illegal :boolean default(FALSE)
|
|
# reject_reason :text
|
|
# reviewable_by_moderator :boolean default(FALSE), not null
|
|
# score :float default(0.0), not null
|
|
# status :integer default("pending"), not null
|
|
# target_type :string
|
|
# type :string not null
|
|
# type_source :string default("unknown"), not null
|
|
# version :integer default(0), not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# category_id :integer
|
|
# created_by_id :integer not null
|
|
# target_created_by_id :integer
|
|
# target_id :integer
|
|
# topic_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_reviewables_score_desc_created_at_desc (score,created_at)
|
|
# 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
|
|
#
|