FEATURE: View flags grouped by topic

This commit is contained in:
Robin Ward 2017-09-06 10:21:07 -04:00
parent bbbd974487
commit 40eba8cd93
27 changed files with 347 additions and 79 deletions

View File

@ -0,0 +1,10 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ['flag-counts'],
@computed('details.flag_type_id')
title(id) {
return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 });
}
});

View File

@ -0,0 +1,10 @@
import RestModel from 'discourse/models/rest';
import computed from 'ember-addons/ember-computed-decorators';
export default RestModel.extend({
@computed('id')
name(id) {
return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1});
}
});

View File

@ -3,37 +3,35 @@ import AdminUser from 'admin/models/admin-user';
import Topic from 'discourse/models/topic';
import Post from 'discourse/models/post';
import { iconHTML } from 'discourse-common/lib/icon-library';
import computed from 'ember-addons/ember-computed-decorators';
const FlaggedPost = Post.extend({
summary: function () {
@computed
summary() {
return _(this.post_actions)
.groupBy(function (a) { return a.post_action_type_id; })
.map(function (v,k) { return I18n.t('admin.flags.summary.action_type_' + k, { count: v.length }); })
.join(',');
}.property(),
},
flaggers: function () {
var self = this;
var flaggers = [];
_.each(this.post_actions, function (postAction) {
flaggers.push({
user: self.userLookup[postAction.user_id],
topic: self.topicLookup[postAction.topic_id],
@computed
flaggers() {
return this.post_actions.map(postAction => {
return {
user: this.userLookup[postAction.user_id],
topic: this.topicLookup[postAction.topic_id],
flagType: I18n.t('admin.flags.summary.action_type_' + postAction.post_action_type_id, { count: 1 }),
flaggedAt: postAction.created_at,
disposedBy: postAction.disposed_by_id ? self.userLookup[postAction.disposed_by_id] : null,
disposedBy: postAction.disposed_by_id ? this.userLookup[postAction.disposed_by_id] : null,
disposedAt: postAction.disposed_at,
dispositionIcon: self.dispositionIcon(postAction.disposition),
dispositionIcon: this.dispositionIcon(postAction.disposition),
tookAction: postAction.staff_took_action
});
}
});
},
return flaggers;
}.property(),
dispositionIcon: function (disposition) {
dispositionIcon(disposition) {
if (!disposition) { return null; }
let icon;
let title = 'admin.flags.dispositions.' + disposition;
@ -45,68 +43,74 @@ const FlaggedPost = Post.extend({
return iconHTML(icon, { title });
},
wasEdited: function () {
@computed('last_revised_at', 'post_actions.@each.created_at')
wasEdited(lastRevisedAt) {
if (Ember.isEmpty(this.get("last_revised_at"))) { return false; }
var lastRevisedAt = Date.parse(this.get("last_revised_at"));
lastRevisedAt = Date.parse(lastRevisedAt);
return _.some(this.get("post_actions"), function (postAction) {
return Date.parse(postAction.created_at) < lastRevisedAt;
});
}.property("last_revised_at", "post_actions.@each.created_at"),
},
conversations: function () {
var self = this;
var conversations = [];
@computed
conversations() {
let conversations = [];
_.each(this.post_actions, function (postAction) {
this.post_actions.forEach(postAction => {
if (postAction.conversation) {
var conversation = {
let conversation = {
permalink: postAction.permalink,
hasMore: postAction.conversation.has_more,
response: {
excerpt: postAction.conversation.response.excerpt,
user: self.userLookup[postAction.conversation.response.user_id]
user: this.userLookup[postAction.conversation.response.user_id]
}
};
if (postAction.conversation.reply) {
conversation["reply"] = {
conversation.reply = {
excerpt: postAction.conversation.reply.excerpt,
user: self.userLookup[postAction.conversation.reply.user_id]
user: this.userLookup[postAction.conversation.reply.user_id]
};
}
conversations.push(conversation);
}
});
return conversations;
}.property(),
},
user: function() {
@computed
user() {
return this.userLookup[this.user_id];
}.property(),
},
topic: function () {
@computed
topic() {
return this.topicLookup[this.topic_id];
}.property(),
},
flaggedForSpam: function() {
@computed('post_actions.@each.name_key')
flaggedForSpam() {
return !_.every(this.get('post_actions'), function(action) { return action.name_key !== 'spam'; });
}.property('post_actions.@each.name_key'),
},
topicFlagged: function() {
@computed('post_actions.@each.targets_topic')
topicFlagged() {
return _.any(this.get('post_actions'), function(action) { return action.targets_topic; });
}.property('post_actions.@each.targets_topic'),
},
postAuthorFlagged: function() {
@computed('post_actions.@each.targets_topic')
postAuthorFlagged() {
return _.any(this.get('post_actions'), function(action) { return !action.targets_topic; });
}.property('post_actions.@each.targets_topic'),
},
canDeleteAsSpammer: function() {
return Discourse.User.currentProp('staff') && this.get('flaggedForSpam') && this.get('user.can_delete_all_posts') && this.get('user.can_be_deleted');
}.property('flaggedForSpam'),
@computed('flaggedForSpan')
canDeleteAsSpammer(flaggedForSpam) {
return Discourse.User.currentProp('staff') && flaggedForSpam && this.get('user.can_delete_all_posts') && this.get('user.can_be_deleted');
},
deletePost: function() {
deletePost() {
if (this.get('post_number') === 1) {
return ajax('/t/' + this.topic_id, { type: 'DELETE', cache: false });
} else {
@ -114,61 +118,58 @@ const FlaggedPost = Post.extend({
}
},
disagreeFlags: function () {
disagreeFlags() {
return ajax('/admin/flags/disagree/' + this.id, { type: 'POST', cache: false });
},
deferFlags: function (deletePost) {
deferFlags(deletePost) {
return ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } });
},
agreeFlags: function (actionOnPost) {
agreeFlags(actionOnPost) {
return ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false, data: { action_on_post: actionOnPost } });
},
postHidden: Em.computed.alias('hidden'),
postHidden: Ember.computed.alias('hidden'),
extraClasses: function() {
var classes = [];
@computed
extraClasses() {
let classes = [];
if (this.get('hidden')) { classes.push('hidden-post'); }
if (this.get('deleted')) { classes.push('deleted'); }
return classes.join(' ');
}.property(),
deleted: Em.computed.or('deleted_at', 'topic_deleted_at')
},
deleted: Ember.computed.or('deleted_at', 'topic_deleted_at')
});
FlaggedPost.reopenClass({
findAll: function (filter, offset) {
findAll(args) {
let { offset, filter } = args;
offset = offset || 0;
var result = Em.A();
let result = [];
result.set('loading', true);
return ajax('/admin/flags/' + filter + '.json?offset=' + offset).then(function (data) {
// users
var userLookup = {};
_.each(data.users, function (user) {
userLookup[user.id] = AdminUser.create(user);
});
let userLookup = {};
data.users.forEach(user => userLookup[user.id] = AdminUser.create(user));
// topics
var topicLookup = {};
_.each(data.topics, function (topic) {
topicLookup[topic.id] = Topic.create(topic);
});
let topicLookup = {};
data.topics.forEach(topic => topicLookup[topic.id] = Topic.create(topic));
// posts
_.each(data.posts, function (post) {
var f = FlaggedPost.create(post);
data.posts.forEach(post => {
let f = FlaggedPost.create(post);
f.userLookup = userLookup;
f.topicLookup = topicLookup;
result.pushObject(f);
});
result.set('loading', false);
return result;
});
}

View File

@ -0,0 +1,9 @@
export default Discourse.Route.extend({
model() {
return this.store.findAll('flagged-topic');
},
setupController(controller, model) {
controller.set('flaggedTopics', model);
}
});

View File

@ -0,0 +1,18 @@
import { loadTopicView } from 'discourse/models/topic';
import FlaggedPost from 'admin/models/flagged-post';
export default Ember.Route.extend({
model(params) {
let topicRecord = this.store.createRecord('topic', { id: params.id });
let topic = loadTopicView(topicRecord).then(() => topicRecord);
return Ember.RSVP.hash({
topic,
flaggedPosts: FlaggedPost.findAll({ filter: 'active' })
});
},
setupController(controller, hash) {
controller.setProperties(hash);
}
});

View File

@ -56,6 +56,9 @@ export default function() {
this.route('adminFlags', { path: '/flags', resetNamespace: true }, function() {
this.route('postsActive', { path: 'active' });
this.route('postsOld', { path: 'old' });
this.route('topics', { path: 'topics' }, function() {
this.route('show', { path: ":id" });
});
});
this.route('adminLogs', { path: '/logs', resetNamespace: true }, function() {

View File

@ -0,0 +1,2 @@
<span class='type-name'>{{title}}</span>
<span class='type-count'>x{{details.count}}</span>

View File

@ -0,0 +1,5 @@
{{#each users as |u|}}
{{#link-to 'adminUser' u}}
{{avatar u imageSize="small"}}
{{/link-to}}
{{/each}}

View File

@ -0,0 +1,42 @@
{{plugin-outlet name="flagged-topics-before" noTags=true args=(hash flaggedTopics=flaggedTopics)}}
<table class='flagged-topics'>
<thead>
{{plugin-outlet name="flagged-topic-header-row" noTags=true}}
<th>{{i18n "admin.flags.flagged_topics.topic"}} </th>
<th>{{i18n "admin.flags.flagged_topics.type"}}</th>
<th>{{I18n "admin.flags.flagged_topics.users"}}</th>
<th>{{i18n "admin.flags.flagged_topics.last_flagged"}}</th>
<th></th>
</thead>
{{#each flaggedTopics as |ft|}}
<tr class='flagged-topic'>
{{plugin-outlet name="flagged-topic-row" noTags=true args=(hash topic=ft.topic)}}
<td class="topic-title">
<a href={{ft.topic.relative_url}} target="_blank">{{replace-emoji ft.topic.fancy_title}}</a>
</td>
<td>
{{#each ft.flag_counts as |fc|}}
{{flag-counts details=fc}}
{{/each}}
</td>
<td>
{{flagged-topic-users users=ft.users tagName=""}}
</td>
<td>
{{format-age ft.last_flag_at}}
</td>
<td>
{{#link-to
"adminFlags.topics.show"
ft.id
class="btn d-button no-text btn-small btn-primary"
title=(i18n "admin.flags.show_details")}}
{{d-icon "search"}}
{{/link-to}}
</td>
</tr>
{{/each}}
</table>

View File

@ -0,0 +1,11 @@
<div class='flagged-topic-details'>
<div class='topic-title'>
{{topic-status topic=topic}}
<h1>{{{topic.fancyTitle}}}</h1>
</div>
{{plugin-outlet name="flagged-topic-details-header" args=(hash topic=topic)}}
</div>
<div class='topic-flags'>
{{flagged-posts flaggedPosts=flaggedPosts filter="active"}}
</div>

View File

@ -1,6 +1,7 @@
{{#admin-nav}}
{{nav-item route='adminFlags.postsActive' label='admin.flags.active'}}
{{nav-item route='adminFlags.postsOld' label='admin.flags.old'}}
{{nav-item route='adminFlags.postsActive' label='admin.flags.active_posts'}}
{{nav-item route='adminFlags.topics' label='admin.flags.topics'}}
{{nav-item route='adminFlags.postsOld' label='admin.flags.old_posts' class='right'}}
{{/admin-nav}}
<div class="admin-container">

View File

@ -194,7 +194,6 @@ export function buildResolver(baseName) {
// (similar to how discourse lays out templates)
findAdminTemplate(parsedName) {
var decamelized = parsedName.fullNameWithoutType.decamelize();
if (decamelized.indexOf('components') === 0) {
const compTemplate = Ember.TEMPLATES['admin/templates/' + decamelized];
if (compTemplate) { return compTemplate; }

View File

@ -1,7 +1,14 @@
import { ajax } from 'discourse/lib/ajax';
import { hashString } from 'discourse/lib/hash';
const ADMIN_MODELS = ['plugin', 'theme', 'embeddable-host', 'web-hook', 'web-hook-event'];
const ADMIN_MODELS = [
'plugin',
'theme',
'embeddable-host',
'web-hook',
'web-hook-event',
'flagged-topic'
];
export function Result(payload, responseJson) {
this.payload = payload;
@ -19,7 +26,6 @@ function rethrow(error) {
export default Ember.Object.extend({
storageKey(type, findArgs, options) {
if (options && options.cacheKey) {
return options.cacheKey;

View File

@ -36,6 +36,12 @@ export default Ember.Component.extend({
connectors: null,
init() {
// This should be the future default
if (this.get('noTags')) {
this.set('tagName', '');
this.set('connectorTagName', '');
}
this._super();
const name = this.get('name');
if (name) {

View File

@ -4,6 +4,7 @@
@import "common/foundation/helpers";
@import "common/admin/customize";
@import "common/admin/flagged_topics";
$mobile-breakpoint: 700px;

View File

@ -0,0 +1,28 @@
.flag-counts {
display: inline-block;
margin-right: 0.5em;
.type-count {
color: $primary-medium;
font-size: 0.9em;
}
}
.flagged-topic {
td.topic-title {
width: 400px;
a {
width: 400px;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.flagged-topic-details {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,14 @@
require_dependency 'flag_query'
class Admin::FlaggedTopicsController < Admin::AdminController
def index
result = FlagQuery.flagged_topics
render_json_dump({
flagged_topics: serialize_data(result[:flagged_topics], FlaggedTopicSummarySerializer),
users: serialize_data(result[:users], BasicUserSerializer),
}, rest_serializer: true)
end
end

View File

@ -490,7 +490,7 @@ class ApplicationController < ActionController::Base
# status - HTTP status code to return
def render_json_error(obj, opts = {})
opts = { status: opts } if opts.is_a?(Integer)
render json: MultiJson.dump(create_errors_json(obj, opts[:type])), status: opts[:status] || 422
render json: MultiJson.dump(create_errors_json(obj, opts)), status: opts[:status] || 422
end
def success_json

View File

@ -132,6 +132,7 @@ module HasCustomFields
if @preloaded.key?(key)
@preloaded[key]
else
raise "#{@preloaded.inspect} -> #{key.inspect}"
# for now you can not mix preload an non preload, it better just to fail
raise StandardError, "Attempting to access a non preloaded custom field, this is disallowed to prevent N+1 queries."
end

View File

@ -0,0 +1,29 @@
class FlaggedTopicSummarySerializer < ActiveModel::Serializer
attributes(
:id,
:flag_counts,
:user_ids,
:last_flag_at
)
has_one :topic, serializer: FlaggedTopicSerializer
def id
topic.id
end
def flag_counts
object.flag_counts.map do |k, v|
{ flag_type_id: k, count: v }
end
end
def user_ids
object.user_ids
end
def last_flag_at
object.last_flag_at
end
end

View File

@ -2596,8 +2596,9 @@ en:
flags:
title: "Flags"
old: "Old"
active: "Active"
active_posts: "Flagged Posts"
old_posts: "Old Flagged Posts"
topics: "Flagged Topics"
agree: "Agree"
agree_title: "Confirm this flag as valid and correct"
@ -2638,11 +2639,18 @@ en:
system: "System"
error: "Something went wrong"
reply_message: "Reply"
no_results: "There are no flags."
no_results: "There are no flaged posts."
topic_flagged: "This <strong>topic</strong> has been flagged."
visit_topic: "Visit the topic to take action"
was_edited: "Post was edited after the first flag"
previous_flags_count: "This post has already been flagged {{count}} times."
show_details: "Show flag details"
flagged_topics:
topic: "Topic"
type: "Type"
users: "Users"
last_flagged: "Last Flagged"
summary:
action_type_3:

View File

@ -188,11 +188,14 @@ Discourse::Application.routes.draw do
get "flags" => "flags#index"
get "flags/:filter" => "flags#index"
get "flags/topics/:topic_id" => "flags#index"
post "flags/agree/:id" => "flags#agree"
post "flags/disagree/:id" => "flags#disagree"
post "flags/defer/:id" => "flags#defer"
resources :flagged_topics, constraints: AdminConstraint.new
resources :themes, constraints: AdminConstraint.new
post "themes/import" => "themes#import"
post "themes/upload_asset" => "themes#upload_asset"
get "themes/:id/preview" => "themes#preview"

View File

@ -1,3 +1,5 @@
require 'ostruct'
module FlagQuery
def self.flagged_posts_report(current_user, filter, offset = 0, per_page = 25)
@ -128,6 +130,44 @@ module FlagQuery
end
def self.flagged_topics
results = PostAction
.flags
.active
.includes(post: [:user, :topic])
.order('post_actions.created_at DESC')
ft_by_id = {}
users_by_id = {}
topics_by_id = {}
results.each do |pa|
if pa.post.present? && pa.post.topic.present?
ft = ft_by_id[pa.post.topic.id] ||= OpenStruct.new(
topic: pa.post.topic,
flag_counts: {},
user_ids: [],
last_flag_at: pa.created_at
)
topics_by_id[pa.post.topic.id] = pa.post.topic
ft.flag_counts[pa.post_action_type_id] ||= 0
ft.flag_counts[pa.post_action_type_id] += 1
ft.user_ids << pa.post.user_id
ft.user_ids.uniq!
users_by_id[pa.post.user_id] ||= pa.post.user
end
end
Topic.preload_custom_fields(topics_by_id.values, TopicList.preloaded_custom_fields)
{ flagged_topics: ft_by_id.values, users: users_by_id.values }
end
private
def self.excerpt(cooked)

View File

@ -1,8 +1,11 @@
module JsonError
def create_errors_json(obj, type = nil)
def create_errors_json(obj, opts = nil)
opts ||= {}
errors = create_errors_array obj
errors[:error_type] = type if type
errors[:error_type] = opts[:type] if opts[:type]
errors[:extras] = opts[:extras] if opts[:extras]
errors
end

View File

@ -0,0 +1,11 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("Admin - Flagged Topics", { loggedIn: true });
QUnit.test("topics with flags", assert => {
visit("/admin/flags/topics");
andThen(() => {
assert.ok(exists('.watched-words-list'));
assert.ok(!exists('.watched-words-list .watched-word'), "Don't show bad words by default.");
});
});

View File

@ -323,6 +323,14 @@ export default function() {
]);
});
this.get('/admin/flagged_topics', () => {
return response(200, {
"flagged_topics": [
{ id: 1 }
]
});
});
this.get('/admin/customize/site_texts', request => {
if (request.queryParams.overridden) {

View File

@ -13,7 +13,6 @@ export default function() {
if (type === "adapter:rest") {
if (!this._restAdapter) {
this._restAdapter = RestAdapter.create({ owner: this });
// this._restAdapter.container = this;
}
return this._restAdapter;
}