Can recover deleted topics. Deleted topics show the first post as deleted in the UI.
This commit is contained in:
parent
f05bc44fbe
commit
6ca5df0a09
|
@ -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();
|
||||
},
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'));
|
||||
},
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -39,8 +39,8 @@ class PostSerializer < BasicPostSerializer
|
|||
:draft_sequence,
|
||||
:hidden,
|
||||
:hidden_reason_id,
|
||||
:deleted_at,
|
||||
:trust_level,
|
||||
:deleted_at,
|
||||
:deleted_by
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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+/}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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");
|
||||
});
|
Loading…
Reference in New Issue