mirror of
https://github.com/discourse/discourse.git
synced 2025-05-26 03:56:26 +08:00
DEV: Add rake task to bulk delete posts (#31576)
This PR adds a destroy:posts rake task that can be used to hard-delete a list of posts. Useful for dealing with large amounts of spam that has been soft deleted and needs to go. Notes: Works on both non-deleted and soft-deleted posts. (We might want to change this to work on only soft-deleted posts?) Works exclusively on post IDs. We can't mix topic and post IDs as they might clash, and we have no way of resolving that ambiguity. Accepts either a rake-style array of IDs or, more conveniently, you can pipe the argument in through STDIN. Added a confirmation step since it's a fairly destructive operation.
This commit is contained in:
@ -1,5 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "highline/import"
|
||||||
|
|
||||||
class DestroyTask
|
class DestroyTask
|
||||||
def initialize(io = STDOUT)
|
def initialize(io = STDOUT)
|
||||||
@io = io
|
@io = io
|
||||||
@ -46,6 +48,32 @@ class DestroyTask
|
|||||||
categories.each { |c| @io.puts destroy_topics(c.slug, c.parent_category&.slug) }
|
categories.each { |c| @io.puts destroy_topics(c.slug, c.parent_category&.slug) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy_posts(post_ids, require_confirmation: true)
|
||||||
|
posts = Post.with_deleted.where(id: post_ids)
|
||||||
|
|
||||||
|
@io.puts "There are #{posts.count} posts to delete"
|
||||||
|
|
||||||
|
if posts.count < post_ids.size
|
||||||
|
@io.puts "Couldn't find the following posts:"
|
||||||
|
@io.puts " #{post_ids.map(&:to_i) - posts.pluck(:id)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
if require_confirmation
|
||||||
|
confirm_destroy = ask("Are you sure? (Y/n)")
|
||||||
|
exit 1 if confirm_destroy.downcase != "y"
|
||||||
|
end
|
||||||
|
|
||||||
|
posts.find_each do |post|
|
||||||
|
@io.puts "Destroying post #{post.id}"
|
||||||
|
@io.puts PostDestroyer.new(
|
||||||
|
Discourse.system_user,
|
||||||
|
post,
|
||||||
|
context: I18n.t("staff_action_logs.cli_bulk_post_delete"),
|
||||||
|
force_destroy: true,
|
||||||
|
).destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def destroy_private_messages
|
def destroy_private_messages
|
||||||
Topic
|
Topic
|
||||||
.where(archetype: "private_message")
|
.where(archetype: "private_message")
|
||||||
|
@ -5560,6 +5560,7 @@ en:
|
|||||||
revoked: Revoked
|
revoked: Revoked
|
||||||
restored: Restored
|
restored: Restored
|
||||||
bulk_user_delete: "deleted in a bulk delete operation"
|
bulk_user_delete: "deleted in a bulk delete operation"
|
||||||
|
cli_bulk_post_delete: "Bulk deleted from rake task"
|
||||||
|
|
||||||
reviewables:
|
reviewables:
|
||||||
already_handled: "Thanks, but we've already reviewed that post and determined it does not need to be flagged again."
|
already_handled: "Thanks, but we've already reviewed that post and determined it does not need to be flagged again."
|
||||||
|
@ -50,3 +50,16 @@ task "destroy:categories" => :environment do |t, args|
|
|||||||
puts "Going to delete these categories: #{categories}"
|
puts "Going to delete these categories: #{categories}"
|
||||||
categories.each { |id| destroy_task.destroy_category(id, true) }
|
categories.each { |id| destroy_task.destroy_category(id, true) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Hard delete a list of posts by ID. Pass a comma
|
||||||
|
# separated list either as a rake argument or through
|
||||||
|
# STDIN, e.g. through a pipe.
|
||||||
|
#
|
||||||
|
# Example: rake destroy:posts[4,8,15,16,23,42]
|
||||||
|
# Example: cat post_ids.txt | rake destroy:posts
|
||||||
|
desc "Destroy a list of posts given by ID"
|
||||||
|
task "destroy:posts" => :environment do |_task, args|
|
||||||
|
post_ids = args.extras.empty? ? STDIN.read.strip.split(",") : args.extras
|
||||||
|
puts "Going to delete these posts: #{post_ids}"
|
||||||
|
DestroyTask.new.destroy_posts(post_ids)
|
||||||
|
end
|
||||||
|
21
spec/lib/tasks/destroy_rake_spec.rb
Normal file
21
spec/lib/tasks/destroy_rake_spec.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
describe "destroy:posts" do # rubocop:disable RSpec/DescribeClass
|
||||||
|
subject(:task) { subject }
|
||||||
|
|
||||||
|
include_context "in a rake task"
|
||||||
|
|
||||||
|
# No console output in test suite, thanks.
|
||||||
|
before { STDOUT.stubs(:puts) }
|
||||||
|
|
||||||
|
it "accepts a list of post IDs piped through STDIN" do
|
||||||
|
destroy_task = instance_spy(DestroyTask)
|
||||||
|
DestroyTask.stubs(:new).returns(destroy_task)
|
||||||
|
|
||||||
|
STDIN.stubs(:read).returns("1,2,3\n")
|
||||||
|
|
||||||
|
task.invoke
|
||||||
|
|
||||||
|
expect(destroy_task).to have_received(:destroy_posts).with(%w[1 2 3])
|
||||||
|
end
|
||||||
|
end
|
@ -67,6 +67,30 @@ RSpec.describe DestroyTask do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#destroy_posts" do
|
||||||
|
let(:task) { DestroyTask.new(StringIO.new) }
|
||||||
|
|
||||||
|
let!(:t1) { Fabricate(:topic) }
|
||||||
|
let!(:t2) { Fabricate(:topic) }
|
||||||
|
|
||||||
|
let!(:p1) { Fabricate(:post, topic: t1) }
|
||||||
|
let!(:p2) { Fabricate(:post, topic: t1) }
|
||||||
|
let!(:p3) { Fabricate(:post, topic: t2) }
|
||||||
|
|
||||||
|
before { p2.trash! }
|
||||||
|
|
||||||
|
it "destroys posts listed and creates staff action logs" do
|
||||||
|
expect { task.destroy_posts([p2.id, p3.id], require_confirmation: false) }.to change {
|
||||||
|
Post.with_deleted.count
|
||||||
|
}.by(-2).and change { UserHistory.pluck(:action) }.from([]).to(
|
||||||
|
[
|
||||||
|
UserHistory.actions[:delete_post_permanently],
|
||||||
|
UserHistory.actions[:delete_topic_permanently],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "private messages" do
|
describe "private messages" do
|
||||||
let!(:pm) { Fabricate(:private_message_post) }
|
let!(:pm) { Fabricate(:private_message_post) }
|
||||||
let!(:pm2) { Fabricate(:private_message_post) }
|
let!(:pm2) { Fabricate(:private_message_post) }
|
||||||
|
26
spec/support/rake_context.rb
Normal file
26
spec/support/rake_context.rb
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rake"
|
||||||
|
|
||||||
|
shared_context "in a rake task" do
|
||||||
|
subject { rake[task_name] }
|
||||||
|
|
||||||
|
let(:rake) { Rake::Application.new }
|
||||||
|
let(:task_name) { self.class.top_level_description }
|
||||||
|
let(:task_path) { "lib/tasks/#{task_name.split(":").first}" }
|
||||||
|
|
||||||
|
def loaded_files_excluding_current_rake_file
|
||||||
|
$".reject { |file| file == Rails.root.join("#{task_path}.rake").to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
Rake.application = rake
|
||||||
|
Rake.application.rake_require(
|
||||||
|
task_path,
|
||||||
|
[Rails.root.to_s],
|
||||||
|
loaded_files_excluding_current_rake_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
Rake::Task.define_task(:environment)
|
||||||
|
end
|
||||||
|
end
|
Reference in New Issue
Block a user