From 410994b7f5e07ed447c9df65f956178d906d440b Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Mon, 4 Dec 2017 12:14:43 -0500 Subject: [PATCH] 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. --- .../components/moderation-history-item.js.es6 | 3 + .../modals/admin-moderation-history.js.es6 | 18 ++++++ .../admin/services/admin-tools.js.es6 | 5 ++ .../components/moderation-history-item.hbs | 17 ++++++ .../modal/admin-moderation-history.hbs | 23 ++++++++ .../discourse/adapters/rest.js.es6 | 3 +- .../components/scrolling-post-stream.js.es6 | 7 ++- .../components/topic-admin-menu-button.js.es6 | 9 +++ .../discourse/controllers/flag.js.es6 | 2 +- .../javascripts/discourse/models/store.js.es6 | 3 +- .../discourse/templates/modal/flag.hbs | 1 - .../discourse/widgets/post-admin-menu.js.es6 | 8 +++ .../discourse/widgets/topic-admin-menu.js.es6 | 9 ++- .../discourse/widgets/widget.js.es6 | 2 +- .../stylesheets/common/admin/admin_base.scss | 1 + .../common/admin/moderation_history.scss | 32 +++++++++++ .../admin/moderation_history_controller.rb | 40 ++++++++++++++ config/locales/client.en.yml | 11 ++++ config/routes.rb | 2 + .../moderation_history_controller_spec.rb | 55 +++++++++++++++++++ 20 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 app/assets/javascripts/admin/components/moderation-history-item.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 create mode 100644 app/assets/javascripts/admin/templates/components/moderation-history-item.hbs create mode 100644 app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs create mode 100644 app/assets/stylesheets/common/admin/moderation_history.scss create mode 100644 app/controllers/admin/moderation_history_controller.rb create mode 100644 spec/requests/admin/moderation_history_controller_spec.rb 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}} + + + + + + + {{#each history as |item|}} + {{moderation-history-item item=item}} + {{/each}} +
{{i18n "admin.logs.created_at"}}{{i18n "admin.logs.action"}}{{i18n "admin.moderation_history.performed_by"}}
+ {{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