allow end user to recover a post they delete

automatically delete stubs after 1 day
This commit is contained in:
Sam 2013-07-22 17:48:24 +10:00
parent d68f30c09d
commit 1f3c5cb656
13 changed files with 131 additions and 23 deletions

View file

@ -27,12 +27,12 @@ Discourse.Post = Discourse.Model.extend({
deleted: Em.computed.or('deleted_at', 'deletedViaTopic'), deleted: Em.computed.or('deleted_at', 'deletedViaTopic'),
postDeletedBy: function() { postDeletedBy: function() {
if (this.get('firstPost')) { return this.get('topic.deleted_by') } if (this.get('firstPost')) { return this.get('topic.deleted_by'); }
return this.get('deleted_by'); return this.get('deleted_by');
}.property('firstPost', 'deleted_by', 'topic.deleted_by'), }.property('firstPost', 'deleted_by', 'topic.deleted_by'),
postDeletedAt: function() { postDeletedAt: function() {
if (this.get('firstPost')) { return this.get('topic.deleted_at') } if (this.get('firstPost')) { return this.get('topic.deleted_at'); }
return this.get('deleted_at'); return this.get('deleted_at');
}.property('firstPost', 'deleted_at', 'topic.deleted_at'), }.property('firstPost', 'deleted_at', 'topic.deleted_at'),
@ -199,13 +199,23 @@ Discourse.Post = Discourse.Model.extend({
@method recover @method recover
**/ **/
recover: function() { recover: function() {
this.setProperties({ var post = this;
post.setProperties({
deleted_at: null, deleted_at: null,
deleted_by: null, deleted_by: null,
can_delete: true user_deleted: false,
can_delete: false
}); });
return Discourse.ajax("/posts/" + (this.get('id')) + "/recover", { type: 'PUT', cache: false }); return Discourse.ajax("/posts/" + (this.get('id')) + "/recover", { type: 'PUT', cache: false }).then(function(data){
post.setProperties({
cooked: data.cooked,
raw: data.raw,
user_deleted: false,
can_delete: true,
version: data.version
});
});
}, },
/** /**
@ -226,7 +236,10 @@ Discourse.Post = Discourse.Model.extend({
this.setProperties({ this.setProperties({
cooked: Discourse.Markdown.cook(I18n.t("post.deleted_by_author")), cooked: Discourse.Markdown.cook(I18n.t("post.deleted_by_author")),
can_delete: false, can_delete: false,
version: this.get('version') + 1 version: this.get('version') + 1,
can_recover: true,
can_edit: false,
user_deleted: true
}); });
} }

View file

@ -88,7 +88,7 @@ Discourse.PostMenuView = Discourse.View.extend({
} else { } else {
// The delete actions target the post iteself // The delete actions target the post iteself
if (post.get('deleted_at')) { if (post.get('deleted_at') || post.get('user_deleted')) {
if (!post.get('can_recover')) { return; } if (!post.get('can_recover')) { return; }
label = "post.controls.undelete"; label = "post.controls.undelete";
action = "recover"; action = "recover";

View file

@ -12,10 +12,15 @@ Discourse.PostView = Discourse.View.extend({
classNameBindings: ['postTypeClass', classNameBindings: ['postTypeClass',
'selected', 'selected',
'post.hidden:hidden', 'post.hidden:hidden',
'post.deleted', 'addDeletedClass:deleted',
'parentPost:replies-above'], 'parentPost:replies-above'],
postBinding: 'content', postBinding: 'content',
addDeletedClass: function() {
var post = this.get('post');
return post.get('deleted') || post.get('user_deleted');
}.property('post.deleted','post.user_deleted'),
postTypeClass: function() { postTypeClass: function() {
return this.get('post.post_type') === Discourse.Site.instance().get('post_types.moderator_action') ? 'moderator' : 'regular'; return this.get('post.post_type') === Discourse.Site.instance().get('post_types.moderator_action') ? 'moderator' : 'regular';
}.property('post.post_type'), }.property('post.post_type'),

View file

@ -94,17 +94,13 @@ class PostsController < ApplicationController
@post = Post.where(topic_id: params[:topic_id], post_number: params[:post_number]).first @post = Post.where(topic_id: params[:topic_id], post_number: params[:post_number]).first
guardian.ensure_can_see!(@post) guardian.ensure_can_see!(@post)
@post.revert_to(params[:version].to_i) if params[:version].present? @post.revert_to(params[:version].to_i) if params[:version].present?
post_serializer = PostSerializer.new(@post, scope: guardian, root: false) render_post_json(@post)
post_serializer.add_raw = true
render_json_dump(post_serializer)
end end
def show def show
@post = find_post_from_params @post = find_post_from_params
@post.revert_to(params[:version].to_i) if params[:version].present? @post.revert_to(params[:version].to_i) if params[:version].present?
post_serializer = PostSerializer.new(@post, scope: guardian, root: false) render_post_json(@post)
post_serializer.add_raw = true
render_json_dump(post_serializer)
end end
def destroy def destroy
@ -120,10 +116,11 @@ class PostsController < ApplicationController
def recover def recover
post = find_post_from_params post = find_post_from_params
guardian.ensure_can_recover_post!(post) guardian.ensure_can_recover_post!(post)
post.recover! destroyer = PostDestroyer.new(current_user, post)
post.topic.update_statistics destroyer.recover
post.reload
render nothing: true render_post_json(post)
end end
def destroy_many def destroy_many
@ -188,6 +185,12 @@ class PostsController < ApplicationController
post post
end end
def render_post_json(post)
post_serializer = PostSerializer.new(post, scope: guardian, root: false)
post_serializer.add_raw = true
render_json_dump(post_serializer)
end
private private
def create_params def create_params

View file

@ -208,6 +208,7 @@ ORDER BY p.created_at desc
end end
def self.synchronize_target_topic_ids(post_ids = nil) def self.synchronize_target_topic_ids(post_ids = nil)
builder = SqlBuilder.new("UPDATE user_actions builder = SqlBuilder.new("UPDATE user_actions
SET target_topic_id = (select topic_id from posts where posts.id = target_post_id) SET target_topic_id = (select topic_id from posts where posts.id = target_post_id)
/*where*/") /*where*/")

View file

@ -41,7 +41,8 @@ class PostSerializer < BasicPostSerializer
:hidden_reason_id, :hidden_reason_id,
:trust_level, :trust_level,
:deleted_at, :deleted_at,
:deleted_by :deleted_by,
:user_deleted
def moderator? def moderator?

View file

@ -34,5 +34,6 @@ module Clockwork
every(1.day, 'version_check') every(1.day, 'version_check')
every(1.minute, 'clockwork_heartbeat') every(1.minute, 'clockwork_heartbeat')
every(1.minute, 'poll_mailbox') every(1.minute, 'poll_mailbox')
every(2.hours, 'destroy_old_deletion_stubs')
end end

View file

@ -267,7 +267,7 @@ class Guardian
end end
def can_edit_post?(post) def can_edit_post?(post)
is_staff? || (not(post.topic.archived?) && is_my_own?(post)) is_staff? || (!post.topic.archived? && is_my_own?(post) && !post.user_deleted &&!post.deleted_at)
end end
def can_edit_user?(user) def can_edit_user?(user)
@ -291,7 +291,7 @@ class Guardian
# Recovery Method # Recovery Method
def can_recover_post?(post) def can_recover_post?(post)
is_staff? is_staff? || (is_my_own?(post) && post.user_deleted && !post.deleted_at)
end end
def can_recover_topic?(topic) def can_recover_topic?(topic)

View file

@ -0,0 +1,8 @@
module Jobs
# various consistency checks
class DestroyOldDeletionStubs < Jobs::Base
def execute(args)
PostDestroyer.destroy_stubs
end
end
end

View file

@ -4,6 +4,13 @@
# #
class PostDestroyer class PostDestroyer
def self.destroy_stubs
Post.where(deleted_at: nil, user_deleted: true)
.where('updated_at < ? AND post_number > 1', 1.day.ago).each do |post|
PostDestroyer.new(Discourse.system_user, post).destroy
end
end
def initialize(user, post) def initialize(user, post)
@user, @post = user, post @user, @post = user, post
end end
@ -16,6 +23,19 @@ class PostDestroyer
end end
end end
def recover
if @user.staff? && @post.deleted_at
staff_recovered
elsif @user.staff? || @user.id == @post.user_id
user_recovered
end
@post.topic.update_statistics
end
def staff_recovered
@post.recover!
end
# When a post is properly deleted. Well, it's still soft deleted, but it will no longer # When a post is properly deleted. Well, it's still soft deleted, but it will no longer
# show up in the topic # show up in the topic
def staff_destroyed def staff_destroyed
@ -75,4 +95,12 @@ class PostDestroyer
end end
end end
def user_recovered
Post.transaction do
@post.update_column(:user_deleted, false)
@post.revise(@user, @post.versions.last.modifications["raw"][0], force_new_version: true)
@post.update_flagged_posts_count
end
end
end end

View file

@ -485,6 +485,16 @@ describe Guardian do
Guardian.new(post.user).can_edit?(post).should be_true Guardian.new(post.user).can_edit?(post).should be_true
end end
it 'returns false if you are trying to edit a post you soft deleted' do
post.user_deleted = true
Guardian.new(post.user).can_edit?(post).should be_false
end
it 'returns false if you are trying to edit a deleted post' do
post.deleted_at = 1.day.ago
Guardian.new(post.user).can_edit?(post).should be_false
end
it 'returns false if another regular user tries to edit your post' do it 'returns false if another regular user tries to edit your post' do
Guardian.new(coding_horror).can_edit?(post).should be_false Guardian.new(coding_horror).can_edit?(post).should be_false
end end

View file

@ -10,6 +10,31 @@ describe PostDestroyer do
let(:moderator) { Fabricate(:moderator) } let(:moderator) { Fabricate(:moderator) }
let(:post) { create_post } let(:post) { create_post }
describe 'destroy_old_stubs' do
it 'destroys stubs for deleted by user posts' do
Fabricate(:admin)
reply1 = create_post(topic: post.topic)
reply2 = create_post(topic: post.topic)
reply3 = create_post(topic: post.topic)
PostDestroyer.new(reply1.user, reply1).destroy
PostDestroyer.new(reply2.user, reply2).destroy
reply2.update_column(:updated_at, 2.days.ago)
PostDestroyer.destroy_stubs
reply1.reload
reply2.reload
reply3.reload
reply1.deleted_at.should == nil
reply2.deleted_at.should_not == nil
reply3.deleted_at.should == nil
end
end
describe 'basic destroying' do describe 'basic destroying' do
let(:moderator) { Fabricate(:moderator) } let(:moderator) { Fabricate(:moderator) }
@ -17,6 +42,7 @@ describe PostDestroyer do
context "as the creator of the post" do context "as the creator of the post" do
before do before do
@orig = post.cooked
PostDestroyer.new(post.user, post).destroy PostDestroyer.new(post.user, post).destroy
post.reload post.reload
end end
@ -24,8 +50,16 @@ describe PostDestroyer do
it "doesn't delete the post" do it "doesn't delete the post" do
post.deleted_at.should be_blank post.deleted_at.should be_blank
post.deleted_by.should be_blank post.deleted_by.should be_blank
post.user_deleted.should be_true
post.raw.should == I18n.t('js.post.deleted_by_author') post.raw.should == I18n.t('js.post.deleted_by_author')
post.version.should == 2 post.version.should == 2
# lets try to recover
PostDestroyer.new(post.user, post).recover
post.reload
post.version.should == 3
post.user_deleted.should be_false
post.cooked.should == @orig
end end
end end

View file

@ -124,10 +124,14 @@ describe PostsController do
response.should be_forbidden response.should be_forbidden
end end
it "calls recover and updates the topic's statistics" do it "recovers a post correctly" do
Post.any_instance.expects(:recover!) topic_id = create_post.topic_id
Topic.any_instance.expects(:update_statistics) post = create_post(topic_id: topic_id)
PostDestroyer.new(user, post).destroy
xhr :put, :recover, post_id: post.id xhr :put, :recover, post_id: post.id
post.reload
post.deleted_at.should == nil
end end
end end