Can recover deleted topics. Deleted topics show the first post as deleted in the UI.

This commit is contained in:
Robin Ward 2013-07-12 12:08:23 -04:00
parent f05bc44fbe
commit 6ca5df0a09
16 changed files with 167 additions and 31 deletions

View File

@ -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();
},

View File

@ -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({

View File

@ -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);

View File

@ -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() {

View File

@ -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>

View File

@ -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'));
},

View File

@ -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() {

View File

@ -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

View File

@ -39,8 +39,8 @@ class PostSerializer < BasicPostSerializer
:draft_sequence,
:hidden,
:hidden_reason_id,
:deleted_at,
:trust_level,
:deleted_at,
:deleted_by

View File

@ -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)

View File

@ -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"

View File

@ -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+/}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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");
});