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

View File

@ -8,36 +8,34 @@
**/ **/
export default Ember.ArrayController.extend({ export default Ember.ArrayController.extend({
adminOldFlagsView: Em.computed.equal('query', 'old'),
adminActiveFlagsView: Em.computed.equal('query', 'active'),
actions: { actions: {
/**
Clear all flags on a post
@method clearFlags agreeFlags: function (flaggedPost) {
@param {Discourse.FlaggedPost} item The post whose flags we want to clear var self = this;
**/ flaggedPost.agreeFlags().then(function () {
disagreeFlags: function(item) { self.removeObject(flaggedPost);
var adminFlagsController = this; }, function () {
item.disagreeFlags().then(function() {
adminFlagsController.removeObject(item);
}, function() {
bootbox.alert(I18n.t("admin.flags.error")); bootbox.alert(I18n.t("admin.flags.error"));
}); });
}, },
agreeFlags: function(item) { disagreeFlags: function (flaggedPost) {
var adminFlagsController = this; var self = this;
item.agreeFlags().then(function() { flaggedPost.disagreeFlags().then(function () {
adminFlagsController.removeObject(item); self.removeObject(flaggedPost);
}, function() { }, function () {
bootbox.alert(I18n.t("admin.flags.error")); bootbox.alert(I18n.t("admin.flags.error"));
}); });
}, },
deferFlags: function(item) { deferFlags: function (flaggedPost) {
var adminFlagsController = this; var self = this;
item.deferFlags().then(function() { flaggedPost.deferFlags().then(function () {
adminFlagsController.removeObject(item); self.removeObject(flaggedPost);
}, function() { }, function () {
bootbox.alert(I18n.t("admin.flags.error")); bootbox.alert(I18n.t("admin.flags.error"));
}); });
}, },
@ -45,47 +43,8 @@ export default Ember.ArrayController.extend({
doneTopicFlags: function(item) { doneTopicFlags: function(item) {
this.send('disagreeFlags', 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(){ loadMore: function(){
var flags = this.get('model'); var flags = this.get('model');
return Discourse.FlaggedPost.findAll(this.get('query'),flags.length+1).then(function(data){ return Discourse.FlaggedPost.findAll(this.get('query'),flags.length+1).then(function(data){

View File

@ -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(); });
}
}
});

View File

@ -8,64 +8,69 @@
**/ **/
Discourse.FlaggedPost = Discourse.Post.extend({ Discourse.FlaggedPost = Discourse.Post.extend({
summary: function(){ summary: function () {
return _(this.post_actions) return _(this.post_actions)
.groupBy(function(a){ return a.post_action_type_id; }) .groupBy(function (a) { return a.post_action_type_id; })
.map(function(v,k){ .map(function (v,k) { return I18n.t('admin.flags.summary.action_type_' + k, { count: v.length }); })
return I18n.t('admin.flags.summary.action_type_' + k, {count: v.length});
})
.join(','); .join(',');
}.property(), }.property(),
flaggers: function() { flaggers: function () {
var r, var self = this;
_this = this; var flaggers = [];
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];
}
var flagType = I18n.t('admin.flags.summary.action_type_' + action.post_action_type_id, {count: 1}); _.each(this.post_actions, function (postAction) {
flaggers.push({
r.push({ user: self.userLookup[postAction.user_id],
user: user, flagType: flagType, flaggedAt: action.created_at, deletedBy: deletedBy, topic: self.topicLookup[postAction.topic_id],
tookAction: action.staff_took_action, deletedAt: action.deleted_at 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(), }.property(),
messages: function() { conversations: function () {
var r, var self = this;
_this = this; var conversations = [];
r = [];
_.each(this.post_actions,function(action) { _.each(this.post_actions, function (postAction) {
if (action.message) { if (postAction.conversation) {
r.push({ var conversation = {
user: _this.userLookup[action.user_id], permalink: postAction.permalink,
message: action.message, hasMore: postAction.conversation.has_more,
permalink: action.permalink, response: {
bySystemUser: (action.user_id === -1 ? true : false) 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 conversations;
return this.post_actions[0].created_at;
}.property(), }.property(),
user: function() { user: function() {
return this.userLookup[this.user_id]; return this.userLookup[this.user_id];
}.property(), }.property(),
topicHidden: function() { topic: function () {
return !this.get('topic_visible'); return this.topicLookup[this.topic_id];
}.property('topic_hidden'), }.property(),
flaggedForSpam: function() { flaggedForSpam: function() {
return !_.every(this.get('post_actions'), function(action) { return action.name_key !== 'spam'; }); 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'), }.property('post_actions.@each.targets_topic'),
canDeleteAsSpammer: function() { 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'), }.property('flaggedForSpam'),
deletePost: function() { 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 }); return Discourse.ajax('/admin/flags/disagree/' + this.id, { type: 'POST', cache: false });
}, },
deferFlags: function() { deferFlags: function (deletePost) {
return Discourse.ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false }); return Discourse.ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } });
}, },
agreeFlags: function() { agreeFlags: function (deletePost) {
return Discourse.ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false }); return Discourse.ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } });
}, },
postHidden: Em.computed.alias('hidden'), postHidden: Em.computed.alias('hidden'),
extraClasses: function() { extraClasses: function() {
var classes = []; var classes = [];
if (this.get('hidden')) { if (this.get('hidden')) { classes.push('hidden-post'); }
classes.push('hidden-post'); if (this.get('deleted')) { classes.push('deleted'); }
}
if (this.get('deleted')){
classes.push('deleted');
}
return classes.join(' '); return classes.join(' ');
}.property(), }.property(),
@ -121,26 +122,36 @@ Discourse.FlaggedPost = Discourse.Post.extend({
}); });
Discourse.FlaggedPost.reopenClass({ Discourse.FlaggedPost.reopenClass({
findAll: function(filter, offset) { findAll: function (filter, offset) {
offset = offset || 0; offset = offset || 0;
var result = Em.A(); var result = Em.A();
result.set('loading', true); 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 = {}; var userLookup = {};
_.each(data.users,function(user) { _.each(data.users,function (user) {
userLookup[user.id] = Discourse.AdminUser.create(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); var f = Discourse.FlaggedPost.create(post);
f.userLookup = userLookup; f.userLookup = userLookup;
f.topicLookup = topicLookup;
result.pushObject(f); result.pushObject(f);
}); });
result.set('loading', false); result.set('loading', false);
return result; return result;
}); });
} }
}); });

View File

@ -18,7 +18,16 @@ Discourse.AdminFlagsRouteType = Discourse.Route.extend({
}); });
Discourse.AdminFlagsActiveRoute = Discourse.AdminFlagsRouteType.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');
}
}
}); });

View File

@ -8,10 +8,10 @@
</div> </div>
<div class="admin-container"> <div class="admin-container">
{{#if model.loading}} {{#if loading}}
<div class='admin-loading'>{{i18n loading}}</div> <div class='admin-loading'>{{i18n loading}}</div>
{{else}} {{else}}
{{#if model.length}} {{#if length}}
<table class='admin-flags'> <table class='admin-flags'>
<thead> <thead>
<tr> <tr>
@ -19,8 +19,6 @@
<th class='excerpt'></th> <th class='excerpt'></th>
<th class='flaggers'>{{i18n admin.flags.flagged_by}}</th> <th class='flaggers'>{{i18n admin.flags.flagged_by}}</th>
<th class='flaggers'>{{#if adminOldFlagsView}}{{i18n admin.flags.resolved_by}}{{/if}}</th> <th class='flaggers'>{{#if adminOldFlagsView}}{{i18n admin.flags.resolved_by}}{{/if}}</th>
<th class='last-flagged'></th>
<th class='action'></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -36,8 +34,13 @@
</td> </td>
<td class='excerpt'> <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> <h3>
<br> {{#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}} {{#if flaggedPost.postAuthorFlagged}}
{{{flaggedPost.excerpt}}} {{{flaggedPost.excerpt}}}
{{/if}} {{/if}}
@ -48,14 +51,16 @@
<tbody> <tbody>
{{#each flaggedPost.flaggers}} {{#each flaggedPost.flaggers}}
<tr> <tr>
<td> <td width="20%">
{{#link-to 'adminUser' this.user}}{{avatar this.user imageSize="small"}} {{/link-to}} {{#link-to 'adminUser' user}}
{{avatar user imageSize="small"}}
{{/link-to}}
</td> </td>
<td> <td width="30%">
{{date this.flaggedAt}} {{date flaggedAt}}
</td> </td>
<td> <td width="50%">
{{this.flagType}} {{flagType}}
</td> </td>
</tr> </tr>
{{/each}} {{/each}}
@ -68,52 +73,62 @@
<tbody> <tbody>
{{#each flaggedPost.flaggers}} {{#each flaggedPost.flaggers}}
<tr> <tr>
{{#if deletedBy}} <td width="20%">
<td> {{#link-to 'adminUser' disposedBy}}
{{#link-to 'adminUser' this.deletedBy}}{{avatar this.deletedBy imageSize="small"}} {{/link-to}} {{avatar disposedBy imageSize="small"}}
{{/link-to}}
</td> </td>
<td> <td width="30%">
{{#if this.tookAction}} {{date disposedAt}}
</td>
<td width="50%">
{{disposition}}
{{#if tookAction}}
<i class='fa fa-gavel'></i> <i class='fa fa-gavel'></i>
{{/if}} {{/if}}
</td> </td>
<td>
{{date this.deletedAt}}
</td>
{{/if}}
</tr> </tr>
{{/each}} {{/each}}
</tbody> </tbody>
</table> </table>
</td> </td>
</tr> </tr>
{{#if flaggedPost.topicFlagged}} {{#if flaggedPost.topicFlagged}}
<tr> <tr class='message'>
<td></td>
<td class='message'><div>{{{i18n admin.flags.topic_flagged}}}</div></td>
<td></td>
<td></td> <td></td>
<td colspan="3">
<div>
{{{i18n admin.flags.topic_flagged}}}
</div>
</td>
</tr> </tr>
{{/if}} {{/if}}
{{#each flaggedPost.messages}} {{#each flaggedPost.conversations}}
<tr> <tr class='message'>
<td></td> <td></td>
<td class='message'> <td colspan="3">
<div> <div>
{{#unless bySystemUser}} {{#if response}}
{{#link-to 'adminUser' user}}{{avatar user imageSize="small"}}{{/link-to}} <p>
{{message}} {{#link-to 'adminUser' response.user}}{{avatar response.user imageSize="small"}}{{/link-to}}&nbsp;{{{response.excerpt}}}
<a href="{{unbound permalink}}"><button class='btn'><i class="fa fa-reply"></i> {{i18n admin.flags.view_message}}</button></a> </p>
{{else}} {{#if reply}}
<b>{{i18n admin.flags.system}}</b>: <p>
{{message}} {{#link-to 'adminUser' reply.user}}{{avatar reply.user imageSize="small"}}{{/link-to}}&nbsp;{{{reply.excerpt}}}
{{/unless}} {{#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> </div>
</td> </td>
<td></td>
<td></td>
</tr> </tr>
{{/each}} {{/each}}
@ -126,18 +141,13 @@
{{#if flaggedPost.postAuthorFlagged}} {{#if flaggedPost.postAuthorFlagged}}
{{#if flaggedPost.postHidden}} {{#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.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>
<button title='{{i18n admin.flags.defer_title}}' class='btn' {{action deferFlags flaggedPost}}><i class="fa fa-external-link"></i> {{i18n admin.flags.defer}}</button>
{{else}} {{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.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_title}}' class='btn' {{action disagreeFlags flaggedPost}}><i class="fa fa-thumbs-o-down"></i> {{i18n admin.flags.disagree}}</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}} {{/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>
{{#if flaggedPost.canDeleteAsSpammer}} <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>
<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}} {{else}}
<button title='{{i18n admin.flags.clear_topic_flags_title}}' class='btn' {{action doneTopicFlags flaggedPost}}>{{i18n admin.flags.clear_topic_flags}}</button> <button title='{{i18n admin.flags.clear_topic_flags_title}}' class='btn' {{action doneTopicFlags flaggedPost}}>{{i18n admin.flags.clear_topic_flags}}</button>
{{/if}} {{/if}}

View File

@ -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}}

View File

@ -1,13 +1,21 @@
Discourse.AdminFlagsView = Discourse.View.extend(Discourse.LoadMore, { Discourse.AdminFlagsView = Discourse.View.extend(Discourse.LoadMore, {
loading: false, loading: false,
eyelineSelector: '.admin-flags tbody tr', eyelineSelector: '.admin-flags tbody tr',
actions: {
loadMore: function() { loadMore: function() {
var view = this; var self = this;
if(this.get("loading") || this.get("model.allLoaded")) { return; }
if (this.get("loading") || this.get("model.allLoaded")) { return; }
this.set("loading", true); this.set("loading", true);
this.get("controller").loadMore().then(function(){
view.set("loading", false); this.get("controller").loadMore().then(function () {
self.set("loading", false);
}); });
} }
}
}); });

View File

@ -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')
});

View File

@ -53,7 +53,7 @@ export default Em.Component.extend({
renderActionIf('usersCollapsed', 'who-acted', c.get('description')); renderActionIf('usersCollapsed', 'who-acted', c.get('description'));
renderActionIf('canAlsoAction', 'act', I18n.t("post.actions.it_too." + c.get('actionType.name_key'))); renderActionIf('canAlsoAction', 'act', I18n.t("post.actions.it_too." + c.get('actionType.name_key')));
renderActionIf('can_undo', 'undo', I18n.t("post.actions.undo." + c.get('actionType.name_key'))); renderActionIf('can_undo', 'undo', I18n.t("post.actions.undo." + c.get('actionType.name_key')));
renderActionIf('can_clear_flags', 'clear-flags', I18n.t("post.actions.clear_flags", { count: c.count })); renderActionIf('can_defer_flags', 'defer-flags', I18n.t("post.actions.defer_flags", { count: c.count }));
buffer.push("</div>"); buffer.push("</div>");
}); });
@ -77,8 +77,8 @@ export default Em.Component.extend({
var $target = $(e.target), var $target = $(e.target),
actionTypeId; actionTypeId;
if (actionTypeId = $target.data('clear-flags')) { if (actionTypeId = $target.data('defer-flags')) {
this.actionTypeById(actionTypeId).clearFlags(); this.actionTypeById(actionTypeId).deferFlags();
return false; return false;
} }

View File

@ -47,9 +47,7 @@ export default Ember.Component.extend({
// Allow a plugin to add a custom icon to a topic // Allow a plugin to add a custom icon to a topic
this.trigger('addCustomIcon', buffer); this.trigger('addCustomIcon', buffer);
var togglePin = function(){ var togglePin = function () {};
};
renderIconIf('topic.closed', 'lock', 'locked'); renderIconIf('topic.closed', 'lock', 'locked');
renderIconIf('topic.archived', 'lock', 'archived'); renderIconIf('topic.archived', 'lock', 'archived');

View File

@ -68,7 +68,7 @@ Discourse.ActionSummary = Discourse.Model.extend({
if(action === 'notify_moderators' || action === 'notify_user') { if(action === 'notify_moderators' || action === 'notify_user') {
this.set('can_undo',false); this.set('can_undo',false);
this.set('can_clear_flags',false); this.set('can_defer_flags',false);
} }
// Add ourselves to the users who liked it if present // Add ourselves to the users who liked it if present
@ -108,9 +108,9 @@ Discourse.ActionSummary = Discourse.Model.extend({
}); });
}, },
clearFlags: function() { deferFlags: function() {
var actionSummary = this; var actionSummary = this;
return Discourse.ajax("/post_actions/clear_flags", { return Discourse.ajax("/post_actions/defer_flags", {
type: "POST", type: "POST",
data: { data: {
post_action_type_id: this.get('id'), post_action_type_id: this.get('id'),

View File

@ -17,7 +17,7 @@
{{#if editingTopic}} {{#if editingTopic}}
{{#if isPrivateMessage}} {{#if isPrivateMessage}}
<span class="private-message-glyph"><i class='fa fa-envelope'></i></span> <span class="private-message-glyph">{{icon envelope}}</span>
{{else}} {{else}}
{{category-chooser valueAttribute="id" value=newCategoryId source=category_id}} {{category-chooser valueAttribute="id" value=newCategoryId source=category_id}}
{{/if}} {{/if}}

View File

@ -494,25 +494,30 @@ section.details {
.admin-flags { .admin-flags {
tr.hidden-post td.excerpt { opacity: 0.4; } .hidden-post td.excerpt { opacity: 0.5; }
tr.deleted td.excerpt { opacity: 0.8; background-color: scale-color($danger, $lightness: 30%); } .deleted td.excerpt { background-color: scale-color($danger, $lightness: 70%); }
td.message { .message { background-color: scale-color($highlight, $lightness: 70%); }
padding: 4px 8px; .message:hover { background-color: scale-color($highlight, $lightness: 30%); }
background-color: scale-color($highlight, $lightness: 30%);
}
td { vertical-align: top; } td { vertical-align: top; }
th { text-align: left; } th { text-align: left; }
.user { width: 40px; padding-top: 12px; } .user {
width: 20px;
padding-top: 8px;
}
.excerpt { .excerpt {
max-width: 740px; max-width: 720px;
width: 740px; width: 720px;
padding: 8px; padding: 8px;
word-wrap: break-word; word-wrap: break-word;
.fa,h3 { display: inline-block; } .fa { display: inline-block; }
h3 {
max-height: 1.2em;
overflow: hidden;
}
} }
.flaggers { .flaggers {
font-size: 11px; font-size: 11px;
padding: 8px 0 0 5px;
td { td {
vertical-align: middle; vertical-align: middle;
padding: 3px; padding: 3px;
@ -523,6 +528,10 @@ section.details {
text-align: right; text-align: right;
padding-bottom: 20px; padding-bottom: 20px;
} }
td p {
font-size: 13px;
margin: 0 0 5px 0;
}
} }
/* Dashboard */ /* Dashboard */
@ -1135,6 +1144,17 @@ button.ru {
visibility: hidden; visibility: hidden;
} }
.delete-flag-modal {
.modal-inner-container {
width: 400px;
}
button {
display: block;
margin: 10px 0 10px 10px;
padding: 10px 15px;
}
}
@media all @media all
and (max-width : 850px) { and (max-width : 850px) {
.nav-stacked { .nav-stacked {
@ -1202,7 +1222,6 @@ and (max-width : 500px) {
.customize .content-list, .customize .current-style { .customize .content-list, .customize .current-style {
width: 100%; width: 100%;
} }
} }

View File

@ -1,37 +1,50 @@
require 'flag_query' require 'flag_query'
class Admin::FlagsController < Admin::AdminController class Admin::FlagsController < Admin::AdminController
def index def index
# we may get out of sync, fix it here # we may get out of sync, fix it here
PostAction.update_flagged_posts_count PostAction.update_flagged_posts_count
posts, users = FlagQuery.flagged_posts_report(current_user, params[:filter], params[:offset].to_i, 10) posts, topics, users = FlagQuery.flagged_posts_report(current_user, params[:filter], params[:offset].to_i, 10)
if posts.blank? if posts.blank?
render json: {users: [], posts: []} render json: { posts: [], topics: [], users: [] }
else else
render json: MultiJson.dump({users: serialize_data(users, AdminDetailedUserSerializer), posts: posts}) render json: MultiJson.dump({
posts: posts,
topics: serialize_data(topics, FlaggedTopicSerializer),
users: serialize_data(users, FlaggedUserSerializer)
})
end end
end end
def disagree
p = Post.find(params[:id])
PostAction.clear_flags!(p, current_user.id)
p.reload
p.unhide!
render nothing: true
end
def agree def agree
p = Post.find(params[:id]) params.permit(:id, :delete_post)
post_action_type = PostAction.post_action_type_for_post(p.id) post = Post.find(params[:id])
PostAction.defer_flags!(p, current_user.id) post_action_type = PostAction.post_action_type_for_post(post.id)
PostAction.hide_post!(p, post_action_type) PostAction.agree_flags!(post, current_user, params[:delete_post])
if params[:delete_post]
PostDestroyer.new(current_user, post).destroy
else
PostAction.hide_post!(post, post_action_type)
end
render nothing: true
end
def disagree
params.permit(:id)
post = Post.find(params[:id])
PostAction.clear_flags!(post, current_user)
post.reload
post.unhide!
render nothing: true render nothing: true
end end
def defer def defer
p = Post.find(params[:id]) params.permit(:id, :delete_post)
PostAction.defer_flags!(p, current_user.id) post = Post.find(params[:id])
PostAction.defer_flags!(post, current_user, params[:delete_post])
PostDestroyer.new(current_user, post).destroy if params[:delete_post]
render nothing: true render nothing: true
end end

View File

@ -181,7 +181,7 @@ class Admin::UsersController < Admin::AdminController
end end
def destroy def destroy
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id].to_i)
guardian.ensure_can_delete_user!(user) guardian.ensure_can_delete_user!(user)
begin begin
if UserDestroyer.new(current_user).destroy(user, params.slice(:delete_posts, :block_email, :block_urls, :block_ip, :context)) if UserDestroyer.new(current_user).destroy(user, params.slice(:delete_posts, :block_email, :block_urls, :block_ip, :context))

View File

@ -11,7 +11,7 @@ class PostActionsController < ApplicationController
args = {} args = {}
args[:message] = params[:message] if params[:message].present? args[:message] = params[:message] if params[:message].present?
args[:take_action] = true if guardian.is_staff? and params[:take_action] == 'true' args[:take_action] = true if guardian.is_staff? && params[:take_action] == 'true'
args[:flag_topic] = true if params[:flag_topic] == 'true' args[:flag_topic] = true if params[:flag_topic] == 'true'
post_action = PostAction.act(current_user, @post, @post_action_type_id, args) post_action = PostAction.act(current_user, @post, @post_action_type_id, args)
@ -46,17 +46,17 @@ class PostActionsController < ApplicationController
render nothing: true render nothing: true
end end
def clear_flags def defer_flags
guardian.ensure_can_clear_flags!(@post) guardian.ensure_can_defer_flags!(@post)
PostAction.clear_flags!(@post, current_user.id, @post_action_type_id) PostAction.defer_flags!(@post, current_user)
@post.reload @post.reload
if @post.is_flagged? if @post.is_flagged?
render json: {success: true, hidden: true} render json: { success: true, hidden: true }
else else
@post.unhide! @post.unhide!
render json: {success: true, hidden: false} render json: { success: true, hidden: false }
end end
end end

View File

@ -16,17 +16,35 @@ class PostAction < ActiveRecord::Base
rate_limit :post_action_rate_limiter rate_limit :post_action_rate_limiter
scope :spam_flags, -> { where(post_action_type_id: PostActionType.types[:spam]) } scope :spam_flags, -> { where(post_action_type_id: PostActionType.types[:spam]) }
scope :flags, -> { where(post_action_type_id: PostActionType.notify_flag_type_ids) }
scope :publics, -> { where(post_action_type_id: PostActionType.public_type_ids) }
scope :active, -> { where(defered_at: nil, agreed_at: nil, deleted_at: nil) }
after_save :update_counters after_save :update_counters
after_save :enforce_rules after_save :enforce_rules
after_commit :notify_subscribers after_commit :notify_subscribers
def disposed_by_id
deleted_by_id || agreed_by_id || defered_by_id
end
def disposed_at
deleted_at || agreed_at || defered_at
end
def disposition
return :disagreed if deleted_at
return :agreed if agreed_at
return :defered if defered_at
nil
end
def self.update_flagged_posts_count def self.update_flagged_posts_count
posts_flagged_count = PostAction.joins(post: :topic) posts_flagged_count = PostAction.active
.where('defer = false or defer IS NULL') .flags
.where('post_actions.post_action_type_id' => PostActionType.notify_flag_type_ids, .joins(post: :topic)
'posts.deleted_at' => nil, .where('posts.deleted_at' => nil)
'topics.deleted_at' => nil) .where('topics.deleted_at' => nil)
.count('DISTINCT posts.id') .count('DISTINCT posts.id')
$redis.set('posts_flagged_count', posts_flagged_count) $redis.set('posts_flagged_count', posts_flagged_count)
@ -41,56 +59,91 @@ class PostAction < ActiveRecord::Base
def self.counts_for(collection, user) def self.counts_for(collection, user)
return {} if collection.blank? return {} if collection.blank?
collection_ids = collection.map {|p| p.id} collection_ids = collection.map(&:id)
user_id = user.present? ? user.id : 0 user_id = user.present? ? user.id : 0
result = PostAction.where(post_id: collection_ids, user_id: user_id) post_actions = PostAction.where(post_id: collection_ids, user_id: user_id)
user_actions = {} user_actions = {}
result.each do |r| post_actions.each do |post_action|
user_actions[r.post_id] ||= {} user_actions[post_action.post_id] ||= {}
user_actions[r.post_id][r.post_action_type_id] = r user_actions[post_action.post_id][post_action.post_action_type_id] = post_action
end end
user_actions user_actions
end end
def self.count_per_day_for_type(sinceDaysAgo = 30, post_action_type) def self.count_per_day_for_type(post_action_type, since_days_ago=30)
unscoped.where(post_action_type_id: post_action_type).where('created_at > ?', sinceDaysAgo.days.ago).group('date(created_at)').order('date(created_at)').count unscoped.where(post_action_type_id: post_action_type)
.where('created_at > ?', since_days_ago.days.ago)
.group('date(created_at)')
.order('date(created_at)')
.count
end end
def self.clear_flags!(post, moderator_id, action_type_id = nil) def self.agree_flags!(post, moderator, delete_post=false)
# -1 is the automatic system cleary actions = PostAction.active
actions = if action_type_id .where(post_id: post.id)
[action_type_id] .where(post_action_type_id: PostActionType.flag_types.values)
else
moderator_id == -1 ? PostActionType.auto_action_flag_types.values : PostActionType.flag_types.values
end
PostAction.where({ post_id: post.id, post_action_type_id: actions }).update_all({ deleted_at: Time.zone.now, deleted_by_id: moderator_id }) actions.each do |action|
f = actions.map{|t| ["#{PostActionType.types[t]}_count", 0]} action.agreed_at = Time.zone.now
Post.where(id: post.id).with_deleted.update_all(Hash[*f.flatten]) action.agreed_by_id = moderator.id
update_flagged_posts_count
end
def self.defer_flags!(post, moderator_id)
actions = PostAction.where(
defer: nil,
post_id: post.id,
post_action_type_id: PostActionType.flag_types.values,
deleted_at: nil
)
actions.each do |a|
a.defer = true
a.defer_by = moderator_id
# so callback is called # so callback is called
a.save action.save
action.add_moderator_post_if_needed(moderator, :agreed, delete_post)
end end
update_flagged_posts_count update_flagged_posts_count
end end
def self.clear_flags!(post, moderator)
# -1 is the automatic system cleary
action_type_ids = moderator.id == -1 ?
PostActionType.auto_action_flag_types.values :
PostActionType.flag_types.values
actions = PostAction.where(post_id: post.id)
.where(post_action_type_id: action_type_ids)
actions.each do |action|
action.deleted_at = Time.zone.now
action.deleted_by_id = moderator.id
# so callback is called
action.save
action.add_moderator_post_if_needed(moderator, :disagreed)
end
# reset all cached counters
f = action_type_ids.map { |t| ["#{PostActionType.types[t]}_count", 0] }
Post.with_deleted.where(id: post.id).update_all(Hash[*f.flatten])
update_flagged_posts_count
end
def self.defer_flags!(post, moderator, delete_post=false)
actions = PostAction.active
.where(post_id: post.id)
.where(post_action_type_id: PostActionType.flag_types.values)
actions.each do |action|
action.defered_at = Time.zone.now
action.defered_by_id = moderator.id
# so callback is called
action.save
action.add_moderator_post_if_needed(moderator, :defered, delete_post)
end
update_flagged_posts_count
end
def add_moderator_post_if_needed(moderator, disposition, delete_post=false)
return unless related_post
message_key = "flags_dispositions.#{disposition}"
message_key << "_and_deleted" if delete_post
related_post.topic.add_moderator_post(moderator, I18n.t(message_key))
end
def self.create_message_for_post_action(user, post, post_action_type_id, opts) def self.create_message_for_post_action(user, post, post_action_type_id, opts)
post_action_type = PostActionType.types[post_action_type_id] post_action_type = PostActionType.types[post_action_type_id]
@ -123,10 +176,10 @@ class PostAction < ActiveRecord::Base
end end
def self.act(user, post, post_action_type_id, opts={}) def self.act(user, post, post_action_type_id, opts={})
related_post_id = create_message_for_post_action(user, post, post_action_type_id, opts)
staff_took_action = opts[:take_action] || false
related_post_id = create_message_for_post_action(user,post,post_action_type_id,opts) targets_topic = if opts[:flag_topic] && post.topic
targets_topic = if opts[:flag_topic] and post.topic
post.topic.reload post.topic.reload
post.topic.posts_count != 1 post.topic.posts_count != 1
end end
@ -138,8 +191,7 @@ class PostAction < ActiveRecord::Base
} }
action_attributes = { action_attributes = {
message: opts[:message], staff_took_action: staff_took_action,
staff_took_action: opts[:take_action] || false,
related_post_id: related_post_id, related_post_id: related_post_id,
targets_topic: !!targets_topic targets_topic: !!targets_topic
} }
@ -157,9 +209,13 @@ class PostAction < ActiveRecord::Base
end end
else else
post_action = PostAction.where(where_attrs).first post_action = PostAction.where(where_attrs).first
post_action.update_counters
end end
# agree with other flags
PostAction.agree_flags!(post, user) if staff_took_action
# update counters
post_action.try(:update_counters)
post_action post_action
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
# can happen despite being .create # can happen despite being .create
@ -216,10 +272,11 @@ class PostAction < ActiveRecord::Base
before_create do before_create do
post_action_type_ids = is_flag? ? PostActionType.flag_types.values : post_action_type_id post_action_type_ids = is_flag? ? PostActionType.flag_types.values : post_action_type_id
raise AlreadyActed if PostAction.where(user_id: user_id, raise AlreadyActed if PostAction.where(user_id: user_id)
post_id: post_id, .where(post_id: post_id)
post_action_type_id: post_action_type_ids, .where(post_action_type_id: post_action_type_ids)
deleted_at: nil) .where(deleted_at: nil)
.where(targets_topic: targets_topic)
.exists? .exists?
end end
@ -251,30 +308,30 @@ class PostAction < ActiveRecord::Base
PostActionType.types[post_action_type_id] PostActionType.types[post_action_type_id]
end end
def update_counters def update_counters
# Update denormalized counts # Update denormalized counts
column = "#{post_action_type_key.to_s}_count" column = "#{post_action_type_key.to_s}_count"
delta = deleted_at.nil? ? 1 : -1 count = PostAction.where(post_id: post_id)
.where(post_action_type_id: post_action_type_id)
.count
# We probably want to refactor this method to something cleaner. # We probably want to refactor this method to something cleaner.
case post_action_type_key case post_action_type_key
when :vote when :vote
# Voting also changes the sort_order # Voting also changes the sort_order
Post.where(id: post_id).update_all ["vote_count = vote_count + :delta, sort_order = :max - (vote_count + :delta)", Post.where(id: post_id).update_all ["vote_count = :count, sort_order = :max - :count", count: count, max: Topic.max_sort_order]
delta: delta,
max: Topic.max_sort_order]
when :like when :like
# `like_score` is weighted higher for staff accounts # `like_score` is weighted higher for staff accounts
Post.where(id: post_id).update_all ["like_count = like_count + :delta, like_score = like_score + :score_delta", score = PostAction.joins(:user)
delta: delta, .where(post_id: post_id)
score_delta: user.staff? ? delta * SiteSetting.staff_like_weight : delta] .sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END")
Post.where(id: post_id).update_all ["like_count = :count, like_score = :score", count: count, score: score]
else else
Post.where(id: post_id).update_all ["#{column} = #{column} + ?", delta] Post.where(id: post_id).update_all ["#{column} = ?", count]
end end
post = Post.with_deleted.where(id: post_id).first topic_id = Post.with_deleted.where(id: post_id).pluck(:topic_id).first
Topic.where(id: post.topic_id).update_all ["#{column} = #{column} + ?", delta] Topic.where(id: topic_id).update_all ["#{column} = ?", count]
if PostActionType.notify_flag_type_ids.include?(post_action_type_id) if PostActionType.notify_flag_type_ids.include?(post_action_type_id)
PostAction.update_flagged_posts_count PostAction.update_flagged_posts_count
@ -314,7 +371,6 @@ class PostAction < ActiveRecord::Base
end end
end end
def self.hide_post!(post, post_action_type, reason=nil) def self.hide_post!(post, post_action_type, reason=nil)
return if post.hidden return if post.hidden
@ -324,8 +380,7 @@ class PostAction < ActiveRecord::Base
end end
Post.where(id: post.id).update_all(["hidden = true, hidden_at = CURRENT_TIMESTAMP, hidden_reason_id = COALESCE(hidden_reason_id, ?)", reason]) Post.where(id: post.id).update_all(["hidden = true, hidden_at = CURRENT_TIMESTAMP, hidden_reason_id = COALESCE(hidden_reason_id, ?)", reason])
Topic.where(["id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)", Topic.where(["id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)", topic_id: post.topic_id]).update_all(visible: false)
topic_id: post.topic_id]).update_all(visible: false)
# inform user # inform user
if post.user if post.user
@ -345,7 +400,7 @@ class PostAction < ActiveRecord::Base
end end
def self.post_action_type_for_post(post_id) def self.post_action_type_for_post(post_id)
post_action = PostAction.find_by(defer: nil, post_id: post_id, post_action_type_id: PostActionType.flag_types.values, deleted_at: nil) post_action = PostAction.find_by(defered_at: nil, post_id: post_id, post_action_type_id: PostActionType.flag_types.values, deleted_at: nil)
PostActionType.types[post_action.post_action_type_id] PostActionType.types[post_action.post_action_type_id]
end end
@ -366,15 +421,17 @@ end
# user_id :integer not null # user_id :integer not null
# post_action_type_id :integer not null # post_action_type_id :integer not null
# deleted_at :datetime # deleted_at :datetime
# created_at :datetime # created_at :datetime not null
# updated_at :datetime # updated_at :datetime not null
# deleted_by_id :integer # deleted_by_id :integer
# message :text # message :text
# related_post_id :integer # related_post_id :integer
# staff_took_action :boolean default(FALSE), not null # staff_took_action :boolean default(FALSE), not null
# defer :boolean # defered_at :datetime
# defer_by :integer # defer_by_id :integer
# targets_topic :boolean default(FALSE) # targets_topic :boolean default(FALSE)
# agreed_at :datetime
# agreed_by_id :integer
# #
# Indexes # Indexes
# #

View File

@ -19,6 +19,10 @@ class PostActionType < ActiveRecord::Base
@public_types ||= types.except(*flag_types.keys << :notify_user) @public_types ||= types.except(*flag_types.keys << :notify_user)
end end
def public_type_ids
@public_type_ids ||= public_types.values
end
def flag_types def flag_types
@flag_types ||= types.only(:off_topic, :spam, :inappropriate, :notify_moderators) @flag_types ||= types.only(:off_topic, :spam, :inappropriate, :notify_moderators)
end end

View File

@ -115,8 +115,8 @@ class Report
def self.post_action_report(report, post_action_type) def self.post_action_report(report, post_action_type)
report.data = [] report.data = []
PostAction.count_per_day_for_type(30, post_action_type).each do |date, count| PostAction.count_per_day_for_type(post_action_type).each do |date, count|
report.data << {x: date, y: count} report.data << { x: date, y: count }
end end
query = PostAction.unscoped.where(post_action_type_id: post_action_type) query = PostAction.unscoped.where(post_action_type_id: post_action_type)
report.total = query.count report.total = query.count

View File

@ -477,7 +477,6 @@ class Topic < ActiveRecord::Base
topic_id: self.id) topic_id: self.id)
new_post = creator.create new_post = creator.create
increment!(:moderator_posts_count) increment!(:moderator_posts_count)
new_post
end end
if new_post.present? if new_post.present?

View File

@ -182,9 +182,12 @@ class User < ActiveRecord::Base
end end
def created_topic_count def created_topic_count
topics.count stat = user_stat || create_user_stat
stat.topic_count
end end
alias_method :topic_count, :created_topic_count
# tricky, we need our bus to be subscribed from the right spot # tricky, we need our bus to be subscribed from the right spot
def sync_notification_channel_position def sync_notification_channel_position
@unread_notifications_by_type = nil @unread_notifications_by_type = nil
@ -370,11 +373,8 @@ class User < ActiveRecord::Base
end end
def post_count def post_count
posts.count stat = user_stat || create_user_stat
end stat.post_count
def first_post
posts.order('created_at ASC').first
end end
def flags_given_count def flags_given_count
@ -607,6 +607,10 @@ class User < ActiveRecord::Base
end end
end end
def first_post_created_at
user_stat.try(:first_post_created_at)
end
protected protected
def badge_grant def badge_grant

View File

@ -0,0 +1,10 @@
class FlaggedTopicSerializer < ActiveModel::Serializer
attributes :id,
:title,
:slug,
:archived,
:closed,
:visible,
:archetype,
:relative_url
end

View File

@ -0,0 +1,21 @@
class FlaggedUserSerializer < BasicUserSerializer
attributes :can_delete_all_posts,
:can_be_deleted,
:post_count,
:topic_count,
:email,
:ip_address
def can_delete_all_posts
scope.can_delete_all_posts?(object)
end
def can_be_deleted
scope.can_delete_user?(object)
end
def ip_address
object.ip_address.try(:to_s)
end
end

View File

@ -164,7 +164,7 @@ class PostSerializer < BasicPostSerializer
# The following only applies if you're logged in # The following only applies if you're logged in
if action_summary[:can_act] && scope.current_user.present? if action_summary[:can_act] && scope.current_user.present?
action_summary[:can_clear_flags] = scope.is_staff? && PostActionType.flag_types.values.include?(id) action_summary[:can_defer_flags] = scope.is_staff? && PostActionType.flag_types.values.include?(id)
end end
if post_actions.present? && post_actions.has_key?(id) if post_actions.present? && post_actions.has_key?(id)

View File

@ -173,7 +173,7 @@ class TopicViewSerializer < ApplicationSerializer
count: 0, count: 0,
hidden: false, hidden: false,
can_act: scope.post_can_act?(post, sym)} can_act: scope.post_can_act?(post, sym)}
# TODO: other keys? :can_clear_flags, :acted, :can_undo # TODO: other keys? :can_defer_flags, :acted, :can_undo
end end
result result
end end

View File

@ -1044,9 +1044,9 @@ en:
actions: actions:
flag: 'Flag' flag: 'Flag'
clear_flags: defer_flags:
one: "Clear flag" one: "Defer flag"
other: "Clear flags" other: "Defer flags"
it_too: it_too:
off_topic: "Flag it too" off_topic: "Flag it too"
spam: "Flag it too" spam: "Flag it too"
@ -1411,25 +1411,37 @@ en:
old: "Old" old: "Old"
active: "Active" active: "Active"
agree_hide: "Agree (hide post + send PM)" agree_flag_hide_post: "Agree (hide post + send PM)"
agree_hide_title: "Hide this post and automatically send the user a private message urging them to edit it" agree_flag_hide_post_title: "Hide this post and automatically send the user a private message urging them to edit it"
defer: "Defer" defer_flag: "Defer"
defer_title: "No action is necessary at this time, defer any action on this flag until a later date, or never" defer_flag_title: "No action is necessary at this time, defer any action on this flag until a later date, or never"
delete_post: "Delete Post" delete: "Delete"
delete_post_title: "Delete post; if the first post, delete the topic" delete_title: "Delete"
disagree_unhide: "Disagree (unhide post)" delete_post_defer_flag: "Delete Post and defer flag"
disagree_unhide_title: "Remove any flags from this post and make the post visible again" delete_post_defer_flag_title: "Delete post; if the first post, delete the topic"
disagree: "Disagree" delete_post_agree_flag: "Delete Post and agree with flag"
disagree_title: "Disagree with flag, remove any flags from this post" delete_post_agree_flag_title: "Delete post; if the first post, delete the topic"
delete_flag_modal_title: "Choose the delete action"
delete_spammer: "Delete Spammer"
delete_spammer_title: "Delete the user and all its posts and topics." delete_spammer_title: "Delete the user and all its posts and topics."
disagree_flag_unhide_post: "Disagree (unhide post)"
disagree_flag_unhide_post_title: "Remove any flags from this post and make the post visible again"
disagree_flag: "Disagree"
disagree_flag_title: "Disagree with flag, remove any flags from this post"
clear_topic_flags: "Done" clear_topic_flags: "Done"
clear_topic_flags_title: "The topic has been investigated and issues have been resolved. Click Done to remove the flags." clear_topic_flags_title: "The topic has been investigated and issues have been resolved. Click Done to remove the flags."
more: "(more...)"
dispositions:
agreed: "agreed"
disagreed: "disagreed"
defered: "defered"
flagged_by: "Flagged by" flagged_by: "Flagged by"
resolved_by: "Resolved by" resolved_by: "Resolved by"
system: "System" system: "System"
error: "Something went wrong" error: "Something went wrong"
view_message: "Reply" reply_message: "Reply"
no_results: "There are no flags." no_results: "There are no flags."
topic_flagged: "This <strong>topic</strong> has been flagged." topic_flagged: "This <strong>topic</strong> has been flagged."
visit_topic: "Visit the topic to take action" visit_topic: "Visit the topic to take action"

View File

@ -1211,6 +1211,13 @@ en:
spam: "Your post was flagged as **spam**: the community thinks it is an advertisement, not useful or relevant to the topic, but promotional in nature." spam: "Your post was flagged as **spam**: the community thinks it is an advertisement, not useful or relevant to the topic, but promotional in nature."
notify_moderators: "Your post was flagged **for moderator attention**: the community thinks something about the post requires moderator intervention." notify_moderators: "Your post was flagged **for moderator attention**: the community thinks something about the post requires moderator intervention."
flags_dispositions:
agreed: "Thanks for your reporting this post. We've agreed with your flag."
agreed_and_deleted: "Thanks for your reporting this post. We've agreed with your flag and deleted the post."
disagreed: "Thanks for your reporting this post. Unfortunately, we've agreed with your flag."
defered: "Thanks for your reporting this post. We're looking into handling this post."
defered_and_deleted: "Thanks for your reporting this post. We've agreed with your flag and deleted the post."
system_messages: system_messages:
post_hidden: post_hidden:
subject_template: "Post hidden due to community flagging" subject_template: "Post hidden due to community flagging"

View File

@ -273,7 +273,7 @@ Discourse::Application.routes.draw do
resources :post_actions do resources :post_actions do
collection do collection do
get "users" get "users"
post "clear_flags" post "defer_flags"
end end
end end
resources :user_actions resources :user_actions

View File

@ -0,0 +1,6 @@
class AddAgreedAtAndAgreedByIdToPostAction < ActiveRecord::Migration
def change
add_column :post_actions, :agreed_at, :datetime
add_column :post_actions, :agreed_by_id, :integer
end
end

View File

@ -0,0 +1,17 @@
class RenameDeferColumnsOnPostAction < ActiveRecord::Migration
def up
rename_column :post_actions, :defer_by, :defered_by_id
add_column :post_actions, :defered_at, :datetime
execute "UPDATE post_actions SET defered_at = updated_at WHERE defer = 't'"
remove_column :post_actions, :defer
end
def down
rename_column :post_actions, :defered_by_id, :defer_by
add_column :post_actions, :defer, :boolean
execute "UPDATE post_actions SET defer = 't' WHERE defered_at IS NOT NULL"
remove_column :post_actions, :defered_at
end
end

View File

@ -0,0 +1,16 @@
class RemoveMessageFromPostAction < ActiveRecord::Migration
def up
remove_column :post_actions, :message
end
def down
add_column :post_actions, :message, :text
execute "UPDATE post_actions
SET message = p.raw
FROM post_actions pa
LEFT JOIN posts p ON p.id = pa.related_post_id
WHERE post_actions.id = pa.id
AND pa.related_post_id IS NOT NULL;"
end
end

View File

@ -0,0 +1,6 @@
class FixIndexOnPostAction < ActiveRecord::Migration
def change
remove_index "post_actions", name: "idx_unique_actions"
add_index "post_actions", ["user_id", "post_action_type_id", "post_id", "deleted_at", "targets_topic"], name: "idx_unique_actions", unique: true
end
end

View File

@ -0,0 +1,24 @@
class AddFirstPostCreatedAtToUserStat < ActiveRecord::Migration
def up
add_column :user_stats, :first_post_created_at, :datetime
execute <<-SQL
WITH first_posts AS (
SELECT p.id,
p.user_id,
p.created_at,
ROW_NUMBER() OVER (PARTITION BY p.user_id ORDER BY p.created_at ASC) AS row
FROM posts p
)
UPDATE user_stats us
SET first_post_created_at = fp.created_at
FROM first_posts fp
WHERE fp.row = 1
AND fp.user_id = us.user_id
SQL
end
def down
remove_column :user_stats, :first_post_created_at
end
end

View File

@ -0,0 +1,25 @@
class AddPostAndTopicCountsToUserStat < ActiveRecord::Migration
def up
add_column :user_stats, :post_count, :integer, default: 0, null: false
add_column :user_stats, :topic_count, :integer, default: 0, null: false
execute <<-SQL
UPDATE user_stats
SET post_count = pc.count
FROM (SELECT user_id, COUNT(*) AS count FROM posts GROUP BY user_id) AS pc
WHERE pc.user_id = user_stats.user_id
SQL
execute <<-SQL
UPDATE user_stats
SET topic_count = tc.count
FROM (SELECT user_id, COUNT(*) AS count FROM topics GROUP BY user_id) AS tc
WHERE tc.user_id = user_stats.user_id
SQL
end
def down
remove_column :user_stats, :post_count
remove_column :user_stats, :topic_count
end
end

View File

@ -1,105 +1,139 @@
module FlagQuery module FlagQuery
def self.flagged_posts_report(current_user, filter, offset = 0, per_page = 25)
def self.flagged_posts_report(current_user, filter, offset=0, per_page=25)
actions = flagged_post_actions(filter) actions = flagged_post_actions(filter)
guardian = Guardian.new(current_user) guardian = Guardian.new(current_user)
if !guardian.is_admin? if !guardian.is_admin?
actions = actions.joins(:post => :topic) actions = actions.where('category_id in (?)', guardian.allowed_category_ids)
.where('category_id in (?)', guardian.allowed_category_ids)
end end
post_ids = actions post_ids = actions.limit(per_page)
.limit(per_page)
.offset(offset) .offset(offset)
.group(:post_id) .group(:post_id)
.order('min(post_actions.created_at) DESC') .order('min(post_actions.created_at) DESC')
.pluck(:post_id).uniq .pluck(:post_id)
.uniq
return nil if post_ids.blank? return nil if post_ids.blank?
actions = actions posts = SqlBuilder.new("
.order('post_actions.created_at DESC') SELECT p.id,
.includes({:related_post => :topic}) p.cooked,
p.user_id,
posts = SqlBuilder.new("SELECT p.id, t.title, p.cooked, p.user_id, p.topic_id,
p.topic_id, p.post_number, p.hidden, t.visible topic_visible, p.post_number,
p.deleted_at, t.deleted_at topic_deleted_at p.hidden,
p.deleted_at
FROM posts p FROM posts p
JOIN topics t ON t.id = p.topic_id
WHERE p.id in (:post_ids)").map_exec(OpenStruct, post_ids: post_ids) WHERE p.id in (:post_ids)").map_exec(OpenStruct, post_ids: post_ids)
post_lookup = {} post_lookup = {}
users = Set.new user_ids = Set.new
topic_ids = Set.new
posts.each do |p| posts.each do |p|
users << p.user_id user_ids << p.user_id
topic_ids << p.topic_id
p.excerpt = Post.excerpt(p.cooked) p.excerpt = Post.excerpt(p.cooked)
p.topic_slug = Slug.for(p.title) p.delete_field(:cooked)
post_lookup[p.id] = p post_lookup[p.id] = p
end end
# maintain order post_actions = actions.order('post_actions.created_at DESC')
posts = post_ids.map{|id| post_lookup[id]} .includes(related_post: { topic: { posts: :user }})
.where(post_id: post_ids)
post_actions = actions.where(:post_id => post_ids)
post_actions.each do |pa| post_actions.each do |pa|
post = post_lookup[pa.post_id] post = post_lookup[pa.post_id]
post.post_actions ||= [] post.post_actions ||= []
action = pa.attributes # TODO: add serializer so we can skip this
action = {
id: pa.id,
post_id: pa.post_id,
user_id: pa.user_id,
post_action_type_id: pa.post_action_type_id,
created_at: pa.created_at,
disposed_by_id: pa.disposed_by_id,
disposed_at: pa.disposed_at,
disposition: pa.disposition,
related_post_id: pa.related_post_id,
targets_topic: pa.targets_topic,
staff_took_action: pa.staff_took_action
}
action[:name_key] = PostActionType.types.key(pa.post_action_type_id) action[:name_key] = PostActionType.types.key(pa.post_action_type_id)
if (pa.related_post && pa.related_post.topic)
action.merge!(topic_id: pa.related_post.topic_id, if pa.related_post && pa.related_post.topic
slug: pa.related_post.topic.slug, conversation = {}
permalink: pa.related_post.topic.url) related_topic = pa.related_post.topic
if response = related_topic.posts[0]
conversation[:response] = {
excerpt: excerpt(response.cooked),
user_id: response.user_id
}
user_ids << response.user_id
if reply = related_topic.posts[1]
conversation[:reply] = {
excerpt: excerpt(reply.cooked),
user_id: reply.user_id
}
user_ids << reply.user_id
conversation[:has_more] = related_topic.posts_count > 2
end end
post.post_actions << action
users << pa.user_id
users << pa.deleted_by_id if pa.deleted_by_id
end end
# TODO add serializer so we can skip this action.merge!(permalink: related_topic.relative_url, conversation: conversation)
end
post.post_actions << action
user_ids << pa.user_id
user_ids << pa.disposed_by_id if pa.disposed_by_id
end
# maintain order
posts = post_ids.map { |id| post_lookup[id] }
# TODO: add serializer so we can skip this
posts.map!(&:marshal_dump) posts.map!(&:marshal_dump)
[posts, User.where(id: users.to_a).to_a]
[
posts,
Topic.with_deleted.where(id: topic_ids.to_a).to_a,
User.includes(:user_stat).where(id: user_ids.to_a).to_a
]
end end
protected protected
def self.flagged_post_ids(filter, offset, limit)
<<SQL
SELECT p.id from posts p
JOIN topics t ON t.id = p.topic_id
WHERE p.id IN (
SELECT post_id from post_actions
WHERE
)
/*offset*/
/*limit*/
SQL
end
def self.flagged_post_actions(filter) def self.flagged_post_actions(filter)
post_actions = PostAction post_actions = PostAction.flags
.where(post_action_type_id: PostActionType.notify_flag_type_ids) .joins("INNER JOIN posts ON posts.id = post_actions.post_id")
.joins(:post => :topic) .joins("INNER JOIN topics ON topics.id = posts.topic_id")
if filter == 'old' if filter == "old"
post_actions post_actions.with_deleted
.with_deleted .where("post_actions.deleted_at IS NOT NULL OR
.where('post_actions.deleted_at IS NOT NULL OR post_actions.defered_at IS NOT NULL OR
defer = true OR post_actions.agreed_at IS NOT NULL")
topics.deleted_at IS NOT NULL OR
posts.deleted_at IS NOT NULL')
else else
post_actions post_actions.active
.where('defer IS NULL OR .where("posts.deleted_at" => nil)
defer = false') .where("topics.deleted_at" => nil)
.where('posts.deleted_at IS NULL AND
topics.deleted_at IS NULL')
end end
end end
private
def self.excerpt(cooked)
excerpt = Post.excerpt(cooked, 200)
# remove the first link if it's the first node
fragment = Nokogiri::HTML.fragment(excerpt)
if fragment.children.first == fragment.css("a:first").first
fragment.children.first.remove
end
fragment.to_html.strip
end
end end

View File

@ -29,7 +29,7 @@ module PostGuardian
end end
end end
def can_clear_flags?(post) def can_defer_flags?(post)
is_staff? && post is_staff? && post
end end
@ -54,7 +54,11 @@ module PostGuardian
end end
def can_delete_all_posts?(user) def can_delete_all_posts?(user)
is_staff? && user && !user.admin? && (user.first_post.nil? || user.first_post.created_at >= SiteSetting.delete_user_max_post_age.days.ago) && user.post_count <= SiteSetting.delete_all_posts_max.to_i is_staff? &&
user &&
!user.admin? &&
(user.first_post_created_at.nil? || user.first_post_created_at >= SiteSetting.delete_user_max_post_age.days.ago) &&
user.post_count <= SiteSetting.delete_all_posts_max.to_i
end end
# Creating Method # Creating Method

View File

@ -35,12 +35,11 @@ module UserGuardian
end end
def can_delete_user?(user) def can_delete_user?(user)
return false if user.nil? return false if user.nil? || user.admin?
return false if user.admin?
if is_me?(user) if is_me?(user)
user.post_count <= 1 user.post_count <= 1
else else
is_staff? && (user.first_post.nil? || user.first_post.created_at > SiteSetting.delete_user_max_post_age.to_i.days.ago) is_staff? && (user.first_post_created_at.nil? || user.first_post_created_at > SiteSetting.delete_user_max_post_age.to_i.days.ago)
end end
end end

View File

@ -61,7 +61,6 @@ class PostCreator
save_post save_post
extract_links extract_links
store_unique_post_key store_unique_post_key
consider_clearing_flags
track_topic track_topic
update_topic_stats update_topic_stats
update_user_counts update_user_counts
@ -147,21 +146,6 @@ class PostCreator
end end
end end
def clear_possible_flags(topic)
# at this point we know the topic is a PM and has been replied to ... check if we need to clear any flags
#
first_post = Post.select(:id).where(topic_id: topic.id).find_by("post_number = 1")
post_action = nil
if first_post
post_action = PostAction.find_by(related_post_id: first_post.id, deleted_at: nil, post_action_type_id: PostActionType.types[:notify_moderators])
end
if post_action
post_action.remove_act!(@user)
end
end
private private
def setup_topic def setup_topic
@ -233,20 +217,23 @@ class PostCreator
@post.store_unique_post_key @post.store_unique_post_key
end end
def consider_clearing_flags def update_user_counts
return if @opts[:import_mode] @user.create_user_stat if @user.user_stat.nil?
return unless @topic.private_message? && @post.post_number > 1 && @topic.user_id != @post.user_id
clear_possible_flags(@topic) if @user.user_stat.first_post_created_at.nil?
@user.user_stat.first_post_created_at = @post.created_at
end end
def update_user_counts @user.user_stat.post_count += 1
@user.user_stat.topic_count += 1 if @post.post_number == 1
# We don't count replies to your own topics # We don't count replies to your own topics
if !@opts[:import_mode] && @user.id != @topic.user_id if !@opts[:import_mode] && @user.id != @topic.user_id
@user.user_stat.update_topic_reply_count @user.user_stat.update_topic_reply_count
@user.user_stat.save!
end end
@user.user_stat.save!
@user.last_posted_at = @post.created_at @user.last_posted_at = @post.created_at
@user.save! @user.save!
end end

View File

@ -62,7 +62,8 @@ class PostDestroyer
feature_users_in_the_topic feature_users_in_the_topic
Topic.reset_highest(@post.topic_id) Topic.reset_highest(@post.topic_id)
end end
trash_post_actions trash_public_post_actions
agree_with_flags
trash_user_actions trash_user_actions
@post.update_flagged_posts_count @post.update_flagged_posts_count
remove_associated_replies remove_associated_replies
@ -130,13 +131,16 @@ class PostDestroyer
Jobs.enqueue(:feature_topic_users, topic_id: @post.topic_id, except_post_id: @post.id) Jobs.enqueue(:feature_topic_users, topic_id: @post.topic_id, except_post_id: @post.id)
end end
def trash_post_actions def trash_public_post_actions
@post.post_actions.each do |pa| public_post_actions = PostAction.publics.where(post_id: @post.id)
pa.trash!(@user) public_post_actions.each { |pa| pa.trash!(@user) }
f = PostActionType.public_types.map { |k,v| ["#{k}_count", 0] }
Post.with_deleted.where(id: @post.id).update_all(Hash[*f.flatten])
end end
f = PostActionType.types.map{|k,v| ["#{k}_count", 0]} def agree_with_flags
Post.with_deleted.where(id: @post.id).update_all(Hash[*f.flatten]) PostAction.agree_flags!(@post, @user, delete_post: true)
end end
def trash_user_actions def trash_user_actions

View File

@ -29,9 +29,7 @@ class PostJobsEnqueuer
end end
def after_post_create def after_post_create
if @post.post_number > 1 TopicTrackingState.publish_unread(@post) if @post.post_number > 1
TopicTrackingState.publish_unread(@post)
end
Jobs.enqueue_in( Jobs.enqueue_in(
SiteSetting.email_time_window_mins.minutes, SiteSetting.email_time_window_mins.minutes,

View File

@ -105,7 +105,7 @@ class PostRevisor
@post.hidden_at = nil @post.hidden_at = nil
@post.topic.update_attributes(visible: true) @post.topic.update_attributes(visible: true)
PostAction.clear_flags!(@post, -1) PostAction.clear_flags!(@post, Discourse.system_user)
end end
@post.extract_quoted_post_numbers @post.extract_quoted_post_numbers

View File

@ -23,17 +23,19 @@ describe FlagQuery do
PostAction.act(codinghorror, post2, PostActionType.types[:spam]) PostAction.act(codinghorror, post2, PostActionType.types[:spam])
PostAction.act(user2, post2, PostActionType.types[:spam]) PostAction.act(user2, post2, PostActionType.types[:spam])
posts, users = FlagQuery.flagged_posts_report(admin, "") posts, topics, users = FlagQuery.flagged_posts_report(admin, "")
posts.count.should == 2 posts.count.should == 2
first = posts.first first = posts.first
users.count.should == 5 users.count.should == 5
first[:post_actions].count.should == 2 first[:post_actions].count.should == 2
topics.count.should == 2
second = posts[1] second = posts[1]
second[:post_actions].count.should == 3 second[:post_actions].count.should == 3
second[:post_actions].first[:permalink].should == mod_message.related_post.topic.url second[:post_actions].first[:permalink].should == mod_message.related_post.topic.relative_url
posts, users = FlagQuery.flagged_posts_report(admin, "", 1) posts, users = FlagQuery.flagged_posts_report(admin, "", 1)
posts.count.should == 1 posts.count.should == 1

View File

@ -81,25 +81,25 @@ describe Guardian do
end end
describe "can_clear_flags" do describe "can_defer_flags" do
let(:post) { Fabricate(:post) } let(:post) { Fabricate(:post) }
let(:user) { post.user } let(:user) { post.user }
let(:moderator) { Fabricate(:moderator) } let(:moderator) { Fabricate(:moderator) }
it "returns false when the user is nil" do it "returns false when the user is nil" do
Guardian.new(nil).can_clear_flags?(post).should be_false Guardian.new(nil).can_defer_flags?(post).should be_false
end end
it "returns false when the post is nil" do it "returns false when the post is nil" do
Guardian.new(moderator).can_clear_flags?(nil).should be_false Guardian.new(moderator).can_defer_flags?(nil).should be_false
end end
it "returns false when the user is not a moderator" do it "returns false when the user is not a moderator" do
Guardian.new(user).can_clear_flags?(post).should be_false Guardian.new(user).can_defer_flags?(post).should be_false
end end
it "returns true when the user is a moderator" do it "returns true when the user is a moderator" do
Guardian.new(moderator).can_clear_flags?(post).should be_true Guardian.new(moderator).can_defer_flags?(post).should be_true
end end
end end
@ -1350,7 +1350,7 @@ describe Guardian do
end end
context "delete myself" do context "delete myself" do
let(:myself) { Fabricate.build(:user, created_at: 6.months.ago) } let(:myself) { Fabricate(:user, created_at: 6.months.ago) }
subject { Guardian.new(myself).can_delete_user?(myself) } subject { Guardian.new(myself).can_delete_user?(myself) }
it "is true to delete myself and I have never made a post" do it "is true to delete myself and I have never made a post" do
@ -1375,7 +1375,7 @@ describe Guardian do
it "is true if user is not an admin and first post is not too old" do it "is true if user is not an admin and first post is not too old" do
user = Fabricate.build(:user, created_at: 100.days.ago) user = Fabricate.build(:user, created_at: 100.days.ago)
user.stubs(:first_post).returns(Fabricate.build(:post, created_at: 9.days.ago)) user.stubs(:first_post_created_at).returns(9.days.ago)
SiteSetting.stubs(:delete_user_max_post_age).returns(10) SiteSetting.stubs(:delete_user_max_post_age).returns(10)
Guardian.new(actor).can_delete_user?(user).should == true Guardian.new(actor).can_delete_user?(user).should == true
end end
@ -1386,7 +1386,7 @@ describe Guardian do
it "is false if user's first post is too old" do it "is false if user's first post is too old" do
user = Fabricate.build(:user, created_at: 100.days.ago) user = Fabricate.build(:user, created_at: 100.days.ago)
user.stubs(:first_post).returns(Fabricate.build(:post, created_at: 11.days.ago)) user.stubs(:first_post_created_at).returns(11.days.ago)
SiteSetting.stubs(:delete_user_max_post_age).returns(10) SiteSetting.stubs(:delete_user_max_post_age).returns(10)
Guardian.new(actor).can_delete_user?(user).should == false Guardian.new(actor).can_delete_user?(user).should == false
end end
@ -1419,19 +1419,19 @@ describe Guardian do
shared_examples "can_delete_all_posts examples" do shared_examples "can_delete_all_posts examples" do
it "is true if user has no posts" do it "is true if user has no posts" do
SiteSetting.stubs(:delete_user_max_post_age).returns(10) SiteSetting.stubs(:delete_user_max_post_age).returns(10)
Guardian.new(actor).can_delete_all_posts?(Fabricate.build(:user, created_at: 100.days.ago)).should be_true Guardian.new(actor).can_delete_all_posts?(Fabricate(:user, created_at: 100.days.ago)).should be_true
end end
it "is true if user's first post is newer than delete_user_max_post_age days old" do it "is true if user's first post is newer than delete_user_max_post_age days old" do
user = Fabricate.build(:user, created_at: 100.days.ago) user = Fabricate(:user, created_at: 100.days.ago)
user.stubs(:first_post).returns(Fabricate.build(:post, created_at: 9.days.ago)) user.stubs(:first_post_created_at).returns(9.days.ago)
SiteSetting.stubs(:delete_user_max_post_age).returns(10) SiteSetting.stubs(:delete_user_max_post_age).returns(10)
Guardian.new(actor).can_delete_all_posts?(user).should be_true Guardian.new(actor).can_delete_all_posts?(user).should be_true
end end
it "is false if user's first post is older than delete_user_max_post_age days old" do it "is false if user's first post is older than delete_user_max_post_age days old" do
user = Fabricate.build(:user, created_at: 100.days.ago) user = Fabricate(:user, created_at: 100.days.ago)
user.stubs(:first_post).returns(Fabricate.build(:post, created_at: 11.days.ago)) user.stubs(:first_post_created_at).returns(11.days.ago)
SiteSetting.stubs(:delete_user_max_post_age).returns(10) SiteSetting.stubs(:delete_user_max_post_age).returns(10)
Guardian.new(actor).can_delete_all_posts?(user).should be_false Guardian.new(actor).can_delete_all_posts?(user).should be_false
end end
@ -1441,14 +1441,14 @@ describe Guardian do
end end
it "is true if number of posts is small" do it "is true if number of posts is small" do
u = Fabricate.build(:user, created_at: 1.day.ago) u = Fabricate(:user, created_at: 1.day.ago)
u.stubs(:post_count).returns(1) u.stubs(:post_count).returns(1)
SiteSetting.stubs(:delete_all_posts_max).returns(10) SiteSetting.stubs(:delete_all_posts_max).returns(10)
Guardian.new(actor).can_delete_all_posts?(u).should be_true Guardian.new(actor).can_delete_all_posts?(u).should be_true
end end
it "is false if number of posts is not small" do it "is false if number of posts is not small" do
u = Fabricate.build(:user, created_at: 1.day.ago) u = Fabricate(:user, created_at: 1.day.ago)
u.stubs(:post_count).returns(11) u.stubs(:post_count).returns(11)
SiteSetting.stubs(:delete_all_posts_max).returns(10) SiteSetting.stubs(:delete_all_posts_max).returns(10)
Guardian.new(actor).can_delete_all_posts?(u).should be_false Guardian.new(actor).can_delete_all_posts?(u).should be_false
@ -1528,7 +1528,7 @@ describe Guardian do
end end
context 'for a new user' do context 'for a new user' do
let(:target_user) { build(:user, created_at: 1.minute.ago) } let(:target_user) { Fabricate(:user, created_at: 1.minute.ago) }
include_examples "staff can always change usernames" include_examples "staff can always change usernames"
it "is true for the user to change their own username" do it "is true for the user to change their own username" do
@ -1541,7 +1541,7 @@ describe Guardian do
SiteSetting.stubs(:username_change_period).returns(3) SiteSetting.stubs(:username_change_period).returns(3)
end end
let(:target_user) { build(:user, created_at: 4.days.ago) } let(:target_user) { Fabricate(:user, created_at: 4.days.ago) }
context 'with no posts' do context 'with no posts' do
include_examples "staff can always change usernames" include_examples "staff can always change usernames"

View File

@ -263,28 +263,24 @@ describe PostDestroyer do
end end
describe "post actions" do describe "post actions" do
let(:codinghorror) { Fabricate(:coding_horror) }
let(:bookmark) { PostAction.new(user_id: post.user_id, post_action_type_id: PostActionType.types[:bookmark] , post_id: post.id) }
let(:second_post) { Fabricate(:post, topic_id: post.topic_id) } let(:second_post) { Fabricate(:post, topic_id: post.topic_id) }
let!(:bookmark) { PostAction.act(moderator, second_post, PostActionType.types[:bookmark]) }
let!(:flag) { PostAction.act(moderator, second_post, PostActionType.types[:off_topic]) }
it "should reset counts when a post is deleted" do it "should delete public post actions and agree with flags" do
PostAction.act(codinghorror, second_post, PostActionType.types[:off_topic]) second_post.expects(:update_flagged_posts_count)
expect { PostDestroyer.new(moderator, second_post).destroy }.to change(PostAction, :flagged_posts_count).by(-1)
end
it "should delete the post actions" do
flag = PostAction.act(codinghorror, second_post, PostActionType.types[:off_topic])
PostDestroyer.new(moderator, second_post).destroy PostDestroyer.new(moderator, second_post).destroy
expect(PostAction.find_by(id: flag.id)).to be_nil
expect(PostAction.find_by(id: bookmark.id)).to be_nil
end
it 'should update flag counts on the post' do PostAction.find_by(id: bookmark.id).should == nil
PostAction.act(codinghorror, second_post, PostActionType.types[:off_topic])
PostDestroyer.new(moderator, second_post.reload).destroy off_topic = PostAction.find_by(id: flag.id)
off_topic.should_not == nil
off_topic.agreed_at.should_not == nil
second_post.reload second_post.reload
expect(second_post.off_topic_count).to eq(0) second_post.bookmark_count.should == 0
expect(second_post.bookmark_count).to eq(0) second_post.off_topic_count.should == 1
end end
end end

View File

@ -307,19 +307,28 @@ describe Admin::UsersController do
response.should be_forbidden response.should be_forbidden
end end
it "returns an error if the user has posts" do context "user has post" do
Fabricate(:post, user: @delete_me)
before do
@user = build(:user)
@user.stubs(:post_count).returns(1)
@user.stubs(:first_post_created_at).returns(Time.zone.now)
User.expects(:find_by).with(id: @delete_me.id).returns(@user)
end
it "returns an error" do
xhr :delete, :destroy, id: @delete_me.id xhr :delete, :destroy, id: @delete_me.id
response.should be_forbidden response.should be_forbidden
end end
it "doesn't return an error if the user has posts and delete_posts == true" do it "doesn't return an error if delete_posts == true" do
Fabricate(:post, user: @delete_me) UserDestroyer.any_instance.expects(:destroy).with(@user, has_entry('delete_posts' => true)).returns(true)
UserDestroyer.any_instance.expects(:destroy).with(@delete_me, has_entry('delete_posts' => true)).returns(true)
xhr :delete, :destroy, id: @delete_me.id, delete_posts: true xhr :delete, :destroy, id: @delete_me.id, delete_posts: true
response.should be_success response.should be_success
end end
end
it "deletes the user record" do it "deletes the user record" do
UserDestroyer.any_instance.expects(:destroy).returns(true) UserDestroyer.any_instance.expects(:destroy).returns(true)
xhr :delete, :destroy, id: @delete_me.id xhr :delete, :destroy, id: @delete_me.id

View File

@ -102,13 +102,13 @@ describe PostActionsController do
end end
context 'clear_flags' do context 'defer_flags' do
let(:flagged_post) { Fabricate(:post, user: Fabricate(:coding_horror)) } let(:flagged_post) { Fabricate(:post, user: Fabricate(:coding_horror)) }
context "not logged in" do context "not logged in" do
it "should not allow them to clear flags" do it "should not allow them to clear flags" do
lambda { xhr :post, :clear_flags }.should raise_error(Discourse::NotLoggedIn) lambda { xhr :post, :defer_flags }.should raise_error(Discourse::NotLoggedIn)
end end
end end
@ -116,43 +116,38 @@ describe PostActionsController do
let!(:user) { log_in(:moderator) } let!(:user) { log_in(:moderator) }
it "raises an error without a post_action_type_id" do it "raises an error without a post_action_type_id" do
-> { xhr :post, :clear_flags, id: flagged_post.id }.should raise_error(ActionController::ParameterMissing) -> { xhr :post, :defer_flags, id: flagged_post.id }.should raise_error(ActionController::ParameterMissing)
end end
it "raises an error when the user doesn't have access" do it "raises an error when the user doesn't have access" do
Guardian.any_instance.expects(:can_clear_flags?).returns(false) Guardian.any_instance.expects(:can_defer_flags?).returns(false)
xhr :post, :clear_flags, id: flagged_post.id, post_action_type_id: PostActionType.types[:spam] xhr :post, :defer_flags, id: flagged_post.id, post_action_type_id: PostActionType.types[:spam]
response.should be_forbidden response.should be_forbidden
end end
context "success" do context "success" do
before do before do
Guardian.any_instance.expects(:can_clear_flags?).returns(true) Guardian.any_instance.expects(:can_defer_flags?).returns(true)
PostAction.expects(:clear_flags!).with(flagged_post, user.id, PostActionType.types[:spam]) PostAction.expects(:defer_flags!).with(flagged_post, user)
end end
it "delegates to clear_flags" do it "delegates to defer_flags" do
xhr :post, :clear_flags, id: flagged_post.id, post_action_type_id: PostActionType.types[:spam] xhr :post, :defer_flags, id: flagged_post.id, post_action_type_id: PostActionType.types[:spam]
response.should be_success response.should be_success
end end
it "works with a deleted post" do it "works with a deleted post" do
flagged_post.trash!(user) flagged_post.trash!(user)
xhr :post, :clear_flags, id: flagged_post.id, post_action_type_id: PostActionType.types[:spam] xhr :post, :defer_flags, id: flagged_post.id, post_action_type_id: PostActionType.types[:spam]
response.should be_success response.should be_success
end end
end end
end end
end end
describe 'users' do describe 'users' do
let!(:post) { Fabricate(:post, user: log_in) } let!(:post) { Fabricate(:post, user: log_in) }
@ -188,6 +183,4 @@ describe PostActionsController do
end end
end end

View File

@ -12,7 +12,6 @@ describe PostAction do
let(:post) { Fabricate(:post) } let(:post) { Fabricate(:post) }
let(:bookmark) { PostAction.new(user_id: post.user_id, post_action_type_id: PostActionType.types[:bookmark] , post_id: post.id) } let(:bookmark) { PostAction.new(user_id: post.user_id, post_action_type_id: PostActionType.types[:bookmark] , post_id: post.id) }
describe "messaging" do describe "messaging" do
it "notify moderators integration test" do it "notify moderators integration test" do
@ -41,13 +40,12 @@ describe PostAction do
# Notification level should be "Watching" for everyone # Notification level should be "Watching" for everyone
topic.topic_users(true).map(&:notification_level).uniq.should == [TopicUser.notification_levels[:watching]] topic.topic_users(true).map(&:notification_level).uniq.should == [TopicUser.notification_levels[:watching]]
# reply to PM should clear flag # reply to PM should not clear flag
p = PostCreator.new(mod, topic_id: posts[0].topic_id, raw: "This is my test reply to the user, it should clear flags") p = PostCreator.new(mod, topic_id: posts[0].topic_id, raw: "This is my test reply to the user, it should clear flags")
p.create p.create
action.reload action.reload
action.deleted_at.should_not be_nil action.deleted_at.should be_nil
end end
describe 'notify_moderators' do describe 'notify_moderators' do
@ -87,7 +85,7 @@ describe PostAction do
PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) PostAction.act(codinghorror, post, PostActionType.types[:off_topic])
PostAction.flagged_posts_count.should == 1 PostAction.flagged_posts_count.should == 1
PostAction.clear_flags!(post, -1) PostAction.clear_flags!(post, Discourse.system_user)
PostAction.flagged_posts_count.should == 0 PostAction.flagged_posts_count.should == 0
end end
@ -103,7 +101,7 @@ describe PostAction do
PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) PostAction.act(codinghorror, post, PostActionType.types[:off_topic])
post.hidden.should be_false post.hidden.should be_false
post.hidden_at.should be_blank post.hidden_at.should be_blank
PostAction.defer_flags!(post, admin.id) PostAction.defer_flags!(post, admin)
PostAction.flagged_posts_count.should == 0 PostAction.flagged_posts_count.should == 0
post.reload post.reload
post.hidden.should be_false post.hidden.should be_false
@ -220,7 +218,7 @@ describe PostAction do
# If staff takes action, it is ranked higher # If staff takes action, it is ranked higher
admin = Fabricate(:admin) admin = Fabricate(:admin)
pa = PostAction.act(admin, post, PostActionType.types[:spam], take_action: true) PostAction.act(admin, post, PostActionType.types[:spam], take_action: true)
PostAction.flag_counts_for(post.id).should == [0, 8] PostAction.flag_counts_for(post.id).should == [0, 8]
# If a flag is dismissed # If a flag is dismissed
@ -252,7 +250,7 @@ describe PostAction do
post.reload post.reload
post.spam_count.should == 1 post.spam_count.should == 1
PostAction.clear_flags!(post, -1) PostAction.clear_flags!(post, Discourse.system_user)
post.reload post.reload
post.spam_count.should == 0 post.spam_count.should == 0

View File

@ -764,10 +764,6 @@ describe Topic do
topic.moderator_posts_count.should == 0 topic.moderator_posts_count.should == 0
end end
it "its user has a topics_count of 1" do
topic.user.created_topic_count.should == 1
end
context 'post' do context 'post' do
let(:post) { Fabricate(:post, topic: topic, user: topic.user) } let(:post) { Fabricate(:post, topic: topic, user: topic.user) }

View File

@ -81,6 +81,10 @@ describe UserDestroyer do
context "delete_posts is false" do context "delete_posts is false" do
subject(:destroy) { UserDestroyer.new(@admin).destroy(@user) } subject(:destroy) { UserDestroyer.new(@admin).destroy(@user) }
before do
@user.stubs(:post_count).returns(1)
@user.stubs(:first_post_created_at).returns(Time.zone.now)
end
it 'should not delete the user' do it 'should not delete the user' do
expect { destroy rescue nil }.to_not change { User.count } expect { destroy rescue nil }.to_not change { User.count }