FEATURE: Hide deleted posts by default for staff

This commit is contained in:
riking 2014-07-15 14:02:43 -07:00
parent ede8f22971
commit 19b757b058
15 changed files with 172 additions and 46 deletions

View file

@ -0,0 +1,20 @@
/**
The controls for toggling the supression of deleted posts
@class ToggleDeletedComponent
@extends Ember.Component
@namespace Discourse
@module Discourse
**/
export default Ember.Component.extend({
layoutName: 'components/toggle-deleted',
tagName: 'section',
classNames: ['information'],
postStream: Em.computed.alias('topic.postStream'),
actions: {
toggleDeleted: function() {
this.get('postStream').toggleDeleted();
}
}
});

View file

@ -13,7 +13,7 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
editingTopic: false, editingTopic: false,
selectedPosts: null, selectedPosts: null,
selectedReplies: null, selectedReplies: null,
queryParams: ['filter', 'username_filters'], queryParams: ['filter', 'username_filters', 'show_deleted'],
contextChanged: function(){ contextChanged: function(){
this.set('controllers.search.searchContext', this.get('model.searchContext')); this.set('controllers.search.searchContext', this.get('model.searchContext'));

View file

@ -120,6 +120,7 @@ Discourse.PostStream = Em.Object.extend({
streamFilters: function() { streamFilters: function() {
var result = {}; var result = {};
if (this.get('summary')) { result.filter = "summary"; } if (this.get('summary')) { result.filter = "summary"; }
if (this.get('show_deleted')) { result.show_deleted = true; }
var userFilters = this.get('userFilters'); var userFilters = this.get('userFilters');
if (!Em.isEmpty(userFilters)) { if (!Em.isEmpty(userFilters)) {
@ -128,7 +129,7 @@ Discourse.PostStream = Em.Object.extend({
} }
return result; return result;
}.property('userFilters.[]', 'summary'), }.property('userFilters.[]', 'summary', 'show_deleted'),
hasNoFilters: function() { hasNoFilters: function() {
var streamFilters = this.get('streamFilters'); var streamFilters = this.get('streamFilters');
@ -186,6 +187,7 @@ Discourse.PostStream = Em.Object.extend({
**/ **/
cancelFilter: function() { cancelFilter: function() {
this.set('summary', false); this.set('summary', false);
this.set('show_deleted', false);
this.get('userFilters').clear(); this.get('userFilters').clear();
}, },
@ -200,6 +202,11 @@ Discourse.PostStream = Em.Object.extend({
return this.refresh(); return this.refresh();
}, },
toggleDeleted: function() {
this.toggleProperty('show_deleted');
return this.refresh();
},
/** /**
Filter the stream to a particular user. Filter the stream to a particular user.
@ -208,6 +215,7 @@ Discourse.PostStream = Em.Object.extend({
toggleParticipant: function(username) { toggleParticipant: function(username) {
var userFilters = this.get('userFilters'); var userFilters = this.get('userFilters');
this.set('summary', false); this.set('summary', false);
this.set('show_deleted', true);
if (userFilters.contains(username)) { if (userFilters.contains(username)) {
userFilters.remove(username); userFilters.remove(username);
} else { } else {

View file

@ -396,6 +396,7 @@ Discourse.Topic.reopenClass({
opts.userFilters.forEach(function(username) { opts.userFilters.forEach(function(username) {
data.username_filters.push(username); data.username_filters.push(username);
}); });
data.show_deleted = true;
} }
// Add the summary of filter if we have it // Add the summary of filter if we have it

View file

@ -11,7 +11,8 @@ Discourse.TopicRoute = Discourse.Route.extend({
queryParams: { queryParams: {
filter: { replace: true }, filter: { replace: true },
username_filters: { replace: true } username_filters: { replace: true },
show_deleted: { replace: true }
}, },
actions: { actions: {
@ -96,6 +97,7 @@ Discourse.TopicRoute = Discourse.Route.extend({
setupParams: function(topic, params) { setupParams: function(topic, params) {
var postStream = topic.get('postStream'); var postStream = topic.get('postStream');
postStream.set('summary', Em.get(params, 'filter') === 'summary'); postStream.set('summary', Em.get(params, 'filter') === 'summary');
postStream.set('show_deleted', !!Em.get(params, 'show_deleted'));
var usernames = Em.get(params, 'username_filters'), var usernames = Em.get(params, 'username_filters'),
userFilters = postStream.get('userFilters'); userFilters = postStream.get('userFilters');

View file

@ -0,0 +1,7 @@
{{#if postStream.show_deleted}}
<p>{{i18n deleted_filter.disabled_description}}</p>
<button class='btn btn-danger' {{action toggleDeleted}}>{{i18n deleted_filter.enable}}</button>
{{else}}
<p>{{i18n deleted_filter.enabled_description}}</p>
<button class='btn btn-danger' {{action toggleDeleted}}>{{i18n deleted_filter.disable}}</button>
{{/if}}

View file

@ -10,6 +10,7 @@
import PrivateMessageMapComponent from 'discourse/components/private-message-map'; import PrivateMessageMapComponent from 'discourse/components/private-message-map';
import TopicMapComponent from 'discourse/components/topic-map'; import TopicMapComponent from 'discourse/components/topic-map';
import ToggleSummaryComponent from 'discourse/components/toggle-summary'; import ToggleSummaryComponent from 'discourse/components/toggle-summary';
import ToggleDeletedComponent from 'discourse/components/toggle-deleted';
export default Discourse.ContainerView.extend({ export default Discourse.ContainerView.extend({
classNameBindings: ['hidden', ':topic-map'], classNameBindings: ['hidden', ':topic-map'],
@ -43,6 +44,16 @@ export default Discourse.ContainerView.extend({
}, ToggleSummaryComponent); }, ToggleSummaryComponent);
} }
if (Discourse.User.currentProp('staff')) {
// If we have deleted post filtering
if (topic.get('has_deleted')) {
container.attachViewWithArgs({
topic: topic,
filterBinding: 'controller.filter'
}, ToggleDeletedComponent);
}
}
// If we have a private message // If we have a private message
if (this.get('topic.isPrivateMessage')) { if (this.get('topic.isPrivateMessage')) {
container.attachViewWithArgs({ topic: topic, showPrivateInviteAction: 'showPrivateInvite' }, PrivateMessageMapComponent); container.attachViewWithArgs({ topic: topic, showPrivateInviteAction: 'showPrivateInvite' }, PrivateMessageMapComponent);

View file

@ -1,24 +1,3 @@
.gap {
background-color: scale-color-diff();
padding: 5px 0;
color: lighten($primary, 35%);
cursor: pointer;
text-align: center;
width: 80%;
&:hover {
background-color: scale-color-diff();
}
@include medium-width {
width: 795px;
}
@include small-width {
width: 815px;
}
}
.container { .container {
@extend .clearfix; @extend .clearfix;
max-width: $large-width; max-width: $large-width;
@ -759,12 +738,16 @@ blockquote > *:last-child {
} }
} }
// variables are used to calculate the width of .gap
$topic-body-width: 690px;
$topic-body-width-padding: 11px;
$topic-avatar-width: 45px;
.topic-body { .topic-body {
width: 690px; width: $topic-body-width;
float: left; float: left;
position: relative; position: relative;
border-top: 1px solid scale-color-diff(); border-top: 1px solid scale-color-diff();
padding: 12px 11px 15px 11px; padding: 12px $topic-body-width-padding 15px $topic-body-width-padding;
&.highlighted { &.highlighted {
background-color: scale-color($tertiary, $lightness: 85%); background-color: scale-color($tertiary, $lightness: 85%);
} }
@ -776,7 +759,7 @@ blockquote > *:last-child {
.topic-avatar { .topic-avatar {
border-top: 1px solid scale-color-diff(); border-top: 1px solid scale-color-diff();
padding-top: 16px; padding-top: 16px;
width: 45px; width: $topic-avatar-width;
float: left; float: left;
.wiki { .wiki {
@ -786,6 +769,15 @@ blockquote > *:last-child {
} }
} }
.gap {
background-color: scale-color-diff();
padding: 5px 0;
color: lighten($primary, 30%);
cursor: pointer;
text-align: center;
width: calc(#{$topic-avatar-width} + #{$topic-body-width} + 2 * #{$topic-body-width-padding});
}
.posts-wrapper { .posts-wrapper {
position: relative; position: relative;
} }

View file

@ -35,7 +35,7 @@ class TopicsController < ApplicationController
# existing installs. # existing installs.
return wordpress if params[:best].present? return wordpress if params[:best].present?
opts = params.slice(:username_filters, :filter, :page, :post_number) opts = params.slice(:username_filters, :filter, :page, :post_number, :show_deleted)
username_filters = opts[:username_filters] username_filters = opts[:username_filters]
opts[:username_filters] = username_filters.split(',') if username_filters.is_a?(String) opts[:username_filters] = username_filters.split(',') if username_filters.is_a?(String)

View file

@ -39,6 +39,7 @@ class TopicViewSerializer < ApplicationSerializer
:highest_post_number, :highest_post_number,
:last_read_post_number, :last_read_post_number,
:deleted_by, :deleted_by,
:has_deleted,
:actions_summary, :actions_summary,
:expandable_first_post :expandable_first_post
@ -177,6 +178,14 @@ class TopicViewSerializer < ApplicationSerializer
result result
end end
def has_deleted
object.has_deleted?
end
def include_has_deleted?
object.guardian.can_see_deleted_posts?
end
def expandable_first_post def expandable_first_post
true true
end end

View file

@ -514,6 +514,12 @@ en:
enable: 'Summarize This Topic' enable: 'Summarize This Topic'
disable: 'Show All Posts' disable: 'Show All Posts'
deleted_filter:
enabled_description: "This topic contains deleted posts, which have been hidden. To see all posts again, click below."
disabled_description: "Deleted posts in the topic are shown. To hide them again, click below."
enable: "Hide Deleted Posts"
disable: "Unhide Deleted Posts"
private_message_info: private_message_info:
title: "Private Message" title: "Private Message"
invite: "Invite Others..." invite: "Invite Others..."

View file

@ -45,14 +45,16 @@ module TopicGuardian
authenticated? && topic && not(topic.private_message?) && @user.has_trust_level?(:basic) authenticated? && topic && not(topic.private_message?) && @user.has_trust_level?(:basic)
end end
def can_see_deleted_topics?
is_staff?
end
def can_see_topic?(topic) def can_see_topic?(topic)
return false unless topic return false unless topic
# Admins can see everything
return true if is_admin? return true if is_admin?
return false if topic.deleted_at # Deleted topics
return false if topic.deleted_at && !can_see_deleted_topics?
# NOTE
# At the moment staff can see PMs, there is some talk of restricting this, however
# we still need to allow staff to join PMs for the case of flagging ones
# not secure, or I can see it # not secure, or I can see it
(not(topic.read_restricted_category?) || can_see_category?(topic.category)) && (not(topic.read_restricted_category?) || can_see_category?(topic.category)) &&

View file

@ -11,8 +11,8 @@ class TopicView
def initialize(topic_id, user=nil, options={}) def initialize(topic_id, user=nil, options={})
@user = user @user = user
@topic = find_topic(topic_id)
@guardian = Guardian.new(@user) @guardian = Guardian.new(@user)
@topic = find_topic(topic_id)
check_and_raise_exceptions check_and_raise_exceptions
options.each do |key, value| options.each do |key, value|
@ -187,6 +187,10 @@ class TopicView
read_posts_set.include?(post_number) read_posts_set.include?(post_number)
end end
def has_deleted?
@predelete_filtered_posts.with_deleted.where("deleted_at IS NOT NULL").exists?
end
def topic_user def topic_user
@topic_user ||= begin @topic_user ||= begin
return nil if @user.blank? return nil if @user.blank?
@ -281,7 +285,7 @@ class TopicView
.includes(:user) .includes(:user)
.includes(:reply_to_user) .includes(:reply_to_user)
.order('sort_order') .order('sort_order')
@posts = @posts.with_deleted if @user.try(:staff?) @posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
@posts @posts
end end
@ -300,13 +304,13 @@ class TopicView
def find_topic(topic_id) def find_topic(topic_id)
finder = Topic.where(id: topic_id).includes(:category) finder = Topic.where(id: topic_id).includes(:category)
finder = finder.with_deleted if @user.try(:staff?) finder = finder.with_deleted if @guardian.can_see_deleted_topics?
finder.first finder.first
end end
def unfiltered_posts def unfiltered_posts
result = @topic.posts result = @topic.posts
result = result.with_deleted if @user.try(:staff?) result = result.with_deleted if @guardian.can_see_deleted_posts?
result = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users result = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users
result result
end end
@ -316,7 +320,6 @@ class TopicView
# Certain filters might leave gaps between posts. If that's true, we can return a gap structure # Certain filters might leave gaps between posts. If that's true, we can return a gap structure
@contains_gaps = false @contains_gaps = false
@filtered_posts = unfiltered_posts @filtered_posts = unfiltered_posts
@filtered_posts = @filtered_posts.with_deleted if @user.try(:staff?)
# Filters # Filters
if @filter == 'summary' if @filter == 'summary'
@ -329,12 +332,22 @@ class TopicView
@contains_gaps = true @contains_gaps = true
end end
# Username filters
if @username_filters.present? if @username_filters.present?
usernames = @username_filters.map{|u| u.downcase} usernames = @username_filters.map{|u| u.downcase}
@filtered_posts = @filtered_posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames) @filtered_posts = @filtered_posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames)
@contains_gaps = true @contains_gaps = true
end end
# Deleted
# This should be last - don't want to tell the admin about deleted posts that clicking the button won't show
# copy the filter for has_deleted? method
@predelete_filtered_posts = @filtered_posts.spawn
if @guardian.can_see_deleted_posts? && !@show_deleted && has_deleted?
@filtered_posts = @filtered_posts.where(deleted_at: nil)
@contains_gaps = true
end
end end
def check_and_raise_exceptions def check_and_raise_exceptions

View file

@ -326,6 +326,15 @@ describe Guardian do
Guardian.new(user).can_see?(topic).should be_true Guardian.new(user).can_see?(topic).should be_true
end end
it "restricts deleted topics" do
topic = Fabricate(:topic)
topic.trash!(moderator)
Guardian.new(build(:user)).can_see?(topic).should be_false
Guardian.new(moderator).can_see?(topic).should be_true
Guardian.new(admin).can_see?(topic).should be_true
end
it "restricts private topics" do it "restricts private topics" do
user.save! user.save!
private_topic = Fabricate(:private_message_topic, user: user) private_topic = Fabricate(:private_message_topic, user: user)
@ -334,6 +343,17 @@ describe Guardian do
Guardian.new(moderator).can_see?(private_topic).should be_false Guardian.new(moderator).can_see?(private_topic).should be_false
Guardian.new(admin).can_see?(private_topic).should be_true Guardian.new(admin).can_see?(private_topic).should be_true
end end
it "restricts private deleted topics" do
user.save!
private_topic = Fabricate(:private_message_topic, user: user)
private_topic.trash!(admin)
Guardian.new(private_topic.user).can_see?(private_topic).should be_false
Guardian.new(build(:user)).can_see?(private_topic).should be_false
Guardian.new(moderator).can_see?(private_topic).should be_false
Guardian.new(admin).can_see?(private_topic).should be_true
end
end end
describe 'a Post' do describe 'a Post' do

View file

@ -218,13 +218,14 @@ describe TopicView do
let!(:p6) { Fabricate(:post, topic: topic, user: Fabricate(:user), deleted_at: Time.now)} let!(:p6) { Fabricate(:post, topic: topic, user: Fabricate(:user), deleted_at: Time.now)}
let!(:p4) { Fabricate(:post, topic: topic, user: coding_horror, deleted_at: Time.now)} let!(:p4) { Fabricate(:post, topic: topic, user: coding_horror, deleted_at: Time.now)}
let!(:p1) { Fabricate(:post, topic: topic, user: first_poster)} let!(:p1) { Fabricate(:post, topic: topic, user: first_poster)}
let!(:p7) { Fabricate(:post, topic: topic, user: coding_horror, deleted_at: Time.now)}
let!(:p3) { Fabricate(:post, topic: topic, user: first_poster)} let!(:p3) { Fabricate(:post, topic: topic, user: first_poster)}
before do before do
SiteSetting.posts_per_page = 3 SiteSetting.posts_per_page = 3
# Update them to the sort order we're checking for # Update them to the sort order we're checking for
[p1, p2, p3, p4, p5, p6].each_with_index do |p, idx| [p1, p2, p3, p4, p5, p6, p7].each_with_index do |p, idx|
p.sort_order = idx + 1 p.sort_order = idx + 1
p.save p.save
end end
@ -277,40 +278,64 @@ describe TopicView do
describe "filter_posts_near" do describe "filter_posts_near" do
def topic_view_near(post) def topic_view_near(post, show_deleted=false)
TopicView.new(topic.id, coding_horror, post_number: post.post_number) TopicView.new(topic.id, coding_horror, post_number: post.post_number, show_deleted: show_deleted)
end end
it "snaps to the lower boundary" do it "snaps to the lower boundary" do
near_view = topic_view_near(p1) near_view = topic_view_near(p1)
near_view.desired_post.should == p1 near_view.desired_post.should == p1
near_view.posts.should == [p1, p2, p3] near_view.posts.should == [p1, p2, p3]
near_view.contains_gaps?.should be_false
end end
it "snaps to the upper boundary" do it "snaps to the upper boundary" do
near_view = topic_view_near(p5) near_view = topic_view_near(p5)
near_view.desired_post.should == p5 near_view.desired_post.should == p5
near_view.posts.should == [p2, p3, p5] near_view.posts.should == [p2, p3, p5]
near_view.contains_gaps?.should be_false
end end
it "returns the posts in the middle" do it "returns the posts in the middle" do
near_view = topic_view_near(p2) near_view = topic_view_near(p2)
near_view.desired_post.should == p2 near_view.desired_post.should == p2
near_view.posts.should == [p1, p2, p3] near_view.posts.should == [p1, p2, p3]
near_view.contains_gaps?.should be_false
end end
it "returns deleted posts to an admin" do it "gaps deleted posts to an admin" do
coding_horror.admin = true coding_horror.admin = true
near_view = topic_view_near(p3) near_view = topic_view_near(p3)
near_view.desired_post.should == p3 near_view.desired_post.should == p3
near_view.posts.should == [p2, p3, p4] near_view.posts.should == [p2, p3, p5]
near_view.gaps.before.should == {p5.id => [p4.id]}
near_view.gaps.after.should == {p5.id => [p6.id, p7.id]}
end end
it "returns deleted posts by nuked users to an admin" do it "returns deleted posts to an admin with show_deleted" do
coding_horror.admin = true
near_view = topic_view_near(p3, true)
near_view.desired_post.should == p3
near_view.posts.should == [p2, p3, p4]
near_view.contains_gaps?.should be_false
end
it "gaps deleted posts by nuked users to an admin" do
coding_horror.admin = true coding_horror.admin = true
near_view = topic_view_near(p5) near_view = topic_view_near(p5)
near_view.desired_post.should == p5 near_view.desired_post.should == p5
# note: both p4 and p6 get skipped
near_view.posts.should == [p2, p3, p5]
near_view.gaps.before.should == {p5.id => [p4.id]}
near_view.gaps.after.should == {p5.id => [p6.id, p7.id]}
end
it "returns deleted posts by nuked users to an admin with show_deleted" do
coding_horror.admin = true
near_view = topic_view_near(p5, true)
near_view.desired_post.should == p5
near_view.posts.should == [p4, p5, p6] near_view.posts.should == [p4, p5, p6]
near_view.contains_gaps?.should be_false
end end
context "when 'posts per page' exceeds the number of posts" do context "when 'posts per page' exceeds the number of posts" do
@ -319,12 +344,22 @@ describe TopicView do
it 'returns all the posts' do it 'returns all the posts' do
near_view = topic_view_near(p5) near_view = topic_view_near(p5)
near_view.posts.should == [p1, p2, p3, p5] near_view.posts.should == [p1, p2, p3, p5]
near_view.contains_gaps?.should be_false
end
it 'gaps deleted posts to admins' do
coding_horror.admin = true
near_view = topic_view_near(p5)
near_view.posts.should == [p1, p2, p3, p5]
near_view.gaps.before.should == {p5.id => [p4.id]}
near_view.gaps.after.should == {p5.id => [p6.id, p7.id]}
end end
it 'returns deleted posts to admins' do it 'returns deleted posts to admins' do
coding_horror.admin = true coding_horror.admin = true
near_view = topic_view_near(p5) near_view = topic_view_near(p5, true)
near_view.posts.should == [p1, p2, p3, p4, p5, p6] near_view.posts.should == [p1, p2, p3, p4, p5, p6, p7]
near_view.contains_gaps?.should be_false
end end
end end
end end