mirror of
https://github.com/discourse/discourse.git
synced 2025-09-06 10:50:21 +08:00
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:
parent
85a59c632d
commit
410994b7f5
20 changed files with 244 additions and 7 deletions
|
@ -0,0 +1,3 @@
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'tr',
|
||||||
|
});
|
|
@ -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));
|
||||||
|
}
|
||||||
|
});
|
|
@ -60,6 +60,11 @@ export default Ember.Service.extend({
|
||||||
this._showControlModal('suspend', user, opts);
|
this._showControlModal('suspend', user, opts);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showModerationHistory(target) {
|
||||||
|
let controller = showModal('admin-moderation-history', { admin: true });
|
||||||
|
controller.loadHistory(target);
|
||||||
|
},
|
||||||
|
|
||||||
_deleteSpammer(adminUser) {
|
_deleteSpammer(adminUser) {
|
||||||
|
|
||||||
// Try loading the email if the site supports it
|
// Try loading the email if the site supports it
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -7,7 +7,8 @@ const ADMIN_MODELS = [
|
||||||
'embeddable-host',
|
'embeddable-host',
|
||||||
'web-hook',
|
'web-hook',
|
||||||
'web-hook-event',
|
'web-hook-event',
|
||||||
'flagged-topic'
|
'flagged-topic',
|
||||||
|
'moderation-history'
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Result(payload, responseJson) {
|
export function Result(payload, responseJson) {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import MountWidget from 'discourse/components/mount-widget';
|
||||||
import { cloak, uncloak } from 'discourse/widgets/post-stream';
|
import { cloak, uncloak } from 'discourse/widgets/post-stream';
|
||||||
import { isWorkaroundActive } from 'discourse/lib/safari-hacks';
|
import { isWorkaroundActive } from 'discourse/lib/safari-hacks';
|
||||||
import offsetCalculator from 'discourse/lib/offset-calculator';
|
import offsetCalculator from 'discourse/lib/offset-calculator';
|
||||||
|
import optionalService from 'discourse/lib/optional-service';
|
||||||
|
|
||||||
function findTopView($posts, viewportTop, postsWrapperTop, min, max) {
|
function findTopView($posts, viewportTop, postsWrapperTop, min, max) {
|
||||||
if (max < min) { return min; }
|
if (max < min) { return min; }
|
||||||
|
@ -23,6 +24,7 @@ function findTopView($posts, viewportTop, postsWrapperTop, min, max) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MountWidget.extend({
|
export default MountWidget.extend({
|
||||||
|
adminTools: optionalService(),
|
||||||
widget: 'post-stream',
|
widget: 'post-stream',
|
||||||
_topVisible: null,
|
_topVisible: null,
|
||||||
_bottomVisible: null,
|
_bottomVisible: null,
|
||||||
|
@ -271,6 +273,9 @@ export default MountWidget.extend({
|
||||||
this.$().off('mouseleave.post-stream');
|
this.$().off('mouseleave.post-stream');
|
||||||
this.appEvents.off('post-stream:refresh');
|
this.appEvents.off('post-stream:refresh');
|
||||||
this.appEvents.off('post-stream:posted');
|
this.appEvents.off('post-stream:posted');
|
||||||
}
|
},
|
||||||
|
|
||||||
|
showModerationHistory(post) {
|
||||||
|
this.get('adminTools').showModerationHistory({ filter: 'post', post_id: post.id });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,20 @@
|
||||||
import MountWidget from 'discourse/components/mount-widget';
|
import MountWidget from 'discourse/components/mount-widget';
|
||||||
|
import optionalService from 'discourse/lib/optional-service';
|
||||||
|
|
||||||
export default MountWidget.extend({
|
export default MountWidget.extend({
|
||||||
classNames: 'topic-admin-menu-button-container',
|
classNames: 'topic-admin-menu-button-container',
|
||||||
tagName: 'span',
|
tagName: 'span',
|
||||||
widget: "topic-admin-menu-button",
|
widget: "topic-admin-menu-button",
|
||||||
|
adminTools: optionalService(),
|
||||||
|
|
||||||
buildArgs() {
|
buildArgs() {
|
||||||
return this.getProperties('topic', 'fixed', 'openUpwards', 'rightSide');
|
return this.getProperties('topic', 'fixed', 'openUpwards', 'rightSide');
|
||||||
|
},
|
||||||
|
|
||||||
|
showModerationHistory() {
|
||||||
|
this.get('adminTools').showModerationHistory({
|
||||||
|
filter: 'topic',
|
||||||
|
topic_id: this.get('topic.id')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||||
onShow() {
|
onShow() {
|
||||||
this.setProperties({
|
this.setProperties({
|
||||||
selected: null,
|
selected: null,
|
||||||
spammerDetails: null
|
spammerDetails: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let adminTools = this.get('adminTools');
|
let adminTools = this.get('adminTools');
|
||||||
|
|
|
@ -40,7 +40,8 @@ flushMap();
|
||||||
|
|
||||||
export default Ember.Object.extend({
|
export default Ember.Object.extend({
|
||||||
_plurals: {'post-reply': 'post-replies',
|
_plurals: {'post-reply': 'post-replies',
|
||||||
'post-reply-history': 'post_reply_histories'},
|
'post-reply-history': 'post_reply_histories',
|
||||||
|
'moderation-history': 'moderation_history'},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super();
|
this._super();
|
||||||
|
|
|
@ -48,5 +48,4 @@
|
||||||
icon="exclamation-triangle"
|
icon="exclamation-triangle"
|
||||||
label="flagging.delete_spammer"}}
|
label="flagging.delete_spammer"}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,6 +16,14 @@ export function buildManageButtons(attrs, currentUser) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let contents = [];
|
let contents = [];
|
||||||
|
if (attrs.canManage) {
|
||||||
|
contents.push({
|
||||||
|
icon: 'list',
|
||||||
|
label: 'admin.flags.moderation_history',
|
||||||
|
action: 'showModerationHistory',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!attrs.isWhisper && currentUser.staff) {
|
if (!attrs.isWhisper && currentUser.staff) {
|
||||||
const buttonAtts = {
|
const buttonAtts = {
|
||||||
action: 'togglePostType',
|
action: 'togglePostType',
|
||||||
|
|
|
@ -10,7 +10,7 @@ createWidget('admin-menu-button', {
|
||||||
className,
|
className,
|
||||||
action: attrs.action,
|
action: attrs.action,
|
||||||
icon: attrs.icon,
|
icon: attrs.icon,
|
||||||
label: `topic.${attrs.label}`,
|
label: attrs.fullLabel || `topic.${attrs.label}`,
|
||||||
secondaryAction: 'hideAdminMenu'
|
secondaryAction: 'hideAdminMenu'
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -114,6 +114,7 @@ export default createWidget('topic-admin-menu', {
|
||||||
|
|
||||||
const topic = attrs.topic;
|
const topic = attrs.topic;
|
||||||
const details = topic.get('details');
|
const details = topic.get('details');
|
||||||
|
|
||||||
if (details.get('can_delete')) {
|
if (details.get('can_delete')) {
|
||||||
buttons.push({ className: 'topic-admin-delete',
|
buttons.push({ className: 'topic-admin-delete',
|
||||||
buttonClass: 'btn-danger',
|
buttonClass: 'btn-danger',
|
||||||
|
@ -184,6 +185,12 @@ export default createWidget('topic-admin-menu', {
|
||||||
label: isPrivateMessage ? 'actions.make_public' : 'actions.make_private' });
|
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);
|
const extraButtons = applyDecorators(this, 'adminMenuButtons', this.attrs, this.state);
|
||||||
|
|
||||||
return [ h('h3', I18n.t('admin_title')),
|
return [ h('h3', I18n.t('admin_title')),
|
||||||
|
|
|
@ -310,7 +310,7 @@ export default class Widget {
|
||||||
view.sendAction(method, param);
|
view.sendAction(method, param);
|
||||||
promise = Ember.RSVP.resolve();
|
promise = Ember.RSVP.resolve();
|
||||||
} else {
|
} else {
|
||||||
const target = view.get('targetObject');
|
const target = view.get('targetObject') || view;
|
||||||
promise = method.call(target, param);
|
promise = method.call(target, param);
|
||||||
if (!promise || !promise.then) {
|
if (!promise || !promise.then) {
|
||||||
promise = Ember.RSVP.resolve(promise);
|
promise = Ember.RSVP.resolve(promise);
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
@import "common/admin/customize";
|
@import "common/admin/customize";
|
||||||
@import "common/admin/flagging";
|
@import "common/admin/flagging";
|
||||||
|
@import "common/admin/moderation_history";
|
||||||
@import "common/admin/suspend";
|
@import "common/admin/suspend";
|
||||||
|
|
||||||
$mobile-breakpoint: 700px;
|
$mobile-breakpoint: 700px;
|
||||||
|
|
32
app/assets/stylesheets/common/admin/moderation_history.scss
Normal file
32
app/assets/stylesheets/common/admin/moderation_history.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
app/controllers/admin/moderation_history_controller.rb
Normal file
40
app/controllers/admin/moderation_history_controller.rb
Normal 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
|
|
@ -2646,6 +2646,7 @@ en:
|
||||||
active_posts: "Flagged Posts"
|
active_posts: "Flagged Posts"
|
||||||
old_posts: "Old Flagged Posts"
|
old_posts: "Old Flagged Posts"
|
||||||
topics: "Flagged Topics"
|
topics: "Flagged Topics"
|
||||||
|
moderation_history: "Moderation History"
|
||||||
|
|
||||||
agree: "Agree"
|
agree: "Agree"
|
||||||
agree_title: "Confirm this flag as valid and correct"
|
agree_title: "Confirm this flag as valid and correct"
|
||||||
|
@ -3112,6 +3113,16 @@ en:
|
||||||
reply_key_placeholder: "reply key"
|
reply_key_placeholder: "reply key"
|
||||||
skipped_reason_placeholder: "reason"
|
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:
|
logs:
|
||||||
title: "Logs"
|
title: "Logs"
|
||||||
action: "Action"
|
action: "Action"
|
||||||
|
|
|
@ -93,6 +93,8 @@ Discourse::Application.routes.draw do
|
||||||
get "groups/:type" => "groups#show", constraints: AdminConstraint.new
|
get "groups/:type" => "groups#show", constraints: AdminConstraint.new
|
||||||
get "groups/:type/:id" => "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
|
resources :users, id: USERNAME_ROUTE_FORMAT, except: [:show] do
|
||||||
collection do
|
collection do
|
||||||
get "list" => "users#index"
|
get "list" => "users#index"
|
||||||
|
|
55
spec/requests/admin/moderation_history_controller_spec.rb
Normal file
55
spec/requests/admin/moderation_history_controller_spec.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue