2019-05-03 06:17:27 +08:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
class Reviewable < ActiveRecord::Base
|
2022-07-28 16:16:33 +08:00
|
|
|
TYPE_TO_BASIC_SERIALIZER = {
|
|
|
|
ReviewableFlaggedPost: BasicReviewableFlaggedPostSerializer,
|
|
|
|
ReviewableQueuedPost: BasicReviewableQueuedPostSerializer,
|
|
|
|
ReviewableUser: BasicReviewableUserSerializer,
|
|
|
|
}
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
class UpdateConflict < StandardError
|
|
|
|
end
|
|
|
|
|
|
|
|
class InvalidAction < StandardError
|
|
|
|
def initialize(action_id, klass)
|
|
|
|
@action_id, @klass = action_id, klass
|
2020-12-02 21:38:59 +08:00
|
|
|
super("Can't perform `#{action_id}` on #{klass.name}")
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-04-18 05:12:32 +08:00
|
|
|
before_save :apply_review_group
|
2019-04-24 03:53:37 +08:00
|
|
|
attr_accessor :created_new
|
2019-01-04 01:03:01 +08:00
|
|
|
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
|
|
|
|
|
2022-10-06 00:38:41 +08:00
|
|
|
has_many :reviewable_histories, dependent: :destroy
|
|
|
|
has_many :reviewable_scores, -> { order(created_at: :desc) }, dependent: :destroy
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2021-12-09 01:12:24 +08:00
|
|
|
enum :status, { pending: 0, approved: 1, rejected: 2, ignored: 3, deleted: 4 }
|
|
|
|
enum :priority, { low: 0, medium: 5, high: 10 }, scopes: false, suffix: true
|
|
|
|
enum :sensitivity, { disabled: 0, low: 9, medium: 6, high: 3 }, scopes: false, suffix: true
|
|
|
|
|
2023-04-27 23:46:25 +08:00
|
|
|
validates :reject_reason, length: { maximum: 500 }
|
|
|
|
|
2019-04-05 04:07:34 +08:00
|
|
|
after_create { log_history(:created, created_by) }
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
after_commit(on: :create) { DiscourseEvent.trigger(:reviewable_created, self) }
|
2019-08-08 22:04:34 +08:00
|
|
|
|
|
|
|
after_commit(on: %i[create update]) do
|
2019-01-04 01:03:01 +08:00
|
|
|
Jobs.enqueue(:notify_reviewable, reviewable_id: self.id) if pending?
|
|
|
|
end
|
|
|
|
|
2019-08-01 23:23:23 +08:00
|
|
|
# Can be used if several actions are equivalent
|
|
|
|
def self.action_aliases
|
|
|
|
{}
|
|
|
|
end
|
|
|
|
|
2019-09-20 01:17:00 +08:00
|
|
|
# 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
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
def self.default_visible
|
2019-05-08 01:25:11 +08:00
|
|
|
where("score >= ?", min_score_for_priority)
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.valid_type?(type)
|
2023-05-02 20:21:14 +08:00
|
|
|
return false unless Reviewable.types.include?(type)
|
2019-01-04 01:03:01 +08:00
|
|
|
type.constantize <= Reviewable
|
|
|
|
rescue NameError
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.types
|
2021-04-21 19:41:36 +08:00
|
|
|
%w[ReviewableFlaggedPost ReviewableQueuedPost ReviewableUser ReviewablePost]
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
2019-11-23 03:33:10 +08:00
|
|
|
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
|
|
|
|
|
2019-05-02 02:40:38 +08:00
|
|
|
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
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
# 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
|
|
|
|
)
|
2019-04-18 05:12:32 +08:00
|
|
|
reviewable =
|
|
|
|
new(
|
2019-01-04 01:03:01 +08:00
|
|
|
target: target,
|
|
|
|
topic: topic,
|
|
|
|
created_by: created_by,
|
2020-03-20 23:28:09 +08:00
|
|
|
reviewable_by_moderator: reviewable_by_moderator,
|
2019-01-04 01:03:01 +08:00
|
|
|
payload: payload,
|
|
|
|
potential_spam: potential_spam,
|
|
|
|
)
|
2019-05-02 02:40:38 +08:00
|
|
|
reviewable.created_new!
|
2019-04-02 05:07:47 +08:00
|
|
|
|
2021-07-08 00:45:00 +08:00
|
|
|
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
|
2020-01-10 01:02:41 +08:00
|
|
|
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,
|
2023-04-17 21:41:56 +08:00
|
|
|
type: target.class.polymorphic_name,
|
2020-01-10 01:02:41 +08:00
|
|
|
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)
|
2021-05-11 01:09:04 +08:00
|
|
|
|
|
|
|
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
|
2020-01-10 01:02:41 +08:00
|
|
|
end
|
|
|
|
end
|
2019-04-02 05:07:47 +08:00
|
|
|
|
|
|
|
reviewable
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def add_score(
|
|
|
|
user,
|
|
|
|
reviewable_score_type,
|
2019-04-11 23:11:35 +08:00
|
|
|
reason: nil,
|
2019-01-04 01:03:01 +08:00
|
|
|
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
|
2019-10-08 21:49:07 +08:00
|
|
|
user_accuracy_bonus = ReviewableScore.user_accuracy_bonus(user)
|
2019-10-12 00:07:19 +08:00
|
|
|
sub_total = ReviewableScore.calculate_score(user, type_bonus, take_action_bonus)
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2019-04-11 23:11:35 +08:00
|
|
|
rs =
|
|
|
|
reviewable_scores.new(
|
2019-01-04 01:03:01 +08:00
|
|
|
user: user,
|
2021-12-09 01:12:24 +08:00
|
|
|
status: :pending,
|
2019-01-04 01:03:01 +08:00
|
|
|
reviewable_score_type: reviewable_score_type,
|
|
|
|
score: sub_total,
|
2019-10-08 21:49:07 +08:00
|
|
|
user_accuracy_bonus: user_accuracy_bonus,
|
2019-01-04 01:03:01 +08:00
|
|
|
meta_topic_id: meta_topic_id,
|
|
|
|
take_action_bonus: take_action_bonus,
|
|
|
|
created_at: created_at || Time.zone.now,
|
|
|
|
)
|
2019-04-11 23:11:35 +08:00
|
|
|
rs.reason = reason.to_s if reason
|
|
|
|
rs.save!
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2020-11-13 19:19:01 +08:00
|
|
|
update(score: self.score + rs.score, latest_score: rs.created_at, force_review: force_review)
|
2019-01-04 01:03:01 +08:00
|
|
|
topic.update(reviewable_score: topic.reviewable_score + rs.score) if topic
|
|
|
|
|
2021-04-27 08:40:32 +08:00
|
|
|
DiscourseEvent.trigger(:reviewable_score_updated, self)
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
rs
|
|
|
|
end
|
|
|
|
|
2019-05-23 05:23:45 +08:00
|
|
|
def self.set_priorities(values)
|
|
|
|
values.each do |k, v|
|
2021-12-09 01:12:24 +08:00
|
|
|
id = priorities[k]
|
2019-05-23 05:23:45 +08:00
|
|
|
PluginStore.set("reviewables", "priority_#{id}", v) unless id.nil?
|
|
|
|
end
|
2019-05-08 01:25:11 +08:00
|
|
|
end
|
|
|
|
|
2019-08-07 03:34:39 +08:00
|
|
|
def self.sensitivity_score_value(sensitivity, scale)
|
2019-05-25 02:13:03 +08:00
|
|
|
return Float::MAX if sensitivity == 0
|
|
|
|
|
2021-12-09 01:12:24 +08:00
|
|
|
ratio = sensitivity / sensitivities[:low].to_f
|
2019-09-20 01:17:00 +08:00
|
|
|
high =
|
2021-12-09 01:12:24 +08:00
|
|
|
(PluginStore.get("reviewables", "priority_#{priorities[:high]}") || typical_sensitivity).to_f
|
2019-05-25 02:13:03 +08:00
|
|
|
|
|
|
|
# We want this to be hard to reach
|
2019-09-20 01:17:00 +08:00
|
|
|
((high.to_f * ratio) * scale).truncate(2)
|
2019-05-25 02:13:03 +08:00
|
|
|
end
|
|
|
|
|
2019-08-07 03:34:39 +08:00
|
|
|
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
|
|
|
|
|
2019-05-25 02:13:03 +08:00
|
|
|
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
|
|
|
|
|
2019-05-08 01:25:11 +08:00
|
|
|
def self.min_score_for_priority(priority = nil)
|
|
|
|
priority ||= SiteSetting.reviewable_default_visibility
|
2021-12-09 01:12:24 +08:00
|
|
|
id = priorities[priority]
|
2019-05-23 05:23:45 +08:00
|
|
|
return 0.0 if id.nil?
|
2019-11-15 04:10:51 +08:00
|
|
|
PluginStore.get("reviewables", "priority_#{id}").to_f
|
2019-05-08 01:25:11 +08:00
|
|
|
end
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
def history
|
|
|
|
reviewable_histories.order(:created_at)
|
|
|
|
end
|
|
|
|
|
|
|
|
def log_history(reviewable_history_type, performed_by, edited: nil)
|
|
|
|
reviewable_histories.create!(
|
2021-12-09 01:12:24 +08:00
|
|
|
reviewable_history_type: reviewable_history_type,
|
2019-01-04 01:03:01 +08:00
|
|
|
status: status,
|
|
|
|
created_by: performed_by,
|
|
|
|
edited: edited,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2019-04-18 05:12:32 +08:00
|
|
|
def apply_review_group
|
2020-07-15 00:36:19 +08:00
|
|
|
unless SiteSetting.enable_category_group_moderation? && category.present? &&
|
2019-05-08 22:20:51 +08:00
|
|
|
category.reviewable_by_group_id
|
2023-01-09 20:20:10 +08:00
|
|
|
return
|
|
|
|
end
|
2019-05-08 22:20:51 +08:00
|
|
|
|
2019-04-18 05:12:32 +08:00
|
|
|
self.reviewable_by_group_id = category.reviewable_by_group_id
|
|
|
|
end
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
def actions_for(guardian, args = nil)
|
|
|
|
args ||= {}
|
2019-05-08 22:20:51 +08:00
|
|
|
|
|
|
|
Actions.new(self, guardian).tap { |actions| build_actions(actions, guardian, args) }
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def editable_for(guardian, args = nil)
|
|
|
|
args ||= {}
|
2019-05-08 22:20:51 +08:00
|
|
|
EditableFields
|
|
|
|
.new(self, guardian, args)
|
|
|
|
.tap { |fields| build_editable_fields(fields, guardian, args) }
|
2019-01-04 01:03:01 +08:00
|
|
|
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 ||= {}
|
2019-08-01 23:23:23 +08:00
|
|
|
# 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
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
# Ensure the user has access to the action
|
2023-04-25 09:22:37 +08:00
|
|
|
actions = actions_for(args[:guardian] || Guardian.new(performed_by), args)
|
2019-08-01 23:23:23 +08:00
|
|
|
raise InvalidAction.new(action_id, self.class) unless valid.any? { |a| actions.has?(a) }
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2019-08-01 23:23:23 +08:00
|
|
|
perform_method = "perform_#{aliases[action_id] || action_id}".to_sym
|
2019-01-04 01:03:01 +08:00
|
|
|
raise InvalidAction.new(action_id, self.class) unless respond_to?(perform_method)
|
|
|
|
|
|
|
|
result = nil
|
2019-04-05 04:07:34 +08:00
|
|
|
update_count = false
|
2019-01-04 01:03:01 +08:00
|
|
|
Reviewable.transaction do
|
|
|
|
increment_version!(args[:version])
|
2019-05-07 09:57:55 +08:00
|
|
|
result = public_send(perform_method, performed_by, args)
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2019-05-10 21:53:25 +08:00
|
|
|
raise ActiveRecord::Rollback unless result.success?
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2019-05-10 21:53:25 +08:00
|
|
|
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
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
2019-04-24 05:29:46 +08:00
|
|
|
result.after_commit.call if result && result.after_commit
|
2021-05-26 07:47:35 +08:00
|
|
|
|
|
|
|
if update_count || result.remove_reviewable_ids.present?
|
|
|
|
Jobs.enqueue(
|
|
|
|
:notify_reviewable,
|
|
|
|
reviewable_id: self.id,
|
2021-07-17 00:57:12 +08:00
|
|
|
performing_username: performed_by.username,
|
|
|
|
updated_reviewable_ids: result.remove_reviewable_ids,
|
2021-05-26 07:47:35 +08:00
|
|
|
)
|
|
|
|
end
|
2019-04-05 04:07:34 +08:00
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
result
|
|
|
|
end
|
|
|
|
|
|
|
|
def transition_to(status_symbol, performed_by)
|
2021-12-09 01:12:24 +08:00
|
|
|
self.status = status_symbol
|
2019-01-04 01:03:01 +08:00
|
|
|
save!
|
2019-04-02 05:07:47 +08:00
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
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
|
|
|
|
|
2021-12-09 01:12:24 +08:00
|
|
|
status_previously_changed?(from: "pending")
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def post_options
|
|
|
|
Discourse.deprecate(
|
|
|
|
"Reviewable#post_options is deprecated. Please use #payload instead.",
|
2021-11-12 22:52:59 +08:00
|
|
|
output_in_test: true,
|
|
|
|
drop_from: "2.9.0",
|
2019-01-04 01:03:01 +08:00
|
|
|
)
|
|
|
|
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 unless user.present?
|
|
|
|
|
2020-05-13 07:05:56 +08:00
|
|
|
result = self.order(order || "reviewables.score desc, reviewables.created_at desc")
|
2019-01-04 01:03:01 +08:00
|
|
|
|
|
|
|
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?
|
|
|
|
|
2020-07-15 00:36:19 +08:00
|
|
|
group_ids =
|
|
|
|
SiteSetting.enable_category_group_moderation? ? user.group_users.pluck(:group_id) : []
|
2019-04-18 05:12:32 +08:00
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
result.where(
|
2020-05-13 07:05:56 +08:00
|
|
|
"(reviewables.reviewable_by_moderator AND :staff) OR (reviewables.reviewable_by_group_id IN (:group_ids))",
|
2019-01-04 01:03:01 +08:00
|
|
|
staff: user.staff?,
|
2019-04-18 05:12:32 +08:00
|
|
|
group_ids: group_ids,
|
2020-05-13 07:05:56 +08:00
|
|
|
).where(
|
|
|
|
"reviewables.category_id IS NULL OR reviewables.category_id IN (?)",
|
|
|
|
Guardian.new(user).allowed_category_ids,
|
|
|
|
)
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.pending_count(user)
|
|
|
|
list_for(user).count
|
|
|
|
end
|
|
|
|
|
2022-12-01 07:09:57 +08:00
|
|
|
def self.unseen_reviewable_count(user)
|
|
|
|
self.unseen_list_for(user).count
|
|
|
|
end
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
def self.list_for(
|
|
|
|
user,
|
2021-02-26 18:56:14 +08:00
|
|
|
ids: nil,
|
2019-01-04 01:03:01 +08:00
|
|
|
status: :pending,
|
|
|
|
category_id: nil,
|
|
|
|
topic_id: nil,
|
|
|
|
type: nil,
|
|
|
|
limit: nil,
|
|
|
|
offset: nil,
|
2019-05-08 01:25:11 +08:00
|
|
|
priority: nil,
|
2019-06-06 01:19:57 +08:00
|
|
|
username: nil,
|
2020-12-05 01:09:05 +08:00
|
|
|
reviewed_by: nil,
|
2019-11-16 02:29:59 +08:00
|
|
|
sort_order: nil,
|
|
|
|
from_date: nil,
|
2019-11-23 03:33:10 +08:00
|
|
|
to_date: nil,
|
2022-07-28 16:16:33 +08:00
|
|
|
additional_filters: {},
|
2022-12-01 07:09:57 +08:00
|
|
|
preload: true,
|
|
|
|
include_claimed_by_others: true
|
2019-01-04 01:03:01 +08:00
|
|
|
)
|
2019-06-06 01:19:57 +08:00
|
|
|
order =
|
|
|
|
case sort_order
|
2020-05-27 23:50:28 +08:00
|
|
|
when "score_asc"
|
2020-05-13 07:05:56 +08:00
|
|
|
"reviewables.score ASC, reviewables.created_at DESC"
|
2019-06-06 01:19:57 +08:00
|
|
|
when "created_at"
|
2020-05-13 07:05:56 +08:00
|
|
|
"reviewables.created_at DESC, reviewables.score DESC"
|
2019-06-06 01:19:57 +08:00
|
|
|
when "created_at_asc"
|
2020-05-13 07:05:56 +08:00
|
|
|
"reviewables.created_at ASC, reviewables.score DESC"
|
2019-06-06 01:19:57 +08:00
|
|
|
else
|
2020-05-13 07:05:56 +08:00
|
|
|
"reviewables.score DESC, reviewables.created_at DESC"
|
2019-06-06 01:19:57 +08:00
|
|
|
end
|
2019-01-04 01:03:01 +08:00
|
|
|
|
|
|
|
if username.present?
|
|
|
|
user_id = User.find_by_username(username)&.id
|
2022-07-28 16:16:33 +08:00
|
|
|
return none if user_id.blank?
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
2022-07-28 16:16:33 +08:00
|
|
|
return none if user.blank?
|
|
|
|
result = viewable_by(user, order: order, preload: preload)
|
2019-01-04 01:03:01 +08:00
|
|
|
|
|
|
|
result = by_status(result, status)
|
2021-02-26 18:56:14 +08:00
|
|
|
result = result.where(id: ids) if ids
|
2023-05-02 20:21:14 +08:00
|
|
|
|
|
|
|
result = result.where("reviewables.type = ?", Reviewable.sti_class_for(type).sti_name) if type
|
2020-05-13 07:05:56 +08:00
|
|
|
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
|
2019-01-04 01:03:01 +08:00
|
|
|
|
2020-12-05 01:09:05 +08:00
|
|
|
if reviewed_by
|
|
|
|
reviewed_by_id = User.find_by_username(reviewed_by)&.id
|
2022-07-28 16:16:33 +08:00
|
|
|
return none if reviewed_by_id.nil?
|
2020-12-05 01:09:05 +08:00
|
|
|
|
|
|
|
result = result.joins(<<~SQL)
|
|
|
|
INNER JOIN(
|
|
|
|
SELECT reviewable_id
|
|
|
|
FROM reviewable_histories
|
|
|
|
WHERE reviewable_history_type = #{ReviewableHistory.types[:transitioned]} AND
|
2021-12-09 01:12:24 +08:00
|
|
|
status <> #{statuses[:pending]} AND created_by_id = #{reviewed_by_id}
|
2020-12-05 01:09:05 +08:00
|
|
|
) AS rh ON rh.reviewable_id = reviewables.id
|
|
|
|
SQL
|
|
|
|
end
|
|
|
|
|
2021-12-09 01:12:24 +08:00
|
|
|
min_score = min_score_for_priority(priority)
|
2021-04-24 02:34:24 +08:00
|
|
|
|
2020-11-13 19:19:01 +08:00
|
|
|
if min_score > 0 && status == :pending
|
|
|
|
result = result.where("reviewables.score >= ? OR reviewables.force_review", min_score)
|
2020-02-12 02:29:22 +08:00
|
|
|
elsif min_score > 0
|
2020-05-13 07:05:56 +08:00
|
|
|
result = result.where("reviewables.score >= ?", min_score)
|
2020-02-12 02:29:22 +08:00
|
|
|
end
|
|
|
|
|
2019-11-23 03:33:10 +08:00
|
|
|
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
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
# If a reviewable doesn't have a target, allow us to filter on who created that reviewable.
|
|
|
|
if user_id
|
|
|
|
result =
|
|
|
|
result.where(
|
2020-05-13 07:05:56 +08:00
|
|
|
"(reviewables.target_created_by_id IS NULL AND reviewables.created_by_id = :user_id)
|
|
|
|
OR (reviewables.target_created_by_id = :user_id)",
|
2019-01-04 01:03:01 +08:00
|
|
|
user_id: user_id,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2022-12-01 07:09:57 +08:00
|
|
|
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
|
2019-01-04 01:03:01 +08:00
|
|
|
result = result.limit(limit) if limit
|
|
|
|
result = result.offset(offset) if offset
|
|
|
|
result
|
|
|
|
end
|
|
|
|
|
2022-08-03 13:57:59 +08:00
|
|
|
def self.unseen_list_for(user, preload: true, limit: nil)
|
2022-12-01 07:09:57 +08:00
|
|
|
results = list_for(user, preload: preload, limit: limit, include_claimed_by_others: false)
|
2022-08-03 13:57:59 +08:00
|
|
|
if user.last_seen_reviewable_id
|
|
|
|
results = results.where("reviewables.id > ?", user.last_seen_reviewable_id)
|
|
|
|
end
|
|
|
|
results
|
|
|
|
end
|
|
|
|
|
2022-10-05 17:30:02 +08:00
|
|
|
def self.user_menu_list_for(user, limit: 30)
|
2022-12-01 07:09:57 +08:00
|
|
|
list_for(user, limit: limit, status: :pending, include_claimed_by_others: false).to_a
|
2022-10-05 17:30:02 +08:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.basic_serializers_for_list(reviewables, user)
|
|
|
|
reviewables.map { |r| r.basic_serializer.new(r, scope: user.guardian, root: nil) }
|
|
|
|
end
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
def serializer
|
|
|
|
self.class.serializer_for(self)
|
|
|
|
end
|
|
|
|
|
2022-07-28 16:16:33 +08:00
|
|
|
def basic_serializer
|
|
|
|
TYPE_TO_BASIC_SERIALIZER[self.type.to_sym] || BasicReviewableSerializer
|
|
|
|
end
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
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
|
|
|
|
|
2020-04-22 16:52:50 +08:00
|
|
|
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
|
2019-01-04 01:03:01 +08:00
|
|
|
.group("date(reviewable_scores.created_at)")
|
|
|
|
.order("date(reviewable_scores.created_at)")
|
|
|
|
.count
|
|
|
|
end
|
|
|
|
|
2019-09-04 23:56:25 +08:00
|
|
|
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,
|
2019-10-08 21:49:07 +08:00
|
|
|
rs.user_accuracy_bonus,
|
2019-09-04 23:56:25 +08:00
|
|
|
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
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
def recalculate_score
|
2019-06-26 23:20:59 +08:00
|
|
|
# pending/agreed scores count
|
|
|
|
sql = <<~SQL
|
2019-01-04 01:03:01 +08:00
|
|
|
UPDATE reviewables
|
|
|
|
SET score = COALESCE((
|
|
|
|
SELECT sum(score)
|
|
|
|
FROM reviewable_scores AS rs
|
|
|
|
WHERE rs.reviewable_id = :id
|
2019-06-26 23:20:59 +08:00
|
|
|
AND rs.status IN (:pending, :agreed)
|
2019-01-04 01:03:01 +08:00
|
|
|
), 0.0)
|
|
|
|
WHERE id = :id
|
|
|
|
RETURNING score
|
|
|
|
SQL
|
|
|
|
|
2019-06-26 23:20:59 +08:00
|
|
|
result =
|
|
|
|
DB.query(
|
|
|
|
sql,
|
|
|
|
id: self.id,
|
|
|
|
pending: ReviewableScore.statuses[:pending],
|
|
|
|
agreed: ReviewableScore.statuses[:agreed],
|
|
|
|
)
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
# Update topic score
|
2019-06-26 23:20:59 +08:00
|
|
|
sql = <<~SQL
|
2019-01-04 01:03:01 +08:00
|
|
|
UPDATE topics
|
|
|
|
SET reviewable_score = COALESCE((
|
|
|
|
SELECT SUM(score)
|
|
|
|
FROM reviewables AS r
|
|
|
|
WHERE r.topic_id = :topic_id
|
2019-06-26 23:20:59 +08:00
|
|
|
AND r.status IN (:pending, :approved)
|
2019-01-04 01:03:01 +08:00
|
|
|
), 0.0)
|
|
|
|
WHERE id = :topic_id
|
|
|
|
SQL
|
|
|
|
|
2019-06-26 23:20:59 +08:00
|
|
|
DB.query(
|
|
|
|
sql,
|
|
|
|
topic_id: topic_id,
|
2021-12-09 01:12:24 +08:00
|
|
|
pending: self.class.statuses[:pending],
|
|
|
|
approved: self.class.statuses[:approved],
|
2019-06-26 23:20:59 +08:00
|
|
|
)
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
self.score = result[0].score
|
2021-04-27 08:40:32 +08:00
|
|
|
|
|
|
|
DiscourseEvent.trigger(:reviewable_score_updated, self)
|
|
|
|
|
|
|
|
self.score
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
|
2021-06-15 23:35:45 +08:00
|
|
|
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
|
|
|
|
|
2021-05-11 01:09:04 +08:00
|
|
|
protected
|
|
|
|
|
2019-01-04 01:03:01 +08:00
|
|
|
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
|
2019-12-31 01:56:17 +08:00
|
|
|
partial_result.where(status: statuses.except(:pending).values)
|
2019-01-04 01:03:01 +08:00
|
|
|
else
|
|
|
|
partial_result.where(status: statuses[status])
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
def update_flag_stats(status:, user_ids:)
|
|
|
|
return unless %i[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
|
|
|
|
|
2019-04-01 21:53:55 +08:00
|
|
|
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)
|
2019-01-04 01:03:01 +08:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# == Schema Information
|
|
|
|
#
|
|
|
|
# Table name: reviewables
|
|
|
|
#
|
2019-05-03 06:34:12 +08:00
|
|
|
# id :bigint not null, primary key
|
2019-01-04 01:03:01 +08:00
|
|
|
# type :string not null
|
2021-12-09 01:12:24 +08:00
|
|
|
# status :integer default("pending"), not null
|
2019-01-04 01:03:01 +08:00
|
|
|
# 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
|
2021-01-15 06:43:26 +08:00
|
|
|
# force_review :boolean default(FALSE), not null
|
|
|
|
# reject_reason :text
|
2019-01-04 01:03:01 +08:00
|
|
|
#
|
|
|
|
# Indexes
|
|
|
|
#
|
2022-07-28 16:16:33 +08:00
|
|
|
# idx_reviewables_score_desc_created_at_desc (score,created_at)
|
2019-05-03 06:34:12 +08:00
|
|
|
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
|
2019-04-12 21:55:27 +08:00
|
|
|
# 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)
|
2020-09-01 16:00:36 +08:00
|
|
|
# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text)
|
2019-04-12 21:55:27 +08:00
|
|
|
# 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
|
2019-01-04 01:03:01 +08:00
|
|
|
#
|