From 6ca5df0a0954ca0e5874f06ebcf4ccb551f81a6c Mon Sep 17 00:00:00 2001
From: Robin Ward <robin.ward@gmail.com>
Date: Fri, 12 Jul 2013 12:08:23 -0400
Subject: [PATCH] Can recover deleted topics. Deleted topics show the first
 post as deleted in the UI.

---
 .../discourse/controllers/topic_controller.js |  5 +-
 .../javascripts/discourse/models/post.js      | 17 +++++-
 .../javascripts/discourse/models/topic.js     | 15 ++++-
 .../discourse/models/topic_details.js         |  2 -
 .../templates/topic_admin_menu.js.handlebars  |  6 ++
 .../discourse/views/post_menu_view.js         | 59 +++++++++++++------
 .../javascripts/discourse/views/post_view.js  |  9 +--
 app/controllers/topics_controller.rb          |  8 +++
 app/serializers/post_serializer.rb            |  2 +-
 app/serializers/topic_view_serializer.rb      |  1 +
 config/locales/client.en.yml                  |  1 +
 config/routes.rb                              |  2 +-
 lib/guardian.rb                               |  4 ++
 spec/components/guardian_spec.rb              | 20 +++++++
 spec/controllers/topics_controller_spec.rb    | 31 ++++++++++
 test/javascripts/models/topic_test.js         | 16 ++++-
 16 files changed, 167 insertions(+), 31 deletions(-)

diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js
index adb15c1a3..5dc1a5a98 100644
--- a/app/assets/javascripts/discourse/controllers/topic_controller.js
+++ b/app/assets/javascripts/discourse/controllers/topic_controller.js
@@ -247,6 +247,10 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
     return Discourse.User.current() && !this.get('isPrivateMessage');
   }.property('isPrivateMessage'),
 
+  recoverTopic: function() {
+    this.get('content').recover();
+  },
+
   deleteTopic: function() {
     this.unsubscribe();
     this.get('content').destroy(Discourse.User.current());
@@ -380,7 +384,6 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
   },
 
   recoverPost: function(post) {
-    post.set('deleted_at', null);
     post.recover();
   },
 
diff --git a/app/assets/javascripts/discourse/models/post.js b/app/assets/javascripts/discourse/models/post.js
index 5e43bbce0..83cdd2462 100644
--- a/app/assets/javascripts/discourse/models/post.js
+++ b/app/assets/javascripts/discourse/models/post.js
@@ -173,21 +173,34 @@ Discourse.Post = Discourse.Model.extend({
     }
   },
 
+  /**
+    Recover a deleted post
+
+    @method recover
+  **/
   recover: function() {
     this.setProperties({
       deleted_at: null,
-      deleted_by: null
+      deleted_by: null,
+      can_delete: true
     });
 
     return Discourse.ajax("/posts/" + (this.get('id')) + "/recover", { type: 'PUT', cache: false });
   },
 
+  /**
+    Deletes a post
+
+    @method destroy
+    @param {Discourse.User} deleted_by The user deleting the post
+  **/
   destroy: function(deleted_by) {
     // Moderators can delete posts. Regular users can only trigger a deleted at message.
     if (deleted_by.get('staff')) {
       this.setProperties({
         deleted_at: new Date(),
-        deleted_by: deleted_by
+        deleted_by: deleted_by,
+        can_delete: false
       });
     } else {
       this.setProperties({
diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js
index 66bde6744..4a4efe3e0 100644
--- a/app/assets/javascripts/discourse/models/topic.js
+++ b/app/assets/javascripts/discourse/models/topic.js
@@ -198,11 +198,24 @@ Discourse.Topic = Discourse.Model.extend({
   destroy: function(deleted_by) {
     this.setProperties({
       deleted_at: new Date(),
-      deleted_by: deleted_by
+      deleted_by: deleted_by,
+      'details.can_delete': false,
+      'details.can_recover': true
     });
     return Discourse.ajax("/t/" + this.get('id'), { type: 'DELETE' });
   },
 
+  // Recover this topic if deleted
+  recover: function(deleted_by) {
+    this.setProperties({
+      deleted_at: null,
+      deleted_by: null,
+      'details.can_delete': true,
+      'details.can_recover': false
+    });
+    return Discourse.ajax("/t/" + this.get('id') + "/recover", { type: 'PUT' });
+  },
+
   // Update our attributes from a JSON result
   updateFromJson: function(json) {
     this.get('details').updateFromJson(json.details);
diff --git a/app/assets/javascripts/discourse/models/topic_details.js b/app/assets/javascripts/discourse/models/topic_details.js
index ff0eea79e..a5aa17e22 100644
--- a/app/assets/javascripts/discourse/models/topic_details.js
+++ b/app/assets/javascripts/discourse/models/topic_details.js
@@ -11,7 +11,6 @@ Discourse.TopicDetails = Discourse.Model.extend({
   loaded: false,
 
   updateFromJson: function(details) {
-
     if (details.allowed_users) {
       details.allowed_users = details.allowed_users.map(function (u) {
         return Discourse.User.create(u);
@@ -26,7 +25,6 @@ Discourse.TopicDetails = Discourse.Model.extend({
 
     this.setProperties(details);
     this.set('loaded', true);
-
   },
 
   fewParticipants: function() {
diff --git a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars
index 518e73caa..f700d8953 100644
--- a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars
@@ -13,6 +13,12 @@
         </li>
       {{/if}}
 
+      {{#if details.can_recover}}
+        <li>
+          <button {{action recoverTopic}} class='btn btn-admin'><i class='icon-undo'></i> {{i18n topic.actions.recover}}</button>
+        </li>
+      {{/if}}
+
       <li>
         {{#if closed}}
           <button {{action toggleClosed}} class='btn btn-admin'><i class='icon-unlock'></i> {{i18n topic.actions.open}}</button>
diff --git a/app/assets/javascripts/discourse/views/post_menu_view.js b/app/assets/javascripts/discourse/views/post_menu_view.js
index 22a3f6273..7193ceacb 100644
--- a/app/assets/javascripts/discourse/views/post_menu_view.js
+++ b/app/assets/javascripts/discourse/views/post_menu_view.js
@@ -19,7 +19,8 @@ Discourse.PostMenuView = Discourse.View.extend({
     'post.showRepliesBelow',
     'post.can_delete',
     'post.read',
-    'post.topic.last_read_post_number'),
+    'post.topic.last_read_post_number',
+    'post.topic.deleted_at'),
 
   render: function(buffer) {
     var post = this.get('post');
@@ -65,30 +66,54 @@ Discourse.PostMenuView = Discourse.View.extend({
 
   // Delete button
   renderDelete: function(post, buffer) {
-    if (post.get('post_number') === 1 && this.get('controller.model.details.can_delete')) {
-      buffer.push("<button title=\"" +
-                  (I18n.t("topic.actions.delete")) +
-                  "\" data-action=\"deleteTopic\" class='delete'><i class=\"icon-trash\"></i></button>");
-      return;
-    }
-    // Show the correct button (undo or delete)
-    if (post.get('deleted_at')) {
-      if (post.get('can_recover')) {
-        buffer.push("<button title=\"" +
-                    (I18n.t("post.controls.undelete")) +
-                    "\" data-action=\"recover\" class=\"delete\"><i class=\"icon-undo\"></i></button>");
+    var label, action, icon;
+
+
+    if (post.get('post_number') === 1) {
+
+      // If if it's the first post, the delete/undo actions are related to the topic
+      var topic = post.get('topic');
+      if (topic.get('deleted_at')) {
+        if (!topic.get('details.can_recover')) { return; }
+        label = "topic.actions.recover";
+        action = "recoverTopic";
+        icon = "undo";
+      } else {
+        if (!topic.get('details.can_delete')) { return; }
+        label = "topic.actions.delete";
+        action = "deleteTopic";
+        icon = "trash";
+      }
+
+    } else {
+
+      // The delete actions target the post iteself
+      if (post.get('deleted_at')) {
+        if (!post.get('can_recover')) { return; }
+        label = "post.controls.undelete";
+        action = "recover";
+        icon = "undo";
+      } else {
+        if (!post.get('can_delete')) { return; }
+        label = "post.controls.delete";
+        action = "delete";
+        icon = "trash";
       }
-    } else if (post.get('can_delete')) {
-      buffer.push("<button title=\"" +
-                 (I18n.t("post.controls.delete")) +
-                 "\" data-action=\"delete\" class=\"delete\"><i class=\"icon-trash\"></i></button>");
     }
+
+    buffer.push("<button title=\"" +
+                I18n.t(label) +
+                "\" data-action=\"" + action + "\" class=\"delete\"><i class=\"icon-" + icon + "\"></i></button>");
   },
 
   clickDeleteTopic: function() {
     this.get('controller').deleteTopic();
   },
 
+  clickRecoverTopic: function() {
+    this.get('controller').recoverTopic();
+  },
+
   clickRecover: function() {
     this.get('controller').recoverPost(this.get('post'));
   },
diff --git a/app/assets/javascripts/discourse/views/post_view.js b/app/assets/javascripts/discourse/views/post_view.js
index 328d89082..44e8de0d1 100644
--- a/app/assets/javascripts/discourse/views/post_view.js
+++ b/app/assets/javascripts/discourse/views/post_view.js
@@ -12,7 +12,7 @@ Discourse.PostView = Discourse.View.extend({
   classNameBindings: ['postTypeClass',
                       'selected',
                       'post.hidden:hidden',
-                      'post.deleted_at:deleted',
+                      'deleted',
                       'parentPost:replies-above'],
   postBinding: 'content',
 
@@ -39,6 +39,9 @@ Discourse.PostView = Discourse.View.extend({
     }
   },
 
+  deletedViaTopic: Em.computed.and('post.firstPost', 'post.topic.deleted_at'),
+  deleted: Em.computed.or('post.deleted_at', 'deletedViaTopic'),
+
   selected: function() {
     var selectedPosts = this.get('controller.selectedPosts');
     if (!selectedPosts) return false;
@@ -49,9 +52,7 @@ Discourse.PostView = Discourse.View.extend({
     return this.get('selected') ? I18n.t('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : I18n.t('topic.multi_select.select');
   }.property('selected', 'controller.selectedPostsCount'),
 
-  repliesHidden: function() {
-    return !this.get('repliesShown');
-  }.property('repliesShown'),
+  repliesHidden: Em.computed.not('repliesShown'),
 
   // Click on the replies button
   showReplies: function() {
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 2db297b54..dc740ddce 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -9,6 +9,7 @@ class TopicsController < ApplicationController
                                           :update,
                                           :star,
                                           :destroy,
+                                          :recover,
                                           :status,
                                           :invite,
                                           :mute,
@@ -175,6 +176,13 @@ class TopicsController < ApplicationController
     render nothing: true
   end
 
+  def recover
+    topic = Topic.where(id: params[:topic_id]).with_deleted.first
+    guardian.ensure_can_recover_topic!(topic)
+    topic.recover!
+    render nothing: true
+  end
+
   def excerpt
     render nothing: true
   end
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 53b25b6f0..e13a56ccb 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -39,8 +39,8 @@ class PostSerializer < BasicPostSerializer
              :draft_sequence,
              :hidden,
              :hidden_reason_id,
-             :deleted_at,
              :trust_level,
+             :deleted_at,
              :deleted_by
 
 
diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb
index 4720f0909..aa2e11500 100644
--- a/app/serializers/topic_view_serializer.rb
+++ b/app/serializers/topic_view_serializer.rb
@@ -88,6 +88,7 @@ class TopicViewSerializer < ApplicationSerializer
     result[:can_move_posts] = true if scope.can_move_posts?(object.topic)
     result[:can_edit] = true if scope.can_edit?(object.topic)
     result[:can_delete] = true if scope.can_delete?(object.topic)
+    result[:can_recover] = true if scope.can_recover_topic?(object.topic)
     result[:can_remove_allowed_users] = true if scope.can_remove_allowed_users?(object.topic)
     result[:can_invite_to] = true if scope.can_invite_to?(object.topic)
     result[:can_create_post] = true if scope.can_create?(Post, object.topic)
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index f8cdc7cac..5f2e17691 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -630,6 +630,7 @@ en:
           description: "you will not be notified of anything about this topic, and it will not appear on your unread tab."
 
       actions:
+        recover: "Un-Delete Topic"
         delete: "Delete Topic"
         open: "Open Topic"
         close: "Close Topic"
diff --git a/config/routes.rb b/config/routes.rb
index b4a3a6a5e..a9b8beaed 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -217,7 +217,7 @@ Discourse::Application.routes.draw do
   put 't/:topic_id/unmute' => 'topics#unmute', constraints: {topic_id: /\d+/}
   put 't/:topic_id/autoclose' => 'topics#autoclose', constraints: {topic_id: /\d+/}
   put 't/:topic_id/remove-allowed-user' => 'topics#remove_allowed_user', constraints: {topic_id: /\d+/}
-
+  put 't/:topic_id/recover' => 'topics#recover', constraints: {topic_id: /\d+/}
   get 't/:topic_id/:post_number' => 'topics#show', constraints: {topic_id: /\d+/, post_number: /\d+/}
   get 't/:slug/:topic_id.rss' => 'topics#feed', format: :rss, constraints: {topic_id: /\d+/}
   get 't/:slug/:topic_id' => 'topics#show', constraints: {topic_id: /\d+/}
diff --git a/lib/guardian.rb b/lib/guardian.rb
index d5a24d71c..f4949aaf9 100644
--- a/lib/guardian.rb
+++ b/lib/guardian.rb
@@ -282,6 +282,10 @@ class Guardian
     is_staff?
   end
 
+  def can_recover_topic?(topic)
+    is_staff?
+  end
+
   def can_delete_category?(category)
     is_staff? && category.topic_count == 0
   end
diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb
index 0eb163bfd..4278df2b8 100644
--- a/spec/components/guardian_spec.rb
+++ b/spec/components/guardian_spec.rb
@@ -410,6 +410,25 @@ describe Guardian do
     end
   end
 
+  describe "can_recover_topic?" do
+
+    it "returns false for a nil user" do
+      Guardian.new(nil).can_recover_topic?(topic).should be_false
+    end
+
+    it "returns false for a nil object" do
+      Guardian.new(user).can_recover_topic?(nil).should be_false
+    end
+
+    it "returns false for a regular user" do
+      Guardian.new(user).can_recover_topic?(topic).should be_false
+    end
+
+    it "returns true for a moderator" do
+      Guardian.new(moderator).can_recover_topic?(topic).should be_true
+    end
+  end
+
   describe "can_recover_post?" do
 
     it "returns false for a nil user" do
@@ -622,6 +641,7 @@ describe Guardian do
 
 
 
+
   context 'can_delete?' do
 
     it 'returns false with a nil object' do
diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb
index 22327d729..787acb756 100644
--- a/spec/controllers/topics_controller_spec.rb
+++ b/spec/controllers/topics_controller_spec.rb
@@ -393,6 +393,37 @@ describe TopicsController do
     end
   end
 
+  describe 'recover' do
+    it "won't allow us to recover a topic when we're not logged in" do
+      lambda { xhr :put, :recover, topic_id: 1 }.should raise_error(Discourse::NotLoggedIn)
+    end
+
+    describe 'when logged in' do
+      let(:topic) { Fabricate(:topic, user: log_in, deleted_at: Time.now, deleted_by: log_in) }
+
+      describe 'without access' do
+        it "raises an exception when the user doesn't have permission to delete the topic" do
+          Guardian.any_instance.expects(:can_recover_topic?).with(topic).returns(false)
+          xhr :put, :recover, topic_id: topic.id
+          response.should be_forbidden
+        end
+      end
+
+      context 'with permission' do
+        before do
+          Guardian.any_instance.expects(:can_recover_topic?).with(topic).returns(true)
+        end
+
+        it 'succeeds' do
+          Topic.any_instance.expects(:recover!)
+          xhr :put, :recover, topic_id: topic.id
+          response.should be_success
+        end
+      end
+    end
+
+  end
+
   describe 'delete' do
     it "won't allow us to delete a topic when we're not logged in" do
       lambda { xhr :delete, :destroy, id: 1 }.should raise_error(Discourse::NotLoggedIn)
diff --git a/test/javascripts/models/topic_test.js b/test/javascripts/models/topic_test.js
index e29faa70d..4fb86c1f3 100644
--- a/test/javascripts/models/topic_test.js
+++ b/test/javascripts/models/topic_test.js
@@ -45,13 +45,25 @@ test("updateFromJson", function() {
 });
 
 test("destroy", function() {
-  var topic = Discourse.Topic.create({id: 1234});
   var user = Discourse.User.create({username: 'eviltrout'});
+  var topic = Discourse.Topic.create({id: 1234});
 
   this.stub(Discourse, 'ajax');
 
   topic.destroy(user);
   present(topic.get('deleted_at'), 'deleted at is set');
   equal(topic.get('deleted_by'), user, 'deleted by is set');
-  ok(Discourse.ajax.calledOnce, "it called delete over the wire");
+  //ok(Discourse.ajax.calledOnce, "it called delete over the wire");
+});
+
+test("recover", function() {
+  var user = Discourse.User.create({username: 'eviltrout'});
+  var topic = Discourse.Topic.create({id: 1234, deleted_at: new Date(), deleted_by: user});
+
+  this.stub(Discourse, 'ajax');
+
+  topic.recover();
+  blank(topic.get('deleted_at'), "it clears deleted_at");
+  blank(topic.get('deleted_by'), "it clears deleted_by");
+  //ok(Discourse.ajax.calledOnce, "it called recover over the wire");
 });
\ No newline at end of file