discourse/app/models/post_mover.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

547 lines
19 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
class PostMover
attr_reader :original_topic, :destination_topic, :user, :post_ids
def self.move_types
@move_types ||= Enum.new(:new_topic, :existing_topic)
end
def initialize(original_topic, user, post_ids, move_to_pm: false)
@original_topic = original_topic
@user = user
@post_ids = post_ids
@move_to_pm = move_to_pm
end
def to_topic(id, participants: nil)
@move_type = PostMover.move_types[:existing_topic]
topic = Topic.find_by_id(id)
if topic.archetype != @original_topic.archetype &&
[@original_topic.archetype, topic.archetype].include?(Archetype.private_message)
raise Discourse::InvalidParameters
end
Topic.transaction do
move_posts_to topic
end
add_allowed_users(participants) if participants.present? && @move_to_pm
enqueue_jobs(topic)
topic
end
def to_new_topic(title, category_id = nil, tags = nil)
@move_type = PostMover.move_types[:new_topic]
post = Post.find_by(id: post_ids.first)
raise Discourse::InvalidParameters unless post
archetype = @move_to_pm ? Archetype.private_message : Archetype.default
topic = Topic.transaction do
new_topic = Topic.create!(
user: post.user,
title: title,
category_id: category_id,
created_at: post.created_at,
archetype: archetype
)
DiscourseTagging.tag_topic_by_names(new_topic, Guardian.new(user), tags)
move_posts_to new_topic
2018-07-20 03:13:27 -04:00
watch_new_topic
update_topic_excerpt new_topic
new_topic
end
enqueue_jobs(topic)
topic
end
private
def update_topic_excerpt(topic)
topic.update_excerpt(topic.first_post.excerpt_for_topic)
end
def move_posts_to(topic)
Guardian.new(user).ensure_can_see! topic
@destination_topic = topic
moving_all_posts = (@original_topic.posts.pluck(:id).sort == @post_ids.sort)
create_temp_table
delete_invalid_post_timings
move_each_post
notify_users_that_posts_have_moved
update_statistics
update_user_actions
update_last_post_stats
update_upload_security_status
if moving_all_posts
@original_topic.update_status('closed', true, @user)
end
destination_topic.reload
destination_topic
end
def create_temp_table
DB.exec("DROP TABLE IF EXISTS moved_posts") if Rails.env.test?
DB.exec <<~SQL
CREATE TEMPORARY TABLE moved_posts (
old_topic_id INTEGER,
old_post_id INTEGER,
old_post_number INTEGER,
new_topic_id INTEGER,
new_topic_title VARCHAR,
new_post_id INTEGER,
new_post_number INTEGER
) ON COMMIT DROP;
CREATE INDEX moved_posts_old_post_number ON moved_posts(old_post_number);
CREATE INDEX moved_posts_old_post_id ON moved_posts(old_post_id);
SQL
end
def move_each_post
max_post_number = destination_topic.max_post_number + 1
@post_creator = nil
@move_map = {}
@reply_count = {}
posts.each_with_index do |post, offset|
@move_map[post.post_number] = offset + max_post_number
if post.reply_to_post_number.present?
@reply_count[post.reply_to_post_number] = (@reply_count[post.reply_to_post_number] || 0) + 1
end
end
posts.each do |post|
metadata = movement_metadata(post)
new_post = post.is_first_post? ? create_first_post(post) : move(post)
store_movement(metadata, new_post)
if @move_to_pm && !destination_topic.topic_allowed_users.exists?(user_id: post.user_id)
destination_topic.topic_allowed_users.create!(user_id: post.user_id)
end
end
move_incoming_emails
move_notifications
update_reply_counts
update_quotes
move_first_post_replies
delete_post_replies
copy_first_post_timings
move_post_timings
copy_topic_users
end
def create_first_post(post)
@post_creator = PostCreator.new(
post.user,
raw: post.raw,
topic_id: destination_topic.id,
acting_user: user,
cook_method: post.cook_method,
via_email: post.via_email,
raw_email: post.raw_email,
skip_validations: true,
created_at: post.created_at,
guardian: Guardian.new(user),
skip_jobs: true
)
new_post = @post_creator.create
move_email_logs(post, new_post)
PostAction.copy(post, new_post)
new_post.update_column(:reply_count, @reply_count[1] || 0)
new_post.custom_fields = post.custom_fields
new_post.save_custom_fields
2017-08-11 22:12:09 -04:00
DiscourseEvent.trigger(:post_moved, new_post, original_topic.id)
new_post
end
def move(post)
@first_post_number_moved ||= post.post_number
update = {
reply_count: @reply_count[post.post_number] || 0,
post_number: @move_map[post.post_number],
reply_to_post_number: @move_map[post.reply_to_post_number],
topic_id: destination_topic.id,
sort_order: @move_map[post.post_number]
}
unless @move_map[post.reply_to_post_number]
update[:reply_to_user_id] = nil
end
post.attributes = update
post.save(validate: false)
2017-08-11 22:12:09 -04:00
DiscourseEvent.trigger(:post_moved, post, original_topic.id)
# Move any links from the post to the new topic
post.topic_links.update_all(topic_id: destination_topic.id)
post
end
def movement_metadata(post)
{
old_topic_id: post.topic_id,
old_post_id: post.id,
old_post_number: post.post_number,
new_topic_id: destination_topic.id,
new_post_number: @move_map[post.post_number],
new_topic_title: destination_topic.title
}
end
def store_movement(metadata, new_post)
metadata[:new_post_id] = new_post.id
DB.exec(<<~SQL, metadata)
INSERT INTO moved_posts(old_topic_id, old_post_id, old_post_number, new_topic_id, new_topic_title, new_post_id, new_post_number)
VALUES (:old_topic_id, :old_post_id, :old_post_number, :new_topic_id, :new_topic_title, :new_post_id, :new_post_number)
SQL
end
def move_incoming_emails
DB.exec <<~SQL
UPDATE incoming_emails ie
SET topic_id = mp.new_topic_id,
post_id = mp.new_post_id
FROM moved_posts mp
WHERE ie.topic_id = mp.old_topic_id AND ie.post_id = mp.old_post_id
SQL
end
def move_email_logs(old_post, new_post)
EmailLog
.where(post_id: old_post.id)
2018-07-18 01:55:35 -04:00
.update_all(post_id: new_post.id)
end
def move_notifications
DB.exec <<~SQL
UPDATE notifications n
SET topic_id = mp.new_topic_id,
post_number = mp.new_post_number,
data = (data :: JSONB ||
jsonb_strip_nulls(
jsonb_build_object(
'topic_title', CASE WHEN data :: JSONB ->> 'topic_title' IS NULL
THEN NULL
ELSE mp.new_topic_title END
)
)) :: JSON
FROM moved_posts mp
WHERE n.topic_id = mp.old_topic_id AND n.post_number = mp.old_post_number
AND n.notification_type <> #{Notification.types[:watching_first_post]}
SQL
end
def update_reply_counts
DB.exec <<~SQL
UPDATE posts p
SET reply_count = GREATEST(0, reply_count - x.moved_reply_count)
FROM (
SELECT r.post_id, mp.new_topic_id, COUNT(1) AS moved_reply_count
FROM moved_posts mp
JOIN post_replies r ON (mp.old_post_id = r.reply_post_id)
GROUP BY r.post_id, mp.new_topic_id
) x
WHERE x.post_id = p.id AND x.new_topic_id <> p.topic_id
SQL
end
def update_quotes
DB.exec <<~SQL
UPDATE posts p
SET raw = REPLACE(p.raw,
', post:' || mp.old_post_number || ', topic:' || mp.old_topic_id,
', post:' || mp.new_post_number || ', topic:' || mp.new_topic_id),
baked_version = NULL
FROM moved_posts mp, quoted_posts qp
WHERE p.id = qp.post_id AND mp.old_post_id = qp.quoted_post_id
SQL
end
def move_first_post_replies
DB.exec <<~SQL
UPDATE post_replies pr
SET post_id = mp.new_post_id
FROM moved_posts mp
WHERE mp.old_post_id <> mp.new_post_id AND pr.post_id = mp.old_post_id AND
EXISTS (SELECT 1 FROM moved_posts mr WHERE mr.new_post_id = pr.reply_post_id)
SQL
end
def delete_post_replies
DB.exec <<~SQL
PERF: Speed up moving posts on large databases Old exection plan: ``` Delete on post_replies pr (cost=6.59..20462.62 rows=2254 width=24) (actual time=2.580..2.580 rows=0 loops=1) -> Nested Loop (cost=6.59..20462.62 rows=2254 width=24) (actual time=0.086..2.557 rows=4 loops=1) Join Filter: (p.topic_id <> r.topic_id) Rows Removed by Join Filter: 328 -> Nested Loop (cost=6.16..16845.77 rows=2254 width=26) (actual time=0.020..1.886 rows=332 loops=1) -> Nested Loop (cost=5.74..13257.09 rows=2254 width=20) (actual time=0.016..1.361 rows=332 loops=1) -> Seq Scan on moved_posts mp (cost=0.00..19.70 rows=970 width=10) (actual time=0.002..0.028 rows=263 loops=1) -> Bitmap Heap Scan on post_replies pr (cost=5.74..13.63 rows=2 width=14) (actual time=0.004..0.005 rows=1 loops=263) Recheck Cond: ((reply_post_id = mp.old_post_id) OR (post_id = mp.old_post_id)) Heap Blocks: exact=278 -> BitmapOr (cost=5.74..5.74 rows=2 width=0) (actual time=0.004..0.004 rows=0 loops=263) -> Bitmap Index Scan on index_post_replies_on_reply_post_id (cost=0.00..2.87 rows=1 width=0) (actual time=0.001..0.001 rows=1 loops=263) Index Cond: (reply_post_id = mp.old_post_id) -> Bitmap Index Scan on index_post_replies_on_post_id_and_reply_post_id (cost=0.00..2.87 rows=1 width=0) (actual time=0.002..0.002 rows=1 loops=263) Index Cond: (post_id = mp.old_post_id) -> Index Scan using posts_pkey on posts p (cost=0.42..1.59 rows=1 width=14) (actual time=0.001..0.001 rows=1 loops=332) Index Cond: (id = pr.post_id) -> Index Scan using posts_pkey on posts r (cost=0.42..1.59 rows=1 width=14) (actual time=0.001..0.002 rows=1 loops=332) Index Cond: (id = pr.reply_post_id) Planning Time: 0.305 ms Execution Time: 2.600 ms ``` New execution plan: ``` Delete on post_replies pr (cost=15.34..6538275.37 rows=364157 width=12) (actual time=1.961..1.961 rows=0 loops=1) -> Nested Loop (cost=15.34..6538275.37 rows=364157 width=12) (actual time=0.048..1.827 rows=187 loops=1) -> Seq Scan on moved_posts mp (cost=0.00..19.70 rows=970 width=10) (actual time=0.004..0.029 rows=188 loops=1) -> Bitmap Heap Scan on post_replies pr (cost=15.34..6736.72 rows=375 width=14) (actual time=0.009..0.009 rows=1 loops=188) Recheck Cond: ((reply_post_id = mp.old_post_id) OR (post_id = mp.old_post_id)) Filter: ((SubPlan 1) <> (SubPlan 2)) Heap Blocks: exact=187 -> BitmapOr (cost=15.34..15.34 rows=377 width=0) (actual time=0.003..0.003 rows=0 loops=188) -> Bitmap Index Scan on index_post_replies_on_reply_post_id (cost=0.00..4.33 rows=1 width=0) (actual time=0.001..0.001 rows=1 loops=188) Index Cond: (reply_post_id = mp.old_post_id) -> Bitmap Index Scan on index_post_replies_on_post_id_and_reply_post_id (cost=0.00..10.82 rows=376 width=0) (actual time=0.001..0.001 rows=0 loops=188) Index Cond: (post_id = mp.old_post_id) SubPlan 1 -> Index Scan using posts_pkey on posts p (cost=0.43..8.45 rows=1 width=4) (actual time=0.002..0.002 rows=1 loops=187) Index Cond: (id = pr.post_id) SubPlan 2 -> Index Scan using posts_pkey on posts r (cost=0.43..8.45 rows=1 width=4) (actual time=0.002..0.003 rows=1 loops=187) Index Cond: (id = pr.reply_post_id) Planning Time: 0.136 ms Execution Time: 1.990 ms ```
2020-02-03 18:26:13 -05:00
DELETE FROM post_replies pr USING moved_posts mp
WHERE (SELECT topic_id FROM posts WHERE id = pr.post_id) <>
(SELECT topic_id FROM posts WHERE id = pr.reply_post_id)
AND (pr.reply_post_id = mp.old_post_id OR pr.post_id = mp.old_post_id)
SQL
end
def copy_first_post_timings
DB.exec <<~SQL
INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
SELECT mp.new_topic_id, pt.user_id, mp.new_post_number, pt.msecs
FROM post_timings pt
JOIN moved_posts mp ON (pt.topic_id = mp.old_topic_id AND pt.post_number = mp.old_post_number)
WHERE mp.old_post_id <> mp.new_post_id
ON CONFLICT (topic_id, post_number, user_id) DO UPDATE
SET msecs = GREATEST(post_timings.msecs, excluded.msecs)
SQL
end
def delete_invalid_post_timings
DB.exec(<<~SQL, topid_id: destination_topic.id)
DELETE
FROM post_timings pt
WHERE pt.topic_id = :topid_id
AND NOT EXISTS(
SELECT 1
FROM posts p
WHERE p.topic_id = pt.topic_id
AND p.post_number = pt.post_number
)
SQL
end
def move_post_timings
DB.exec <<~SQL
UPDATE post_timings pt
SET topic_id = mp.new_topic_id,
post_number = mp.new_post_number
FROM moved_posts mp
WHERE pt.topic_id = mp.old_topic_id
AND pt.post_number = mp.old_post_number
AND mp.old_post_id = mp.new_post_id
SQL
end
def copy_topic_users
params = {
old_topic_id: original_topic.id,
new_topic_id: destination_topic.id,
old_highest_post_number: destination_topic.highest_post_number,
old_highest_staff_post_number: destination_topic.highest_staff_post_number
}
DB.exec(<<~SQL, params)
INSERT INTO topic_users(user_id, topic_id, posted, last_read_post_number, highest_seen_post_number,
last_emailed_post_number, first_visited_at, last_visited_at, notification_level,
notifications_changed_at, notifications_reason_id)
SELECT tu.user_id,
:new_topic_id AS topic_id,
EXISTS(
SELECT 1
FROM posts p
WHERE p.topic_id = :new_topic_id
AND p.user_id = tu.user_id
LIMIT 1
) AS posted,
(
SELECT MAX(lr.new_post_number)
FROM moved_posts lr
WHERE lr.old_topic_id = tu.topic_id
AND lr.old_post_number <= tu.last_read_post_number
) AS last_read_post_number,
(
SELECT MAX(hs.new_post_number)
FROM moved_posts hs
WHERE hs.old_topic_id = tu.topic_id
AND hs.old_post_number <= tu.highest_seen_post_number
) AS highest_seen_post_number,
(
SELECT MAX(le.new_post_number)
FROM moved_posts le
WHERE le.old_topic_id = tu.topic_id
AND le.old_post_number <= tu.last_emailed_post_number
) AS last_emailed_post_number,
GREATEST(tu.first_visited_at, t.created_at) AS first_visited_at,
GREATEST(tu.last_visited_at, t.created_at) AS last_visited_at,
tu.notification_level,
tu.notifications_changed_at,
tu.notifications_reason_id
FROM topic_users tu
JOIN topics t ON (t.id = :new_topic_id)
WHERE tu.topic_id = :old_topic_id
AND GREATEST(
tu.last_read_post_number,
tu.highest_seen_post_number,
tu.last_emailed_post_number
) >= (SELECT MIN(old_post_number) FROM moved_posts)
ON CONFLICT (topic_id, user_id) DO UPDATE
SET posted = excluded.posted,
last_read_post_number = CASE
WHEN topic_users.last_read_post_number = :old_highest_staff_post_number OR (
:old_highest_post_number < :old_highest_staff_post_number
AND topic_users.last_read_post_number = :old_highest_post_number
AND NOT EXISTS(SELECT 1
FROM users u
WHERE u.id = topic_users.user_id
AND (admin OR moderator))
) THEN
GREATEST(topic_users.last_read_post_number,
excluded.last_read_post_number)
ELSE topic_users.last_read_post_number END,
highest_seen_post_number = CASE
WHEN topic_users.highest_seen_post_number = :old_highest_staff_post_number OR (
:old_highest_post_number < :old_highest_staff_post_number
AND topic_users.highest_seen_post_number = :old_highest_post_number
AND NOT EXISTS(SELECT 1
FROM users u
WHERE u.id = topic_users.user_id
AND (admin OR moderator))
) THEN
GREATEST(topic_users.highest_seen_post_number,
excluded.highest_seen_post_number)
ELSE topic_users.highest_seen_post_number END,
last_emailed_post_number = CASE
WHEN topic_users.last_emailed_post_number = :old_highest_staff_post_number OR (
:old_highest_post_number < :old_highest_staff_post_number
AND topic_users.last_emailed_post_number = :old_highest_post_number
AND NOT EXISTS(SELECT 1
FROM users u
WHERE u.id = topic_users.user_id
AND (admin OR moderator))
) THEN
GREATEST(topic_users.last_emailed_post_number,
excluded.last_emailed_post_number)
ELSE topic_users.last_emailed_post_number END,
first_visited_at = LEAST(topic_users.first_visited_at, excluded.first_visited_at),
last_visited_at = GREATEST(topic_users.last_visited_at, excluded.last_visited_at)
SQL
end
def update_statistics
destination_topic.update_statistics
original_topic.update_statistics
TopicUser.update_post_action_cache(topic_id: original_topic.id)
TopicUser.update_post_action_cache(topic_id: destination_topic.id)
end
def update_user_actions
UserAction.synchronize_target_topic_ids(posts.map(&:id))
end
def notify_users_that_posts_have_moved
enqueue_notification_job
create_moderator_post_in_original_topic
end
def enqueue_notification_job
Jobs.enqueue(
:notify_moved_posts,
post_ids: post_ids,
moved_by_id: user.id
)
end
def create_moderator_post_in_original_topic
2015-07-31 16:30:18 -04:00
move_type_str = PostMover.move_types[@move_type].to_s
move_type_str.sub!("topic", "message") if @move_to_pm
2015-07-31 16:30:18 -04:00
message = I18n.with_locale(SiteSetting.default_locale) do
I18n.t(
"move_posts.#{move_type_str}_moderator_post",
count: posts.length,
topic_link: posts.first.is_first_post? ?
"[#{destination_topic.title}](#{destination_topic.relative_url})" :
"[#{destination_topic.title}](#{posts.first.url})"
)
end
post_type = @move_to_pm ? Post.types[:whisper] : Post.types[:small_action]
original_topic.add_moderator_post(
user, message,
post_type: post_type,
2015-07-31 16:30:18 -04:00
action_code: "split_topic",
post_number: @first_post_number_moved
)
end
def posts
@posts ||= begin
2017-11-23 11:16:05 -05:00
Post.where(topic: @original_topic, id: post_ids)
.where.not(post_type: Post.types[:small_action])
.where.not(raw: '')
2017-11-23 11:16:05 -05:00
.order(:created_at).tap do |posts|
raise Discourse::InvalidParameters.new(:post_ids) if posts.empty?
end
end
end
def update_last_post_stats
post = destination_topic.ordered_posts.where.not(post_type: Post.types[:whisper]).last
if post && post_ids.include?(post.id)
attrs = {}
attrs[:last_posted_at] = post.created_at
attrs[:last_post_user_id] = post.user_id
attrs[:bumped_at] = Time.now
attrs[:updated_at] = Time.now
destination_topic.update_columns(attrs)
end
end
def update_upload_security_status
DB.after_commit do
Jobs.enqueue(:update_topic_upload_security, topic_id: @destination_topic.id)
end
end
2018-07-20 03:13:27 -04:00
def watch_new_topic
if @destination_topic.archetype == Archetype.private_message
if @original_topic.archetype == Archetype.private_message
notification_levels = TopicUser.where(topic_id: @original_topic.id, user_id: posts.pluck(:user_id)).pluck(:user_id, :notification_level).to_h
else
notification_levels = posts.pluck(:user_id).uniq.map { |user_id| [user_id, TopicUser.notification_levels[:watching]] }.to_h
end
else
notification_levels = [[@destination_topic.user_id, TopicUser.notification_levels[:watching]]]
end
notification_levels.each do |user_id, notification_level|
TopicUser.change(
user_id,
@destination_topic.id,
notification_level: notification_level,
notifications_reason_id: TopicUser.notification_reasons[destination_topic.user_id == user_id ? :created_topic : :created_post]
)
end
end
def add_allowed_users(usernames)
return unless usernames.present?
names = usernames.split(',').flatten
User.where(username: names).find_each do |user|
destination_topic.topic_allowed_users.build(user_id: user.id) unless destination_topic.topic_allowed_users.where(user_id: user.id).exists?
end
destination_topic.save!
end
def enqueue_jobs(topic)
@post_creator.enqueue_jobs if @post_creator
Jobs.enqueue(
:delete_inaccessible_notifications,
topic_id: topic.id
)
end
end