FEATURE: log topic/post deletions from staff members

This commit is contained in:
Régis Hanol 2014-10-01 17:40:13 +02:00
parent be93f224a6
commit 98b6b9821a
14 changed files with 155 additions and 33 deletions

View file

@ -17,6 +17,8 @@ Discourse.StaffActionLog = Discourse.Model.extend({
var formatted = ""; var formatted = "";
formatted += this.format('email', 'email'); formatted += this.format('email', 'email');
formatted += this.format('admin.logs.ip_address', 'ip_address'); formatted += this.format('admin.logs.ip_address', 'ip_address');
formatted += this.format('admin.logs.topic_id', 'topic_id');
formatted += this.format('admin.logs.post_id', 'post_id');
if (!this.get('useCustomModalForDetails')) { if (!this.get('useCustomModalForDetails')) {
formatted += this.format('admin.logs.staff_actions.new_value', 'new_value'); formatted += this.format('admin.logs.staff_actions.new_value', 'new_value');
formatted += this.format('admin.logs.staff_actions.previous_value', 'previous_value'); formatted += this.format('admin.logs.staff_actions.previous_value', 'previous_value');
@ -25,7 +27,7 @@ Discourse.StaffActionLog = Discourse.Model.extend({
if (this.get('details')) formatted += Handlebars.Utils.escapeExpression(this.get('details')) + '<br/>'; if (this.get('details')) formatted += Handlebars.Utils.escapeExpression(this.get('details')) + '<br/>';
} }
return formatted; return formatted;
}.property('ip_address', 'email'), }.property('ip_address', 'email', 'topic_id', 'post_id'),
format: function(label, propertyName) { format: function(label, propertyName) {
if (this.get(propertyName)) { if (this.get(propertyName)) {

View file

@ -280,7 +280,10 @@ Discourse.Post = Discourse.Model.extend({
**/ **/
destroy: function(deletedBy) { destroy: function(deletedBy) {
this.setDeletedState(deletedBy); this.setDeletedState(deletedBy);
return Discourse.ajax("/posts/" + (this.get('id')), { type: 'DELETE' }); return Discourse.ajax("/posts/" + this.get('id'), {
data: { context: window.location.pathname },
type: 'DELETE'
});
}, },
/** /**

View file

@ -215,7 +215,10 @@ Discourse.Topic = Discourse.Model.extend({
'details.can_delete': false, 'details.can_delete': false,
'details.can_recover': true 'details.can_recover': true
}); });
return Discourse.ajax("/t/" + this.get('id'), { type: 'DELETE' }); return Discourse.ajax("/t/" + this.get('id'), {
data: { context: window.location.pathname },
type: 'DELETE'
});
}, },
// Recover this topic if deleted // Recover this topic if deleted

View file

@ -167,7 +167,7 @@ class PostsController < ApplicationController
guardian.ensure_can_delete!(post) guardian.ensure_can_delete!(post)
destroyer = PostDestroyer.new(current_user, post) destroyer = PostDestroyer.new(current_user, post, { context: params[:context] })
destroyer.destroy destroyer.destroy
render nothing: true render nothing: true

View file

@ -211,7 +211,7 @@ class TopicsController < ApplicationController
guardian.ensure_can_delete!(topic) guardian.ensure_can_delete!(topic)
first_post = topic.ordered_posts.first first_post = topic.ordered_posts.first
PostDestroyer.new(current_user, first_post).destroy PostDestroyer.new(current_user, first_post, { context: params[:context] }).destroy
render nothing: true render nothing: true
end end

View file

@ -5,6 +5,9 @@ class UserHistory < ActiveRecord::Base
belongs_to :acting_user, class_name: 'User' belongs_to :acting_user, class_name: 'User'
belongs_to :target_user, class_name: 'User' belongs_to :target_user, class_name: 'User'
belongs_to :post
belongs_to :topic
validates_presence_of :action validates_presence_of :action
scope :only_staff_actions, ->{ where("action IN (?)", UserHistory.staff_action_ids) } scope :only_staff_actions, ->{ where("action IN (?)", UserHistory.staff_action_ids) }
@ -12,22 +15,24 @@ class UserHistory < ActiveRecord::Base
before_save :set_admin_only before_save :set_admin_only
def self.actions def self.actions
@actions ||= Enum.new( :delete_user, @actions ||= Enum.new(:delete_user,
:change_trust_level, :change_trust_level,
:change_site_setting, :change_site_setting,
:change_site_customization, :change_site_customization,
:delete_site_customization, :delete_site_customization,
:checked_for_custom_avatar, :checked_for_custom_avatar,
:notified_about_avatar, :notified_about_avatar,
:notified_about_sequential_replies, :notified_about_sequential_replies,
:notified_about_dominating_topic, :notified_about_dominating_topic,
:suspend_user, :suspend_user,
:unsuspend_user, :unsuspend_user,
:facebook_no_email, :facebook_no_email,
:grant_badge, :grant_badge,
:revoke_badge, :revoke_badge,
:auto_trust_level_change, :auto_trust_level_change,
:check_email) :check_email,
:delete_post,
:delete_topic)
end end
# Staff actions is a subset of all actions, used to audit actions taken by staff users. # Staff actions is a subset of all actions, used to audit actions taken by staff users.
@ -41,7 +46,9 @@ class UserHistory < ActiveRecord::Base
:unsuspend_user, :unsuspend_user,
:grant_badge, :grant_badge,
:revoke_badge, :revoke_badge,
:check_email] :check_email,
:delete_post,
:delete_topic]
end end
def self.staff_action_ids def self.staff_action_ids

View file

@ -7,7 +7,9 @@ class UserHistorySerializer < ApplicationSerializer
:created_at, :created_at,
:subject, :subject,
:previous_value, :previous_value,
:new_value :new_value,
:topic_id,
:post_id
has_one :acting_user, serializer: BasicUserSerializer, embed: :objects has_one :acting_user, serializer: BasicUserSerializer, embed: :objects
has_one :target_user, serializer: BasicUserSerializer, embed: :objects has_one :target_user, serializer: BasicUserSerializer, embed: :objects

View file

@ -2,32 +2,74 @@
class StaffActionLogger class StaffActionLogger
def initialize(admin) def initialize(admin)
@admin = admin @admin = admin
raise Discourse::InvalidParameters.new('admin is nil') unless @admin and @admin.is_a?(User) raise Discourse::InvalidParameters.new('admin is nil') unless @admin && @admin.is_a?(User)
end end
def log_user_deletion(deleted_user, opts={}) def log_user_deletion(deleted_user, opts={})
raise Discourse::InvalidParameters.new('user is nil') unless deleted_user and deleted_user.is_a?(User) raise Discourse::InvalidParameters.new('user is nil') unless deleted_user && deleted_user.is_a?(User)
UserHistory.create( params(opts).merge({ UserHistory.create( params(opts).merge({
action: UserHistory.actions[:delete_user], action: UserHistory.actions[:delete_user],
email: deleted_user.email, email: deleted_user.email,
ip_address: deleted_user.ip_address.to_s, ip_address: deleted_user.ip_address.to_s,
details: [:id, :username, :name, :created_at, :trust_level, :last_seen_at, :last_emailed_at].map { |x| "#{x}: #{deleted_user.send(x)}" }.join(', ') details: [:id, :username, :name, :created_at, :trust_level, :last_seen_at, :last_emailed_at].map { |x| "#{x}: #{deleted_user.send(x)}" }.join("\n")
}))
end
def log_post_deletion(deleted_post, opts={})
raise Discourse::InvalidParameters.new("post is nil") unless deleted_post && deleted_post.is_a?(Post)
topic = deleted_post.topic || Topic.with_deleted.find(deleted_post.topic_id)
details = [
"id: #{deleted_post.id}",
"created_at: #{deleted_post.created_at}",
"user: #{deleted_post.user.username} (#{deleted_post.user.name})",
"topic: #{topic.title}",
"post_number: #{deleted_post.post_number}",
"raw: #{deleted_post.raw}"
]
UserHistory.create(params(opts).merge({
action: UserHistory.actions[:delete_post],
post_id: deleted_post.id,
details: details.join("\n")
}))
end
def log_topic_deletion(deleted_topic, opts={})
raise Discourse::InvalidParameters.new("topic is nil") unless deleted_topic && deleted_topic.is_a?(Topic)
details = [
"id: #{deleted_topic.id}",
"created_at: #{deleted_topic.created_at}",
"user: #{deleted_topic.user.username} (#{deleted_topic.user.name})",
"title: #{deleted_topic.title}"
]
if first_post = deleted_topic.ordered_posts.first
details << "raw: #{first_post.raw}"
end
UserHistory.create(params(opts).merge({
action: UserHistory.actions[:delete_topic],
topic_id: deleted_topic.id,
details: details.join("\n")
})) }))
end end
def log_trust_level_change(user, old_trust_level, new_trust_level, opts={}) def log_trust_level_change(user, old_trust_level, new_trust_level, opts={})
raise Discourse::InvalidParameters.new('user is nil') unless user and user.is_a?(User) raise Discourse::InvalidParameters.new('user is nil') unless user && user.is_a?(User)
raise Discourse::InvalidParameters.new('old trust level is invalid') unless TrustLevel.valid? old_trust_level raise Discourse::InvalidParameters.new('old trust level is invalid') unless TrustLevel.valid? old_trust_level
raise Discourse::InvalidParameters.new('new trust level is invalid') unless TrustLevel.valid? new_trust_level raise Discourse::InvalidParameters.new('new trust level is invalid') unless TrustLevel.valid? new_trust_level
UserHistory.create!( params(opts).merge({ UserHistory.create!( params(opts).merge({
action: UserHistory.actions[:change_trust_level], action: UserHistory.actions[:change_trust_level],
target_user_id: user.id, target_user_id: user.id,
details: "old trust level: #{old_trust_level}, new trust level: #{new_trust_level}" details: "old trust level: #{old_trust_level}\nnew trust level: #{new_trust_level}"
})) }))
end end
def log_site_setting_change(setting_name, previous_value, new_value, opts={}) def log_site_setting_change(setting_name, previous_value, new_value, opts={})
raise Discourse::InvalidParameters.new('setting_name is invalid') unless setting_name.present? and SiteSetting.respond_to?(setting_name) raise Discourse::InvalidParameters.new('setting_name is invalid') unless setting_name.present? && SiteSetting.respond_to?(setting_name)
UserHistory.create( params(opts).merge({ UserHistory.create( params(opts).merge({
action: UserHistory.actions[:change_site_setting], action: UserHistory.actions[:change_site_setting],
subject: setting_name, subject: setting_name,

View file

@ -1768,6 +1768,8 @@ en:
last_match_at: "Last Matched" last_match_at: "Last Matched"
match_count: "Matches" match_count: "Matches"
ip_address: "IP" ip_address: "IP"
topic_id: "Topic ID"
post_id: "Post ID"
delete: 'Delete' delete: 'Delete'
edit: 'Edit' edit: 'Edit'
save: 'Save' save: 'Save'
@ -1802,6 +1804,8 @@ en:
grant_badge: "grant badge" grant_badge: "grant badge"
revoke_badge: "revoke badge" revoke_badge: "revoke badge"
check_email: "check email" check_email: "check email"
delete_topic: "delete topic"
delete_post: "delete post"
screened_emails: screened_emails:
title: "Screened Emails" title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View file

@ -0,0 +1,5 @@
class AddPostIdToUserHistories < ActiveRecord::Migration
def change
add_column :user_histories, :post_id, :integer
end
end

View file

@ -33,10 +33,11 @@ class PostDestroyer
end end
end end
def initialize(user, post) def initialize(user, post, opts={})
@user = user @user = user
@post = post @post = post
@topic = post.topic if post @topic = post.topic if post
@opts = opts
end end
def destroy def destroy
@ -79,7 +80,12 @@ class PostDestroyer
@post.update_flagged_posts_count @post.update_flagged_posts_count
remove_associated_replies remove_associated_replies
remove_associated_notifications remove_associated_notifications
@post.topic.trash!(@user) if @post.topic && @post.post_number == 1 if @post.topic && @post.post_number == 1
StaffActionLogger.new(@user).log_topic_deletion(@post.topic, @opts.slice(:context))
@post.topic.trash!(@user)
else
StaffActionLogger.new(@user).log_post_deletion(@post, @opts.slice(:context))
end
update_associated_category_latest_topic update_associated_category_latest_topic
update_user_counts update_user_counts
end end

View file

@ -170,6 +170,12 @@ describe PostDestroyer do
author.reload author.reload
}.to change { author.post_count }.by(-1) }.to change { author.post_count }.by(-1)
end end
it "creates a new user history entry" do
expect {
PostDestroyer.new(moderator, post).destroy
}.to change { UserHistory.count}.by(1)
end
end end
context "as an admin" do context "as an admin" do
@ -258,6 +264,12 @@ describe PostDestroyer do
post.deleted_at.should be_present post.deleted_at.should be_present
post.deleted_by.should == admin post.deleted_by.should == admin
end end
it "creates a new user history entry" do
expect {
PostDestroyer.new(admin, post).destroy
}.to change { UserHistory.count}.by(1)
end
end end
end end

View file

@ -143,7 +143,7 @@ describe PostsController do
it "uses a PostDestroyer" do it "uses a PostDestroyer" do
destroyer = mock destroyer = mock
PostDestroyer.expects(:new).with(user, post).returns(destroyer) PostDestroyer.expects(:new).returns(destroyer)
destroyer.expects(:destroy) destroyer.expects(:destroy)
xhr :delete, :destroy, id: post.id xhr :delete, :destroy, id: post.id
end end

View file

@ -33,6 +33,42 @@ describe StaffActionLogger do
end end
end end
describe 'log_post_deletion' do
let(:deleted_post) { Fabricate(:post) }
subject(:log_post_deletion) { described_class.new(admin).log_post_deletion(deleted_post) }
it 'raises an error when post is nil' do
expect { logger.log_post_deletion(nil) }.to raise_error(Discourse::InvalidParameters)
end
it 'raises an error when post is not a Post' do
expect { logger.log_post_deletion(1) }.to raise_error(Discourse::InvalidParameters)
end
it 'creates a new UserHistory record' do
expect { log_post_deletion }.to change { UserHistory.count }.by(1)
end
end
describe 'log_topic_deletion' do
let(:deleted_topic) { Fabricate(:topic) }
subject(:log_topic_deletion) { described_class.new(admin).log_topic_deletion(deleted_topic) }
it 'raises an error when topic is nil' do
expect { logger.log_topic_deletion(nil) }.to raise_error(Discourse::InvalidParameters)
end
it 'raises an error when topic is not a Topic' do
expect { logger.log_topic_deletion(1) }.to raise_error(Discourse::InvalidParameters)
end
it 'creates a new UserHistory record' do
expect { log_topic_deletion }.to change { UserHistory.count }.by(1)
end
end
describe 'log_trust_level_change' do describe 'log_trust_level_change' do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:old_trust_level) { TrustLevel[0] } let(:old_trust_level) { TrustLevel[0] }