mirror of
https://github.com/discourse/discourse.git
synced 2024-12-01 13:09:33 -06:00
e82e255531
### Why? Before, all flags were static. Therefore, they were stored in class variables and serialized by SiteSerializer. Recently, we added an option for admins to add their own flags or disable existing flags. Therefore, the class variable had to be dropped because it was unsafe for a multisite environment. However, it started causing performance problems. ### Solution When a new Flag system is used, instead of using PostActionType, we can serialize Flags and use fragment cache for performance reasons. At the same time, we are still supporting deprecated `replace_flags` API call. When it is used, we fall back to the old solution and the admin cannot add custom flags. In a couple of months, we will be able to drop that API function and clean that code properly. However, because it may still be used, redis cache was introduced to improve performance. To test backward compatibility you can add this code to any plugin ```ruby replace_flags do |flag_settings| flag_settings.add( 4, :inappropriate, topic_type: true, notify_type: true, auto_action_type: true, ) flag_settings.add(1001, :trolling, topic_type: true, notify_type: true, auto_action_type: true) end ```
282 lines
9.2 KiB
Ruby
282 lines
9.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class PostAction < ActiveRecord::Base
|
|
include RateLimiter::OnCreateRecord
|
|
include Trashable
|
|
|
|
belongs_to :post
|
|
belongs_to :user
|
|
belongs_to :post_action_type
|
|
belongs_to :related_post, class_name: "Post"
|
|
belongs_to :target_user, class_name: "User"
|
|
|
|
rate_limit :post_action_rate_limiter
|
|
|
|
scope :spam_flags, -> { where(post_action_type_id: PostActionType.types[:spam]) }
|
|
scope :flags, -> { where(post_action_type_id: PostActionType.notify_flag_type_ids) }
|
|
scope :publics, -> { where(post_action_type_id: PostActionType.public_type_ids) }
|
|
scope :active, -> { where(disagreed_at: nil, deferred_at: nil, agreed_at: nil, deleted_at: nil) }
|
|
|
|
after_save :update_counters
|
|
validate :ensure_unique_actions, on: :create
|
|
|
|
def self.counts_for(collection, user)
|
|
return {} if collection.blank? || !user
|
|
|
|
collection_ids = collection.map(&:id)
|
|
user_id = user.try(:id) || 0
|
|
|
|
post_actions = PostAction.where(post_id: collection_ids, user_id: user_id)
|
|
|
|
user_actions = {}
|
|
post_actions.each do |post_action|
|
|
user_actions[post_action.post_id] ||= {}
|
|
user_actions[post_action.post_id][post_action.post_action_type_id] = post_action
|
|
end
|
|
|
|
user_actions
|
|
end
|
|
|
|
def self.lookup_for(user, topics, post_action_type_id)
|
|
return if topics.blank?
|
|
# in critical path 2x faster than AR
|
|
#
|
|
topic_ids = topics.map(&:id)
|
|
map = {}
|
|
|
|
builder = DB.build <<~SQL
|
|
SELECT p.topic_id, p.post_number
|
|
FROM post_actions pa
|
|
JOIN posts p ON pa.post_id = p.id
|
|
WHERE p.deleted_at IS NULL AND pa.deleted_at IS NULL AND
|
|
pa.post_action_type_id = :post_action_type_id AND
|
|
pa.user_id = :user_id AND
|
|
p.topic_id IN (:topic_ids)
|
|
ORDER BY p.topic_id, p.post_number
|
|
SQL
|
|
|
|
builder
|
|
.query(user_id: user.id, post_action_type_id: post_action_type_id, topic_ids: topic_ids)
|
|
.each { |row| (map[row.topic_id] ||= []) << row.post_number }
|
|
|
|
map
|
|
end
|
|
|
|
def self.count_per_day_for_type(post_action_type, opts = nil)
|
|
opts ||= {}
|
|
result = unscoped.where(post_action_type_id: post_action_type)
|
|
result =
|
|
result.where(
|
|
"post_actions.created_at >= ?",
|
|
opts[:start_date] || (opts[:since_days_ago] || 30).days.ago,
|
|
)
|
|
result = result.where("post_actions.created_at <= ?", opts[:end_date]) if opts[:end_date]
|
|
if opts[:category_id]
|
|
if opts[:include_subcategories]
|
|
result =
|
|
result.joins(post: :topic).where(
|
|
"topics.category_id IN (?)",
|
|
Category.subcategory_ids(opts[:category_id]),
|
|
)
|
|
else
|
|
result = result.joins(post: :topic).where("topics.category_id = ?", opts[:category_id])
|
|
end
|
|
end
|
|
|
|
if opts[:group_ids]
|
|
result =
|
|
result
|
|
.joins("INNER JOIN users ON users.id = post_actions.user_id")
|
|
.joins("INNER JOIN group_users ON group_users.user_id = users.id")
|
|
.where("group_users.group_id IN (?)", opts[:group_ids])
|
|
end
|
|
|
|
result.group("date(post_actions.created_at)").order("date(post_actions.created_at)").count
|
|
end
|
|
|
|
def add_moderator_post_if_needed(moderator, disposition, delete_post = false)
|
|
return if !SiteSetting.auto_respond_to_flag_actions
|
|
return if related_post.nil? || related_post.topic.nil?
|
|
return if staff_already_replied?(related_post.topic)
|
|
message_key = +"flags_dispositions.#{disposition}"
|
|
message_key << "_and_deleted" if delete_post
|
|
|
|
I18n.with_locale(SiteSetting.default_locale) do
|
|
related_post.topic.add_moderator_post(moderator, I18n.t(message_key))
|
|
end
|
|
|
|
# archive message for moderators
|
|
GroupArchivedMessage.archive!(Group[:moderators].id, related_post.topic)
|
|
end
|
|
|
|
def staff_already_replied?(topic)
|
|
topic
|
|
.posts
|
|
.where(
|
|
"user_id IN (SELECT id FROM users WHERE moderator OR admin) OR (post_type != :regular_post_type)",
|
|
regular_post_type: Post.types[:regular],
|
|
)
|
|
.exists?
|
|
end
|
|
|
|
def self.limit_action!(user, post, post_action_type_id)
|
|
RateLimiter.new(user, "post_action-#{post.id}_#{post_action_type_id}", 4, 1.minute).performed!
|
|
end
|
|
|
|
def self.copy(original_post, target_post)
|
|
cols_to_copy = (column_names - %w[id post_id]).join(", ")
|
|
|
|
DB.exec <<~SQL
|
|
INSERT INTO post_actions(post_id, #{cols_to_copy})
|
|
SELECT #{target_post.id}, #{cols_to_copy}
|
|
FROM post_actions
|
|
WHERE post_id = #{original_post.id}
|
|
SQL
|
|
|
|
target_post.post_actions.each { |post_action| post_action.update_counters }
|
|
end
|
|
|
|
def remove_act!(user)
|
|
trash!(user)
|
|
# NOTE: save is called to ensure all callbacks are called
|
|
# trash will not trigger callbacks, and triggering after_commit
|
|
# is not trivial
|
|
save
|
|
end
|
|
|
|
def post_action_type_view
|
|
@post_action_type_view ||= PostActionTypeView.new
|
|
end
|
|
|
|
def is_like?
|
|
post_action_type_id == post_action_type_view.types[:like]
|
|
end
|
|
|
|
def is_flag?
|
|
!!post_action_type_view.notify_flag_types[post_action_type_id]
|
|
end
|
|
|
|
def is_private_message?
|
|
post_action_type_id == post_action_type_view.types[:notify_user] ||
|
|
post_action_type_id == post_action_type_view.types[:notify_moderators]
|
|
end
|
|
|
|
# A custom rate limiter for this model
|
|
def post_action_rate_limiter
|
|
return unless is_flag? || is_like?
|
|
|
|
return @rate_limiter if @rate_limiter.present?
|
|
|
|
%w[like flag].each do |type|
|
|
if public_send("is_#{type}?")
|
|
limit = SiteSetting.get("max_#{type}s_per_day")
|
|
|
|
if (is_flag? || is_like?) && user && user.trust_level >= 2
|
|
multiplier =
|
|
SiteSetting.get("tl#{user.trust_level}_additional_#{type}s_per_day_multiplier").to_f
|
|
multiplier = 1.0 if multiplier < 1.0
|
|
|
|
limit = (limit * multiplier).to_i
|
|
end
|
|
|
|
@rate_limiter = RateLimiter.new(user, "create_#{type}", limit, 1.day.to_i)
|
|
return @rate_limiter
|
|
end
|
|
end
|
|
end
|
|
|
|
def ensure_unique_actions
|
|
post_action_type_ids =
|
|
is_flag? ? post_action_type_view.notify_flag_types.values : post_action_type_id
|
|
|
|
acted =
|
|
PostAction
|
|
.where(user_id: user_id)
|
|
.where(post_id: post_id)
|
|
.where(post_action_type_id: post_action_type_ids)
|
|
.where(deleted_at: nil)
|
|
.where(disagreed_at: nil)
|
|
.where(targets_topic: targets_topic)
|
|
.exists?
|
|
|
|
errors.add(:post_action_type_id) if acted
|
|
end
|
|
|
|
def post_action_type_key
|
|
post_action_type_view.types[post_action_type_id]
|
|
end
|
|
|
|
def update_counters
|
|
# Update denormalized counts
|
|
column = "#{post_action_type_key}_count"
|
|
count = PostAction.where(post_id: post_id).where(post_action_type_id: post_action_type_id).count
|
|
|
|
# We probably want to refactor this method to something cleaner.
|
|
case post_action_type_key
|
|
when :like
|
|
# 'like_score' is weighted higher for staff accounts
|
|
score =
|
|
PostAction
|
|
.joins(:user)
|
|
.where(post_id: post_id)
|
|
.sum(
|
|
"CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END",
|
|
)
|
|
Post.where(id: post_id).update_all [
|
|
"like_count = :count, like_score = :score",
|
|
count: count,
|
|
score: score,
|
|
]
|
|
else
|
|
if ActiveRecord::Base.connection.column_exists?(:posts, column)
|
|
Post.where(id: post_id).update_all ["#{column} = ?", count]
|
|
end
|
|
end
|
|
|
|
topic_id = Post.with_deleted.where(id: post_id).pick(:topic_id)
|
|
|
|
# topic_user
|
|
if post_action_type_key == :like
|
|
TopicUser.update_post_action_cache(
|
|
user_id: user_id,
|
|
topic_id: topic_id,
|
|
post_action_type: post_action_type_key,
|
|
)
|
|
end
|
|
|
|
Topic.find_by(id: topic_id)&.update_action_counts if column == "like_count"
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: post_actions
|
|
#
|
|
# id :integer not null, primary key
|
|
# post_id :integer not null
|
|
# user_id :integer not null
|
|
# post_action_type_id :integer not null
|
|
# deleted_at :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# deleted_by_id :integer
|
|
# related_post_id :integer
|
|
# staff_took_action :boolean default(FALSE), not null
|
|
# deferred_by_id :integer
|
|
# targets_topic :boolean default(FALSE), not null
|
|
# agreed_at :datetime
|
|
# agreed_by_id :integer
|
|
# deferred_at :datetime
|
|
# disagreed_at :datetime
|
|
# disagreed_by_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_unique_actions (user_id,post_action_type_id,post_id,targets_topic) UNIQUE WHERE ((deleted_at IS NULL) AND (disagreed_at IS NULL) AND (deferred_at IS NULL))
|
|
# idx_unique_flags (user_id,post_id,targets_topic) UNIQUE WHERE ((deleted_at IS NULL) AND (disagreed_at IS NULL) AND (deferred_at IS NULL) AND (post_action_type_id = ANY (ARRAY[3, 4, 7, 8])))
|
|
# index_post_actions_on_post_action_type_id_and_disagreed_at (post_action_type_id,disagreed_at) WHERE (disagreed_at IS NULL)
|
|
# index_post_actions_on_post_id (post_id)
|
|
# index_post_actions_on_user_id (user_id)
|
|
# index_post_actions_on_user_id_and_post_action_type_id (user_id,post_action_type_id) WHERE (deleted_at IS NULL)
|
|
#
|