discourse/app/models/post_action.rb
Krzysztof Kotlarek 094052c1ff
FIX: serialize Flags instead of PostActionType (#28259)
### 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
```
2024-08-13 11:22:37 +10:00

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)
#