FEATURE: flag dispositions normalization

All flags should end up in one of the three dispositions
  - Agree
  - Disagree
  - Defer

In the administration area, the *active* flags section displays 4 buttons
  - Agree (hide post + send PM)
  - Disagree
  - Defer
  - Delete

Clicking "Delete" will open a modal that offer to
  - Delete Post & Defer Flags
  - Delete Post & Agree with Flags
  - Delete Spammer (if available)

When the flag has a list associated, the list will now display 1
response and 1 reply and a "show more..." link if there are more in the
conversation. Replying to the conversation will NOT give a disposition.
Moderators must click the buttons that does that.

If someone clicks one buttons, this will add a default moderator message
from that moderator saying what happened.

The *old* flags section now displays the proper dispositions and is
super duper fast (no more N+9999 queries).

FIX: the old list includes deleted topics
FIX: the lists now properly display the topic states (deleted, closed,
archived, hidden, PM)
FIX: flagging a topic that you've already flagged the first post
This commit is contained in:
Régis Hanol
2014-07-28 19:17:37 +02:00
parent 717f57c968
commit bddffa7f9a
50 changed files with 886 additions and 558 deletions
@@ -8,36 +8,34 @@
**/
export default Ember.ArrayController.extend({
adminOldFlagsView: Em.computed.equal('query', 'old'),
adminActiveFlagsView: Em.computed.equal('query', 'active'),
actions: {
/**
Clear all flags on a post
@method clearFlags
@param {Discourse.FlaggedPost} item The post whose flags we want to clear
**/
disagreeFlags: function(item) {
var adminFlagsController = this;
item.disagreeFlags().then(function() {
adminFlagsController.removeObject(item);
}, function() {
agreeFlags: function (flaggedPost) {
var self = this;
flaggedPost.agreeFlags().then(function () {
self.removeObject(flaggedPost);
}, function () {
bootbox.alert(I18n.t("admin.flags.error"));
});
},
agreeFlags: function(item) {
var adminFlagsController = this;
item.agreeFlags().then(function() {
adminFlagsController.removeObject(item);
}, function() {
disagreeFlags: function (flaggedPost) {
var self = this;
flaggedPost.disagreeFlags().then(function () {
self.removeObject(flaggedPost);
}, function () {
bootbox.alert(I18n.t("admin.flags.error"));
});
},
deferFlags: function(item) {
var adminFlagsController = this;
item.deferFlags().then(function() {
adminFlagsController.removeObject(item);
}, function() {
deferFlags: function (flaggedPost) {
var self = this;
flaggedPost.deferFlags().then(function () {
self.removeObject(flaggedPost);
}, function () {
bootbox.alert(I18n.t("admin.flags.error"));
});
},
@@ -45,47 +43,8 @@ export default Ember.ArrayController.extend({
doneTopicFlags: function(item) {
this.send('disagreeFlags', item);
},
/**
Deletes a post
@method deletePost
@param {Discourse.FlaggedPost} post The post to delete
**/
deletePost: function(post) {
var adminFlagsController = this;
post.deletePost().then(function() {
adminFlagsController.removeObject(post);
}, function() {
bootbox.alert(I18n.t("admin.flags.error"));
});
},
/**
Deletes a user and all posts and topics created by that user.
@method deleteSpammer
@param {Discourse.FlaggedPost} item The post to delete
**/
deleteSpammer: function(item) {
item.get('user').deleteAsSpammer(function() { window.location.reload(); });
}
},
/**
Are we viewing the 'old' view?
@property adminOldFlagsView
**/
adminOldFlagsView: Em.computed.equal('query', 'old'),
/**
Are we viewing the 'active' view?
@property adminActiveFlagsView
**/
adminActiveFlagsView: Em.computed.equal('query', 'active'),
loadMore: function(){
var flags = this.get('model');
return Discourse.FlaggedPost.findAll(this.get('query'),flags.length+1).then(function(data){
@@ -0,0 +1,52 @@
/**
The modal for deleting a flag.
@class AdminDeleteFlagController
@extends Discourse.Controller
@namespace Discourse
@uses Discourse.ModalFunctionality
@module Discourse
**/
Discourse.AdminDeleteFlagController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, {
needs: ["adminFlags"],
actions: {
deletePostDeferFlag: function () {
var adminFlagController = this.get("controllers.adminFlags");
var post = this.get("content");
var self = this;
return post.deferFlags(true).then(function () {
adminFlagController.removeObject(post);
self.send("closeModal");
}, function () {
bootbox.alert(I18n.t("admin.flags.error"));
});
},
deletePostAgreeFlag: function () {
var adminFlagController = this.get("controllers.adminFlags");
var post = this.get("content");
var self = this;
return post.agreeFlags(true).then(function () {
adminFlagController.removeObject(post);
self.send("closeModal");
}, function () {
bootbox.alert(I18n.t("admin.flags.error"));
});
},
/**
Deletes a user and all posts and topics created by that user.
@method deleteSpammer
**/
deleteSpammer: function () {
this.get("content.user").deleteAsSpammer(function() { window.location.reload(); });
}
}
});
@@ -8,64 +8,69 @@
**/
Discourse.FlaggedPost = Discourse.Post.extend({
summary: function(){
summary: function () {
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});
})
.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 r,
_this = this;
r = [];
_.each(this.post_actions, function(action) {
var user = _this.userLookup[action.user_id];
var deletedBy = null;
if(action.deleted_by_id){
deletedBy = _this.userLookup[action.deleted_by_id];
}
flaggers: function () {
var self = this;
var flaggers = [];
var flagType = I18n.t('admin.flags.summary.action_type_' + action.post_action_type_id, {count: 1});
r.push({
user: user, flagType: flagType, flaggedAt: action.created_at, deletedBy: deletedBy,
tookAction: action.staff_took_action, deletedAt: action.deleted_at
_.each(this.post_actions, function (postAction) {
flaggers.push({
user: self.userLookup[postAction.user_id],
topic: self.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,
disposedAt: postAction.disposed_at,
disposition: postAction.disposition ? I18n.t('admin.flags.dispositions.' + postAction.disposition) : null,
tookAction: postAction.staff_took_action
});
});
return r;
return flaggers;
}.property(),
messages: function() {
var r,
_this = this;
r = [];
_.each(this.post_actions,function(action) {
if (action.message) {
r.push({
user: _this.userLookup[action.user_id],
message: action.message,
permalink: action.permalink,
bySystemUser: (action.user_id === -1 ? true : false)
});
conversations: function () {
var self = this;
var conversations = [];
_.each(this.post_actions, function (postAction) {
if (postAction.conversation) {
var conversation = {
permalink: postAction.permalink,
hasMore: postAction.conversation.has_more,
response: {
excerpt: postAction.conversation.response.excerpt,
user: self.userLookup[postAction.conversation.response.user_id]
}
};
if (postAction.conversation.reply) {
conversation["reply"] = {
excerpt: postAction.conversation.reply.excerpt,
user: self.userLookup[postAction.conversation.reply.user_id]
};
}
conversations.push(conversation);
}
});
return r;
}.property(),
lastFlagged: function() {
return this.post_actions[0].created_at;
return conversations;
}.property(),
user: function() {
return this.userLookup[this.user_id];
}.property(),
topicHidden: function() {
return !this.get('topic_visible');
}.property('topic_hidden'),
topic: function () {
return this.topicLookup[this.topic_id];
}.property(),
flaggedForSpam: function() {
return !_.every(this.get('post_actions'), function(action) { return action.name_key !== 'spam'; });
@@ -80,7 +85,7 @@ Discourse.FlaggedPost = Discourse.Post.extend({
}.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'));
return Discourse.User.currentProp('staff') && this.get('flaggedForSpam') && this.get('user.can_delete_all_posts') && this.get('user.can_be_deleted');
}.property('flaggedForSpam'),
deletePost: function() {
@@ -91,28 +96,24 @@ Discourse.FlaggedPost = Discourse.Post.extend({
}
},
disagreeFlags: function() {
disagreeFlags: function () {
return Discourse.ajax('/admin/flags/disagree/' + this.id, { type: 'POST', cache: false });
},
deferFlags: function() {
return Discourse.ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false });
deferFlags: function (deletePost) {
return Discourse.ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } });
},
agreeFlags: function() {
return Discourse.ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false });
agreeFlags: function (deletePost) {
return Discourse.ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } });
},
postHidden: Em.computed.alias('hidden'),
extraClasses: function() {
var classes = [];
if (this.get('hidden')) {
classes.push('hidden-post');
}
if (this.get('deleted')){
classes.push('deleted');
}
if (this.get('hidden')) { classes.push('hidden-post'); }
if (this.get('deleted')) { classes.push('deleted'); }
return classes.join(' ');
}.property(),
@@ -121,26 +122,36 @@ Discourse.FlaggedPost = Discourse.Post.extend({
});
Discourse.FlaggedPost.reopenClass({
findAll: function(filter, offset) {
findAll: function (filter, offset) {
offset = offset || 0;
var result = Em.A();
result.set('loading', true);
return Discourse.ajax('/admin/flags/' + filter + '.json?offset=' + offset).then(function(data) {
return Discourse.ajax('/admin/flags/' + filter + '.json?offset=' + offset).then(function (data) {
// users
var userLookup = {};
_.each(data.users,function(user) {
_.each(data.users,function (user) {
userLookup[user.id] = Discourse.AdminUser.create(user);
});
_.each(data.posts,function(post) {
// topics
var topicLookup = {};
_.each(data.topics, function (topic) {
topicLookup[topic.id] = Discourse.Topic.create(topic);
});
// posts
_.each(data.posts,function (post) {
var f = Discourse.FlaggedPost.create(post);
f.userLookup = userLookup;
f.topicLookup = topicLookup;
result.pushObject(f);
});
result.set('loading', false);
return result;
});
}
});
@@ -18,7 +18,16 @@ Discourse.AdminFlagsRouteType = Discourse.Route.extend({
});
Discourse.AdminFlagsActiveRoute = Discourse.AdminFlagsRouteType.extend({
filter: 'active'
filter: 'active',
actions: {
showDeleteFlagModal: function(flaggedPost) {
Discourse.Route.showModal(this, 'admin_delete_flag', flaggedPost);
this.controllerFor('modal').set('modalClass', 'delete-flag-modal');
}
}
});
@@ -8,10 +8,10 @@
</div>
<div class="admin-container">
{{#if model.loading}}
{{#if loading}}
<div class='admin-loading'>{{i18n loading}}</div>
{{else}}
{{#if model.length}}
{{#if length}}
<table class='admin-flags'>
<thead>
<tr>
@@ -19,131 +19,141 @@
<th class='excerpt'></th>
<th class='flaggers'>{{i18n admin.flags.flagged_by}}</th>
<th class='flaggers'>{{#if adminOldFlagsView}}{{i18n admin.flags.resolved_by}}{{/if}}</th>
<th class='last-flagged'></th>
<th class='action'></th>
</tr>
</thead>
<tbody>
{{#each flaggedPost in content}}
<tr {{bind-attr class="flaggedPost.extraClasses"}}>
<tr {{bind-attr class="flaggedPost.extraClasses"}}>
<td class='user'>
{{#if flaggedPost.postAuthorFlagged}}
{{#if flaggedPost.user}}
{{#link-to 'adminUser' flaggedPost.user}}{{avatar flaggedPost.user imageSize="small"}}{{/link-to}}
<td class='user'>
{{#if flaggedPost.postAuthorFlagged}}
{{#if flaggedPost.user}}
{{#link-to 'adminUser' flaggedPost.user}}{{avatar flaggedPost.user imageSize="small"}}{{/link-to}}
{{/if}}
{{/if}}
{{/if}}
</td>
</td>
<td class='excerpt'>
{{#if flaggedPost.topicHidden}}<i title='{{i18n topic_statuses.invisible.help}}' class='fa fa-eye-slash'></i> {{/if}}<h3><a href='{{unbound flaggedPost.url}}'>{{flaggedPost.title}}</a></h3>
<br>
{{#if flaggedPost.postAuthorFlagged}}
{{{flaggedPost.excerpt}}}
{{/if}}
</td>
<td class='excerpt'>
<h3>
{{#if flaggedPost.topic.isPrivateMessage}}
<span class="private-message-glyph">{{icon envelope}}</span>
{{/if}}
{{topic-status topic=flaggedPost.topic}}
<a href='{{unbound flaggedPost.topic.url}}'>{{flaggedPost.topic.title}}</a>
</h3>
{{#if flaggedPost.postAuthorFlagged}}
{{{flaggedPost.excerpt}}}
{{/if}}
</td>
<td class='flaggers'>
<table>
<tbody>
{{#each flaggedPost.flaggers}}
<tr>
<td>
{{#link-to 'adminUser' this.user}}{{avatar this.user imageSize="small"}} {{/link-to}}
</td>
<td>
{{date this.flaggedAt}}
</td>
<td>
{{this.flagType}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</td>
<td class='flaggers'>
<table>
<tbody>
{{#each flaggedPost.flaggers}}
<tr>
<td width="20%">
{{#link-to 'adminUser' user}}
{{avatar user imageSize="small"}}
{{/link-to}}
</td>
<td width="30%">
{{date flaggedAt}}
</td>
<td width="50%">
{{flagType}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</td>
<td class='flaggers result'>
<table>
<tbody>
{{#each flaggedPost.flaggers}}
<tr>
{{#if deletedBy}}
<td>
{{#link-to 'adminUser' this.deletedBy}}{{avatar this.deletedBy imageSize="small"}} {{/link-to}}
</td>
<td>
{{#if this.tookAction}}
<i class='fa fa-gavel'></i>
{{/if}}
</td>
<td>
{{date this.deletedAt}}
</td>
{{/if}}
</tr>
{{/each}}
</tbody>
</table>
</td>
</tr>
<td class='flaggers result'>
<table>
<tbody>
{{#each flaggedPost.flaggers}}
<tr>
<td width="20%">
{{#link-to 'adminUser' disposedBy}}
{{avatar disposedBy imageSize="small"}}
{{/link-to}}
</td>
<td width="30%">
{{date disposedAt}}
</td>
<td width="50%">
{{disposition}}
{{#if tookAction}}
<i class='fa fa-gavel'></i>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</td>
</tr>
{{#if flaggedPost.topicFlagged}}
<tr>
<td></td>
<td class='message'><div>{{{i18n admin.flags.topic_flagged}}}</div></td>
<td></td>
<tr class='message'>
<td></td>
<td colspan="3">
<div>
{{{i18n admin.flags.topic_flagged}}}
</div>
</td>
</tr>
{{/if}}
{{#each flaggedPost.messages}}
<tr>
{{#each flaggedPost.conversations}}
<tr class='message'>
<td></td>
<td class='message'>
<td colspan="3">
<div>
{{#unless bySystemUser}}
{{#link-to 'adminUser' user}}{{avatar user imageSize="small"}}{{/link-to}}
{{message}}
<a href="{{unbound permalink}}"><button class='btn'><i class="fa fa-reply"></i> {{i18n admin.flags.view_message}}</button></a>
{{else}}
<b>{{i18n admin.flags.system}}</b>:
{{message}}
{{/unless}}
{{#if response}}
<p>
{{#link-to 'adminUser' response.user}}{{avatar response.user imageSize="small"}}{{/link-to}}&nbsp;{{{response.excerpt}}}
</p>
{{#if reply}}
<p>
{{#link-to 'adminUser' reply.user}}{{avatar reply.user imageSize="small"}}{{/link-to}}&nbsp;{{{reply.excerpt}}}
{{#if hasMore}}
<a href="{{unbound permalink}}">{{i18n admin.flags.more}}</a>
{{/if}}
</p>
{{/if}}
<a href="{{unbound permalink}}">
<button class='btn btn-reply'><i class="fa fa-reply"></i>&nbsp;{{i18n admin.flags.reply_message}}</button>
</a>
{{/if}}
</div>
</td>
<td></td>
<td></td>
</tr>
{{/each}}
<tr>
<tr>
<td colspan="4" class="action">
{{#if adminActiveFlagsView}}
{{#if flaggedPost.topicFlagged}}
<a href='{{unbound flaggedPost.url}}' class="btn">{{i18n admin.flags.visit_topic}}</a>
{{/if}}
{{#if adminActiveFlagsView}}
{{#if flaggedPost.topicFlagged}}
<a href='{{unbound flaggedPost.url}}' class="btn">{{i18n admin.flags.visit_topic}}</a>
{{/if}}
{{#if flaggedPost.postAuthorFlagged}}
{{#if flaggedPost.postHidden}}
<button title='{{i18n admin.flags.disagree_unhide_title}}' class='btn' {{action disagreeFlags flaggedPost}}><i class="fa fa-thumbs-o-down"></i> {{i18n admin.flags.disagree_unhide}}</button>
<button title='{{i18n admin.flags.defer_title}}' class='btn' {{action deferFlags flaggedPost}}><i class="fa fa-external-link"></i> {{i18n admin.flags.defer}}</button>
{{#if flaggedPost.postAuthorFlagged}}
{{#if flaggedPost.postHidden}}
<button title='{{i18n admin.flags.disagree_flag_unhide_post_title}}' class='btn' {{action disagreeFlags flaggedPost}}><i class="fa fa-thumbs-o-down"></i>&nbsp;{{i18n admin.flags.disagree_flag_unhide_post}}</button>
{{else}}
<button title='{{i18n admin.flags.agree_flag_hide_post_title}}' class='btn btn-primary' {{action agreeFlags flaggedPost}}><i class="fa fa-thumbs-o-up"></i>&nbsp;{{i18n admin.flags.agree_flag_hide_post}}</button>
<button title='{{i18n admin.flags.disagree_flag_title}}' class='btn' {{action disagreeFlags flaggedPost}}><i class="fa fa-thumbs-o-down"></i>&nbsp;{{i18n admin.flags.disagree_flag}}</button>
{{/if}}
<button title='{{i18n admin.flags.defer_flag_title}}' class='btn' {{action deferFlags flaggedPost}}><i class="fa fa-external-link"></i>&nbsp;{{i18n admin.flags.defer_flag}}</button>
<button title='{{i18n admin.flags.delete_title}}' class='btn btn-danger' {{action showDeleteFlagModal flaggedPost}}><i class="fa fa-trash-o"></i>&nbsp;{{i18n admin.flags.delete}}</button>
{{else}}
<button title='{{i18n admin.flags.agree_hide_title}}' class='btn' {{action agreeFlags flaggedPost}}><i class="fa fa-thumbs-o-up"></i> {{i18n admin.flags.agree_hide}}</button>
<button title='{{i18n admin.flags.disagree_title}}' class='btn' {{action disagreeFlags flaggedPost}}><i class="fa fa-thumbs-o-down"></i> {{i18n admin.flags.disagree}}</button>
<button title='{{i18n admin.flags.clear_topic_flags_title}}' class='btn' {{action doneTopicFlags flaggedPost}}>{{i18n admin.flags.clear_topic_flags}}</button>
{{/if}}
{{#if flaggedPost.canDeleteAsSpammer}}
<button title='{{i18n admin.flags.delete_spammer_title}}' class="btn" {{action deleteSpammer flaggedPost}}><i class="fa fa-exclamation-triangle"></i> {{i18n flagging.delete_spammer}}</button>
{{/if}}
<button title='{{i18n admin.flags.delete_post_title}}' class='btn' {{action deletePost flaggedPost}}><i class="fa fa-trash-o"></i> {{i18n admin.flags.delete_post}}</button>
{{else}}
<button title='{{i18n admin.flags.clear_topic_flags_title}}' class='btn' {{action doneTopicFlags flaggedPost}}>{{i18n admin.flags.clear_topic_flags}}</button>
{{/if}}
{{/if}}
</td>
</tr>
</tr>
{{/each}}
@@ -0,0 +1,5 @@
<button title="{{i18n admin.flags.delete_post_defer_flag_title}}" {{action deletePostDeferFlag}} class="btn btn-primary"><i class="fa fa-trash-o"></i> {{i18n admin.flags.delete_post_defer_flag}}</button>
<button title="{{i18n admin.flags.delete_post_agree_flag_title}}" {{action deletePostAgreeFlag}} class="btn btn-primary"><i class="fa fa-trash-o"></i> {{i18n admin.flags.delete_post_agree_flag}}</button>
{{#if canDeleteAsSpammer}}
<button title="{{i18n admin.flags.delete_spammer_title}}" {{action deleteSpammer}} class="btn btn-danger"><i class="fa fa-exclamation-triangle"></i> {{i18n admin.flags.delete_spammer}}</button>
{{/if}}
@@ -1,13 +1,21 @@
Discourse.AdminFlagsView = Discourse.View.extend(Discourse.LoadMore, {
loading: false,
eyelineSelector: '.admin-flags tbody tr',
loadMore: function() {
var view = this;
if(this.get("loading") || this.get("model.allLoaded")) { return; }
this.set("loading", true);
this.get("controller").loadMore().then(function(){
view.set("loading", false);
});
actions: {
loadMore: function() {
var self = this;
if (this.get("loading") || this.get("model.allLoaded")) { return; }
this.set("loading", true);
this.get("controller").loadMore().then(function () {
self.set("loading", false);
});
}
}
});
@@ -0,0 +1,12 @@
/**
A modal view for deleting a flag.
@class AdminDeleteFlagView
@extends Discourse.ModalBodyView
@namespace Discourse
@module Discourse
**/
Discourse.AdminDeleteFlagView = Discourse.ModalBodyView.extend({
templateName: 'admin/templates/modal/admin_delete_flag',
title: I18n.t('admin.flags.delete_flag_modal_title')
});