mirror of
https://github.com/discourse/discourse.git
synced 2025-05-26 06:41:24 +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
|
||||
|
||||
require "highline/import"
|
||||
|
||||
class DestroyTask
|
||||
def initialize(io = STDOUT)
|
||||
@io = io
|
||||
@ -46,6 +48,32 @@ class DestroyTask
|
||||
categories.each { |c| @io.puts destroy_topics(c.slug, c.parent_category&.slug) }
|
||||
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
|
||||
Topic
|
||||
.where(archetype: "private_message")
|
||||
|
@ -5560,6 +5560,7 @@ en:
|
||||
revoked: Revoked
|
||||
restored: Restored
|
||||
bulk_user_delete: "deleted in a bulk delete operation"
|
||||
cli_bulk_post_delete: "Bulk deleted from rake task"
|
||||
|
||||
reviewables:
|
||||
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}"
|
||||
categories.each { |id| destroy_task.destroy_category(id, true) }
|
||||
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
|
||||
|
||||
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
|
||||
let!(:pm) { 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