diff --git a/app/assets/javascripts/discourse/components/utilities.coffee b/app/assets/javascripts/discourse/components/utilities.coffee index 69c445f99..82f4c2304 100644 --- a/app/assets/javascripts/discourse/components/utilities.coffee +++ b/app/assets/javascripts/discourse/components/utilities.coffee @@ -155,7 +155,9 @@ Discourse.Utilities = # Takes raw input and cooks it to display nicely (mostly markdown) - cook: (raw, opts) -> + cook: (raw, opts=null) -> + + opts ||= {} # Make sure we've got a string return "" unless raw diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js.coffee b/app/assets/javascripts/discourse/controllers/topic_controller.js.coffee index 6449ba373..cd8101f27 100644 --- a/app/assets/javascripts/discourse/controllers/topic_controller.js.coffee +++ b/app/assets/javascripts/discourse/controllers/topic_controller.js.coffee @@ -299,14 +299,15 @@ Discourse.TopicController = Ember.ObjectController.extend Discourse.Presence, @get('controllers.modal')?.show(view) false + recoverPost: (post) -> + post.set('deleted_at', null) + post.recover() + deletePost: (post) -> - - deleted = !!post.get('deleted_at') - - if deleted - post.set('deleted_at', null) + if post.get('user_id') is Discourse.get('currentUser.id') + post.set('cooked', Discourse.Utilities.cook(Em.String.i18n("post.deleted_by_author"))) + post.set('can_delete', false) else post.set('deleted_at', new Date()) - post.delete => - # nada + post.delete() diff --git a/app/assets/javascripts/discourse/models/post.js.coffee.erb b/app/assets/javascripts/discourse/models/post.js.coffee.erb index 64ab9743d..79177716b 100644 --- a/app/assets/javascripts/discourse/models/post.js.coffee.erb +++ b/app/assets/javascripts/discourse/models/post.js.coffee.erb @@ -153,8 +153,11 @@ window.Discourse.Post = Ember.Object.extend Discourse.Presence, error: (result) -> error?(result) + recover: -> + $.ajax "/posts/#{@get('id')}/recover", type: 'PUT', cache: false + delete: (complete) -> - $.ajax "/posts/#{@get('id')}", type: 'DELETE', success: (result) -> complete() + $.ajax "/posts/#{@get('id')}", type: 'DELETE', success: (result) -> complete?() # Update the properties of this post from an obj, ignoring cooked as we should already # have that rendered. diff --git a/app/assets/javascripts/discourse/views/post_menu_view.js.coffee b/app/assets/javascripts/discourse/views/post_menu_view.js.coffee index 65297ee70..9d67b30fb 100644 --- a/app/assets/javascripts/discourse/views/post_menu_view.js.coffee +++ b/app/assets/javascripts/discourse/views/post_menu_view.js.coffee @@ -26,7 +26,7 @@ window.Discourse.PostMenuView = Ember.View.extend Discourse.Presence, # Trigger re rendering needsToRender: (-> @rerender() - ).observes('post.deleted_at', 'post.flagsAvailable.@each', 'post.url', 'post.bookmarked', 'post.reply_count', 'post.replyBelowUrl') + ).observes('post.deleted_at', 'post.flagsAvailable.@each', 'post.url', 'post.bookmarked', 'post.reply_count', 'post.replyBelowUrl', 'post.can_delete') # Replies Button renderReplies: (post, buffer) -> @@ -49,11 +49,14 @@ window.Discourse.PostMenuView = Ember.View.extend Discourse.Presence, # Delete button renderDelete: (post, buffer) -> - return unless post.get('can_delete') - title = if post.get('deleted_at') then Em.String.i18n("post.controls.undelete") else Em.String.i18n("post.controls.delete") - buffer.push("<button title=\"#{title}\" data-action=\"delete\"><i class=\"icon-trash\"></i></button>") + if post.get('deleted_at') + if post.get('can_recover') + buffer.push("<button title=\"#{Em.String.i18n("post.controls.undelete")}\" data-action=\"recover\"><i class=\"icon-undo\"></i></button>") + else if post.get('can_delete') + buffer.push("<button title=\"#{Em.String.i18n("post.controls.delete")}\" data-action=\"delete\"><i class=\"icon-trash\"></i></button>") + clickRecover: -> @get('controller').recoverPost(@get('post')) clickDelete: -> @get('controller').deletePost(@get('post')) # Like button diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index d503b66b0..ce4d2e9ba 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -74,16 +74,16 @@ class PostsController < ApplicationController end def destroy - Post.transaction do - post = Post.with_deleted.where(id: params[:id]).first - guardian.ensure_can_delete!(post) - if post.deleted_at.nil? - post.destroy - else - post.recover - end - Topic.reset_highest(post.topic_id) - end + post = Post.where(id: params[:id]).first + guardian.ensure_can_delete!(post) + post.delete_by(current_user) + render nothing: true + end + + def recover + post = Post.with_deleted.where(id: params[:post_id]).first + guardian.ensure_can_recover_post!(post) + post.recover render nothing: true end diff --git a/app/models/post.rb b/app/models/post.rb index 6617c57a7..9482b9321 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -159,7 +159,23 @@ class Post < ActiveRecord::Base else @raw_mentions = [] end + end + # The rules for deletion change depending on who is doing it. + def delete_by(deleted_by) + if deleted_by.has_trust_level?(:moderator) + # As a moderator, delete the post. + Post.transaction do + self.destroy + Topic.reset_highest(self.topic_id) + end + elsif deleted_by.id == self.user_id + # As the poster, make a revision that says deleted. + Post.transaction do + revise(deleted_by, I18n.t('js.post.deleted_by_author'), force_new_version: true) + update_column(:user_deleted, true) + end + end end def archetype @@ -311,6 +327,8 @@ class Post < ActiveRecord::Base # We always create a new version if it's been greater than the ninja edit window new_version = true if (revised_at - last_version_at) > SiteSetting.ninja_edit_window.to_i + new_version = true if opts[:force_new_version] + # Create the new version (or don't) if new_version diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index f51913206..8e5f74379 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -28,6 +28,7 @@ class PostSerializer < ApplicationSerializer :version, :can_edit, :can_delete, + :can_recover, :link_counts, :cooked, :read, @@ -66,6 +67,10 @@ class PostSerializer < ApplicationSerializer scope.can_delete?(object) end + def can_recover + scope.can_recover_post?(object) + end + def link_counts return @single_post_link_counts if @single_post_link_counts.present? diff --git a/config/locales/en.yml b/config/locales/en.yml index db014fc30..88b9d9626 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -831,6 +831,7 @@ en: reply_as_new_topic: "Reply as new Topic" continue_discussion: "Continuing the discussion from {{postLink}}:" follow_quote: "go to the quoted post" + deleted_by_author: "Post deleted by author." has_replies_below: one: "Reply Below" diff --git a/config/routes.rb b/config/routes.rb index 9bcd44afd..c81a86d17 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,6 +103,7 @@ Discourse::Application.routes.draw do get 'versions' put 'bookmark' get 'replies' + put 'recover' collection do delete 'destroy_many' end diff --git a/db/migrate/20130207200019_add_user_deleted_to_posts.rb b/db/migrate/20130207200019_add_user_deleted_to_posts.rb new file mode 100644 index 000000000..04e45a1a0 --- /dev/null +++ b/db/migrate/20130207200019_add_user_deleted_to_posts.rb @@ -0,0 +1,5 @@ +class AddUserDeletedToPosts < ActiveRecord::Migration + def change + add_column :posts, :user_deleted, :boolean, null: false, default: false + end +end diff --git a/db/structure.sql b/db/structure.sql index dc1b58777..b7456f03e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1798,7 +1798,8 @@ CREATE TABLE posts ( spam_count integer DEFAULT 0 NOT NULL, illegal_count integer DEFAULT 0 NOT NULL, inappropriate_count integer DEFAULT 0 NOT NULL, - last_version_at timestamp without time zone NOT NULL + last_version_at timestamp without time zone NOT NULL, + user_deleted boolean DEFAULT false NOT NULL ); @@ -4569,4 +4570,6 @@ INSERT INTO schema_migrations (version) VALUES ('20130203204338'); INSERT INTO schema_migrations (version) VALUES ('20130204000159'); -INSERT INTO schema_migrations (version) VALUES ('20130205021905'); \ No newline at end of file +INSERT INTO schema_migrations (version) VALUES ('20130205021905'); + +INSERT INTO schema_migrations (version) VALUES ('20130207200019'); \ No newline at end of file diff --git a/lib/guardian.rb b/lib/guardian.rb index 48b4d4128..7654a62fa 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -232,6 +232,15 @@ class Guardian # Can't delete the first post return false if post.post_number == 1 + # You can delete your own posts + return !post.user_deleted? if post.user == @user + + @user.has_trust_level?(:moderator) + end + + # Recovery Method + def can_recover_post?(post) + return false if @user.blank? @user.has_trust_level?(:moderator) end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 3a1d47732..355c2324c 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -361,6 +361,26 @@ describe Guardian do end end + describe "can_recover_post?" do + + it "returns false for a nil user" do + Guardian.new(nil).can_recover_post?(post).should be_false + end + + it "returns false for a nil object" do + Guardian.new(user).can_recover_post?(nil).should be_false + end + + it "returns false for a regular user" do + Guardian.new(user).can_recover_post?(post).should be_false + end + + it "returns true for a moderator" do + Guardian.new(moderator).can_recover_post?(post).should be_true + end + + end + describe 'can_edit?' do it 'returns false with a nil object' do @@ -576,10 +596,20 @@ describe Guardian do Guardian.new.can_delete?(post).should be_false end - it 'returns false when not a moderator' do + it "returns false when trying to delete your own post that has already been deleted" do + post.delete_by(user) + post.reload Guardian.new(user).can_delete?(post).should be_false end + it 'returns true when trying to delete your own post' do + Guardian.new(user).can_delete?(post).should be_true + end + + it "returns false when trying to delete another user's own post" do + Guardian.new(Fabricate(:user)).can_delete?(post).should be_false + end + it "returns false when it's the OP, even as a moderator" do post.update_attribute :post_number, 1 Guardian.new(moderator).can_delete?(post).should be_false diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index a96c16cc3..302da485e 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -51,7 +51,8 @@ describe PostsController do describe 'when logged in' do - let(:post) { Fabricate(:post, user: log_in(:moderator), post_number: 2) } + let(:user) { log_in(:moderator) } + let(:post) { Fabricate(:post, user: user, post_number: 2) } it "raises an error when the user doesn't have permission to see the post" do Guardian.any_instance.expects(:can_delete?).with(post).returns(false) @@ -59,19 +60,39 @@ describe PostsController do response.should be_forbidden end - it "deletes the post" do - Post.any_instance.expects(:destroy) - xhr :delete, :destroy, id: post.id - end - - it "updates the highest read data for the forum" do - Topic.expects(:reset_highest).with(post.topic_id) + it "calls delete_by" do + Post.any_instance.expects(:delete_by).with(user) xhr :delete, :destroy, id: post.id end end end + describe 'recover a post' do + it 'raises an exception when not logged in' do + lambda { xhr :put, :recover, post_id: 123 }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + + let(:user) { log_in(:moderator) } + let(:post) { Fabricate(:post, user: user, post_number: 2) } + + it "raises an error when the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_recover_post?).with(post).returns(false) + xhr :put, :recover, post_id: post.id + response.should be_forbidden + end + + it "calls recover" do + Post.any_instance.expects(:recover) + xhr :put, :recover, post_id: post.id + end + + end + end + + describe 'destroy_many' do it 'raises an exception when not logged in' do lambda { xhr :delete, :destroy_many, post_ids: [123, 345] }.should raise_error(Discourse::NotLoggedIn) diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index ec698f02e..79fdd7921 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -505,6 +505,48 @@ describe Post do end + describe 'delete_by' do + + let(:moderator) { Fabricate(:moderator) } + let(:post) { Fabricate(:post) } + + context "as the creator of the post" do + + before do + post.delete_by(post.user) + post.reload + end + + it "doesn't delete the post" do + post.deleted_at.should be_blank + end + + it "updates the text of the post" do + post.raw.should == I18n.t('js.post.deleted_by_author') + end + + + it "creates a new version" do + post.version.should == 2 + end + + end + + context "as a moderator" do + + before do + post.delete_by(post.user) + post.reload + end + + it "deletes the post" do + post.deleted_at.should be_blank + end + + end + + end + describe 'after delete' do let!(:coding_horror) { Fabricate(:coding_horror) } @@ -550,6 +592,10 @@ describe Post do let(:post) { Fabricate(:post, post_args) } + it "defaults to not user_deleted" do + post.user_deleted?.should be_false + end + it 'has a post nubmer' do post.post_number.should be_present end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fa9098f8d..636bd1ae5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -88,6 +88,7 @@ end Spork.each_run do # This code will be run each time you run your specs. $redis.client.reconnect + MessageBus.reliable_pub_sub.pub_redis.client.reconnect end # --- Instructions ---