diff --git a/app/assets/javascripts/admin/components/moderation-history-item.js.es6 b/app/assets/javascripts/admin/components/moderation-history-item.js.es6
new file mode 100644
index 00000000000..b8674a8aafb
--- /dev/null
+++ b/app/assets/javascripts/admin/components/moderation-history-item.js.es6
@@ -0,0 +1,3 @@
+export default Ember.Component.extend({
+ tagName: 'tr',
+});
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6
new file mode 100644
index 00000000000..ee017eae88b
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6
@@ -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));
+ }
+});
diff --git a/app/assets/javascripts/admin/services/admin-tools.js.es6 b/app/assets/javascripts/admin/services/admin-tools.js.es6
index bc524706f94..439b85cd421 100644
--- a/app/assets/javascripts/admin/services/admin-tools.js.es6
+++ b/app/assets/javascripts/admin/services/admin-tools.js.es6
@@ -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
diff --git a/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs b/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs
new file mode 100644
index 00000000000..bf2ddc0f4cf
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs
@@ -0,0 +1,17 @@
+
+ {{format-date item.created_at}}
+ |
+
+
+ {{i18n (concat "admin.moderation_history.actions." item.action_name)}}
+
+ {{item.details}}
+ |
+
+ {{#if item.acting_user}}
+ {{#user-link user=item.acting_user}}
+ {{avatar item.acting_user imageSize="small"}}
+ {{format-username item.acting_user.username}}
+ {{/user-link}}
+ {{/if}}
+ |
diff --git a/app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs b/app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs
new file mode 100644
index 00000000000..4abc13e7f3c
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs
@@ -0,0 +1,23 @@
+{{#d-modal-body title="admin.flags.moderation_history"}}
+ {{#conditional-loading-spinner condition=loading}}
+ {{#if history}}
+
+
+ {{i18n "admin.logs.created_at"}} |
+ {{i18n "admin.logs.action"}} |
+ {{i18n "admin.moderation_history.performed_by"}} |
+
+ {{#each history as |item|}}
+ {{moderation-history-item item=item}}
+ {{/each}}
+
+ {{else}}
+
+ {{i18n "admin.moderation_history.no_results"}}
+
+ {{/if}}
+ {{/conditional-loading-spinner}}
+{{/d-modal-body}}
+
diff --git a/app/assets/javascripts/discourse/adapters/rest.js.es6 b/app/assets/javascripts/discourse/adapters/rest.js.es6
index af0da8debe2..c826bbefc9c 100644
--- a/app/assets/javascripts/discourse/adapters/rest.js.es6
+++ b/app/assets/javascripts/discourse/adapters/rest.js.es6
@@ -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) {
diff --git a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6
index 2192c4e3d83..b36b39a7019 100644
--- a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6
+++ b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6
@@ -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 });
+ }
});
diff --git a/app/assets/javascripts/discourse/components/topic-admin-menu-button.js.es6 b/app/assets/javascripts/discourse/components/topic-admin-menu-button.js.es6
index fb6d7bf59a2..c8bb7fef4f3 100644
--- a/app/assets/javascripts/discourse/components/topic-admin-menu-button.js.es6
+++ b/app/assets/javascripts/discourse/components/topic-admin-menu-button.js.es6
@@ -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')
+ });
}
});
diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6
index 00627a2ef80..e8d309c8c98 100644
--- a/app/assets/javascripts/discourse/controllers/flag.js.es6
+++ b/app/assets/javascripts/discourse/controllers/flag.js.es6
@@ -17,7 +17,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
onShow() {
this.setProperties({
selected: null,
- spammerDetails: null
+ spammerDetails: null,
});
let adminTools = this.get('adminTools');
diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6
index 515c135ba8d..d198883b58c 100644
--- a/app/assets/javascripts/discourse/models/store.js.es6
+++ b/app/assets/javascripts/discourse/models/store.js.es6
@@ -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();
diff --git a/app/assets/javascripts/discourse/templates/modal/flag.hbs b/app/assets/javascripts/discourse/templates/modal/flag.hbs
index d24d2e0e469..e863fd79483 100644
--- a/app/assets/javascripts/discourse/templates/modal/flag.hbs
+++ b/app/assets/javascripts/discourse/templates/modal/flag.hbs
@@ -48,5 +48,4 @@
icon="exclamation-triangle"
label="flagging.delete_spammer"}}
{{/if}}
-
diff --git a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6
index 0a29b82bb2b..85eb942f6ee 100644
--- a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6
@@ -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',
diff --git a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6
index 003de97cead..b8299f26d71 100644
--- a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6
@@ -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')),
diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6
index f5822f8fa9b..e81123c8ade 100644
--- a/app/assets/javascripts/discourse/widgets/widget.js.es6
+++ b/app/assets/javascripts/discourse/widgets/widget.js.es6
@@ -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);
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 4c184f58a61..f3ac6afb5c1 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -5,6 +5,7 @@
@import "common/admin/customize";
@import "common/admin/flagging";
+@import "common/admin/moderation_history";
@import "common/admin/suspend";
$mobile-breakpoint: 700px;
diff --git a/app/assets/stylesheets/common/admin/moderation_history.scss b/app/assets/stylesheets/common/admin/moderation_history.scss
new file mode 100644
index 00000000000..aac258b92eb
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/moderation_history.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/app/controllers/admin/moderation_history_controller.rb b/app/controllers/admin/moderation_history_controller.rb
new file mode 100644
index 00000000000..5bad39a8427
--- /dev/null
+++ b/app/controllers/admin/moderation_history_controller.rb
@@ -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
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 4662366d966..d0b24641416 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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"
diff --git a/config/routes.rb b/config/routes.rb
index c66d91e15e4..1e44790ce1e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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"
diff --git a/spec/requests/admin/moderation_history_controller_spec.rb b/spec/requests/admin/moderation_history_controller_spec.rb
new file mode 100644
index 00000000000..f16d340faef
--- /dev/null
+++ b/spec/requests/admin/moderation_history_controller_spec.rb
@@ -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