diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js
index adb15c1a3ad..5dc1a5a987a 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 5e43bbce072..83cdd2462c9 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 66bde67445c..4a4efe3e088 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 ff0eea79ea2..a5aa17e2286 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 518e73caa3d..f700d895387 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 @@
{{/if}}
+ {{#if details.can_recover}}
+
+
+
+ {{/if}}
+
{{#if closed}}
diff --git a/app/assets/javascripts/discourse/views/post_menu_view.js b/app/assets/javascripts/discourse/views/post_menu_view.js
index 22a3f627328..7193ceacbaa 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("");
- return;
- }
- // Show the correct button (undo or delete)
- if (post.get('deleted_at')) {
- if (post.get('can_recover')) {
- buffer.push("");
+ 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("");
}
+
+ buffer.push("");
},
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 328d8908265..44e8de0d143 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 2db297b5439..dc740ddceea 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 53b25b6f011..e13a56ccb83 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 4720f090945..aa2e1150026 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 f8cdc7cac55..5f2e1769102 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 b4a3a6a5e7a..a9b8beaedaf 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 d5a24d71c66..f4949aaf911 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 0eb163bfdd6..4278df2b897 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 22327d72958..787acb7566e 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 e29faa70d6b..4fb86c1f3a3 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