FEATURE: Show a button to Staff for "Moderation History" on posts/topics

When clicked, it pops up a modal showing a history of moderation actions
taken on the post or topic.
This commit is contained in:
Robin Ward 2017-12-04 12:14:43 -05:00
parent 85a59c632d
commit 410994b7f5
20 changed files with 244 additions and 7 deletions

View File

@ -0,0 +1,3 @@
export default Ember.Component.extend({
tagName: 'tr',
});

View File

@ -0,0 +1,18 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
export default Ember.Controller.extend(ModalFunctionality, {
loading: null,
historyTarget: null,
history: null,
onShow() {
this.set('loading', true);
this.set('history', null);
},
loadHistory(target) {
this.store.findAll('moderation-history', target).then(result => {
this.set('history', result);
}).finally(() => this.set('loading', false));
}
});

View File

@ -60,6 +60,11 @@ export default Ember.Service.extend({
this._showControlModal('suspend', user, opts);
},
showModerationHistory(target) {
let controller = showModal('admin-moderation-history', { admin: true });
controller.loadHistory(target);
},
_deleteSpammer(adminUser) {
// Try loading the email if the site supports it

View File

@ -0,0 +1,17 @@
<td class='date'>
{{format-date item.created_at}}
</td>
<td class='history-item-action'>
<div class='action-name'>
{{i18n (concat "admin.moderation_history.actions." item.action_name)}}
</div>
<div class='action-details'>{{item.details}}</div>
</td>
<td class='history-item-actor'>
{{#if item.acting_user}}
{{#user-link user=item.acting_user}}
{{avatar item.acting_user imageSize="small"}}
<span>{{format-username item.acting_user.username}}</span>
{{/user-link}}
{{/if}}
</td>

View File

@ -0,0 +1,23 @@
{{#d-modal-body title="admin.flags.moderation_history"}}
{{#conditional-loading-spinner condition=loading}}
{{#if history}}
<table class='moderation-history'>
<tr>
<th>{{i18n "admin.logs.created_at"}}</th>
<th>{{i18n "admin.logs.action"}}</th>
<th>{{i18n "admin.moderation_history.performed_by"}}</th>
</tr>
{{#each history as |item|}}
{{moderation-history-item item=item}}
{{/each}}
</table>
{{else}}
<div class='no-results'>
{{i18n "admin.moderation_history.no_results"}}
</div>
{{/if}}
{{/conditional-loading-spinner}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action=(action "closeModal") label="close"}}
</div>

View File

@ -7,7 +7,8 @@ const ADMIN_MODELS = [
'embeddable-host',
'web-hook',
'web-hook-event',
'flagged-topic'
'flagged-topic',
'moderation-history'
];
export function Result(payload, responseJson) {

View File

@ -3,6 +3,7 @@ import MountWidget from 'discourse/components/mount-widget';
import { cloak, uncloak } from 'discourse/widgets/post-stream';
import { isWorkaroundActive } from 'discourse/lib/safari-hacks';
import offsetCalculator from 'discourse/lib/offset-calculator';
import optionalService from 'discourse/lib/optional-service';
function findTopView($posts, viewportTop, postsWrapperTop, min, max) {
if (max < min) { return min; }
@ -23,6 +24,7 @@ function findTopView($posts, viewportTop, postsWrapperTop, min, max) {
}
export default MountWidget.extend({
adminTools: optionalService(),
widget: 'post-stream',
_topVisible: null,
_bottomVisible: null,
@ -271,6 +273,9 @@ export default MountWidget.extend({
this.$().off('mouseleave.post-stream');
this.appEvents.off('post-stream:refresh');
this.appEvents.off('post-stream:posted');
}
},
showModerationHistory(post) {
this.get('adminTools').showModerationHistory({ filter: 'post', post_id: post.id });
}
});

View File

@ -1,11 +1,20 @@
import MountWidget from 'discourse/components/mount-widget';
import optionalService from 'discourse/lib/optional-service';
export default MountWidget.extend({
classNames: 'topic-admin-menu-button-container',
tagName: 'span',
widget: "topic-admin-menu-button",
adminTools: optionalService(),
buildArgs() {
return this.getProperties('topic', 'fixed', 'openUpwards', 'rightSide');
},
showModerationHistory() {
this.get('adminTools').showModerationHistory({
filter: 'topic',
topic_id: this.get('topic.id')
});
}
});

View File

@ -17,7 +17,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
onShow() {
this.setProperties({
selected: null,
spammerDetails: null
spammerDetails: null,
});
let adminTools = this.get('adminTools');

View File

@ -40,7 +40,8 @@ flushMap();
export default Ember.Object.extend({
_plurals: {'post-reply': 'post-replies',
'post-reply-history': 'post_reply_histories'},
'post-reply-history': 'post_reply_histories',
'moderation-history': 'moderation_history'},
init() {
this._super();

View File

@ -48,5 +48,4 @@
icon="exclamation-triangle"
label="flagging.delete_spammer"}}
{{/if}}
</div>

View File

@ -16,6 +16,14 @@ export function buildManageButtons(attrs, currentUser) {
}
let contents = [];
if (attrs.canManage) {
contents.push({
icon: 'list',
label: 'admin.flags.moderation_history',
action: 'showModerationHistory',
});
}
if (!attrs.isWhisper && currentUser.staff) {
const buttonAtts = {
action: 'togglePostType',

View File

@ -10,7 +10,7 @@ createWidget('admin-menu-button', {
className,
action: attrs.action,
icon: attrs.icon,
label: `topic.${attrs.label}`,
label: attrs.fullLabel || `topic.${attrs.label}`,
secondaryAction: 'hideAdminMenu'
}));
}
@ -114,6 +114,7 @@ export default createWidget('topic-admin-menu', {
const topic = attrs.topic;
const details = topic.get('details');
if (details.get('can_delete')) {
buttons.push({ className: 'topic-admin-delete',
buttonClass: 'btn-danger',
@ -184,6 +185,12 @@ export default createWidget('topic-admin-menu', {
label: isPrivateMessage ? 'actions.make_public' : 'actions.make_private' });
}
buttons.push({
action: 'showModerationHistory',
icon: 'list',
fullLabel: 'admin.flags.moderation_history'
});
const extraButtons = applyDecorators(this, 'adminMenuButtons', this.attrs, this.state);
return [ h('h3', I18n.t('admin_title')),

View File

@ -310,7 +310,7 @@ export default class Widget {
view.sendAction(method, param);
promise = Ember.RSVP.resolve();
} else {
const target = view.get('targetObject');
const target = view.get('targetObject') || view;
promise = method.call(target, param);
if (!promise || !promise.then) {
promise = Ember.RSVP.resolve(promise);

View File

@ -5,6 +5,7 @@
@import "common/admin/customize";
@import "common/admin/flagging";
@import "common/admin/moderation_history";
@import "common/admin/suspend";
$mobile-breakpoint: 700px;

View File

@ -0,0 +1,32 @@
.moderation-history {
width: 100%;
th {
text-align: left;
}
td.date {
padding-right: 1em;
}
td, th {
padding-bottom: 0.5em;
vertical-align: top;
}
.history-item-action {
.action-details {
margin: 1em 0;
color: $primary-medium;
white-space: pre-wrap;
line-height: 1em;
width: 300px;
}
}
.history-item-actor {
a {
display: flex;
align-items: center;
span {
margin-left: 0.5em;
}
}
}
}

View File

@ -0,0 +1,40 @@
class Admin::ModerationHistoryController < Admin::AdminController
def index
history_filter = params[:filter]
raise Discourse::NotFound unless ['post', 'topic'].include?(history_filter)
query = UserHistory.where(
action: UserHistory.actions.only(
:delete_user,
:suspend_user,
:silence_user,
:delete_post,
:delete_topic
).values
)
case history_filter
when 'post'
raise Discourse::NotFound if params[:post_id].blank?
query = query.where(post_id: params[:post_id])
when 'topic'
raise Discourse::NotFound if params[:topic_id].blank?
query = query.where(
"topic_id = ? OR post_id IN (?)",
params[:topic_id],
Post.with_deleted.where(topic_id: params[:topic_id]).pluck(:id)
)
end
query = query.includes(:acting_user)
query = query.order(:created_at)
render_serialized(
query,
UserHistorySerializer,
root: 'moderation_history',
rest_serializer: true
)
end
end

View File

@ -2646,6 +2646,7 @@ en:
active_posts: "Flagged Posts"
old_posts: "Old Flagged Posts"
topics: "Flagged Topics"
moderation_history: "Moderation History"
agree: "Agree"
agree_title: "Confirm this flag as valid and correct"
@ -3112,6 +3113,16 @@ en:
reply_key_placeholder: "reply key"
skipped_reason_placeholder: "reason"
moderation_history:
performed_by: "Performed By"
no_results: "There is no moderation history available."
actions:
delete_user: "User Deleted"
suspend_user: "User Suspended"
silence_user: "User Silenced"
delete_post: "Post Deleted"
delete_topic: "Topic Deleted"
logs:
title: "Logs"
action: "Action"

View File

@ -93,6 +93,8 @@ Discourse::Application.routes.draw do
get "groups/:type" => "groups#show", constraints: AdminConstraint.new
get "groups/:type/:id" => "groups#show", constraints: AdminConstraint.new
get "moderation_history" => "moderation_history#index"
resources :users, id: USERNAME_ROUTE_FORMAT, except: [:show] do
collection do
get "list" => "users#index"

View File

@ -0,0 +1,55 @@
require 'rails_helper'
RSpec.describe Admin::BackupsController do
let(:admin) { Fabricate(:admin) }
before do
sign_in(admin)
end
describe "parameters" do
it "returns 404 without a valid filter" do
get "/admin/moderation_history.json"
expect(response).not_to be_success
end
it "returns 404 without a valid id" do
get "/admin/moderation_history.json?filter=topic"
expect(response).not_to be_success
end
end
describe "for a post" do
it "returns an empty array when the post doesn't exist" do
get "/admin/moderation_history.json?filter=post&post_id=99999999"
expect(response).to be_success
expect(::JSON.parse(response.body)['moderation_history']).to be_blank
end
it "returns a history when the post exists" do
p = Fabricate(:post)
p = Fabricate(:post, topic_id: p.topic_id)
PostDestroyer.new(Discourse.system_user, p).destroy
get "/admin/moderation_history.json?filter=post&post_id=#{p.id}"
expect(response).to be_success
expect(::JSON.parse(response.body)['moderation_history']).to be_present
end
end
describe "for a topic" do
it "returns empty history when the topic doesn't exist" do
get "/admin/moderation_history.json?filter=topic&topic_id=1234"
expect(response).to be_success
expect(::JSON.parse(response.body)['moderation_history']).to be_blank
end
it "returns a history when the topic exists" do
p = Fabricate(:post)
PostDestroyer.new(Discourse.system_user, p).destroy
get "/admin/moderation_history.json?filter=topic&topic_id=#{p.topic_id}"
expect(response).to be_success
expect(::JSON.parse(response.body)['moderation_history']).to be_present
end
end
end