- {{{flaggedPost.excerpt}}} - {{i18n "admin.flags.show_full"}} -
- {{/if}} - {{/if}} -diff --git a/app/assets/javascripts/admin/adapters/flagged-post.js.es6 b/app/assets/javascripts/admin/adapters/flagged-post.js.es6 deleted file mode 100644 index dc44315170f..00000000000 --- a/app/assets/javascripts/admin/adapters/flagged-post.js.es6 +++ /dev/null @@ -1,38 +0,0 @@ -import RestAdapter from "discourse/adapters/rest"; - -export default RestAdapter.extend({ - pathFor(store, type, findArgs) { - let args = _.merge({ rest_api: true }, findArgs); - delete args.filter; - return `/admin/flags/${findArgs.filter}.json?${$.param(args)}`; - }, - - afterFindAll(results, helper) { - results.forEach(flag => { - let conversations = []; - flag.post_actions.forEach(pa => { - if (pa.conversation) { - let conversation = { - permalink: pa.permalink, - hasMore: pa.conversation.has_more, - response: { - excerpt: pa.conversation.response.excerpt, - user: helper.lookup("user", pa.conversation.response.user_id) - } - }; - - if (pa.conversation.reply) { - conversation.reply = { - excerpt: pa.conversation.reply.excerpt, - user: helper.lookup("user", pa.conversation.reply.user_id) - }; - } - conversations.push(conversation); - } - }); - flag.set("conversations", conversations); - }); - - return results; - } -}); diff --git a/app/assets/javascripts/admin/components/flagged-post-response.js.es6 b/app/assets/javascripts/admin/components/flagged-post-response.js.es6 deleted file mode 100644 index e8dc2a230cd..00000000000 --- a/app/assets/javascripts/admin/components/flagged-post-response.js.es6 +++ /dev/null @@ -1,3 +0,0 @@ -export default Ember.Component.extend({ - classNames: ["flagged-post-response"] -}); diff --git a/app/assets/javascripts/admin/components/flagged-post-title.js.es6 b/app/assets/javascripts/admin/components/flagged-post-title.js.es6 deleted file mode 100644 index 4a6fa5d604f..00000000000 --- a/app/assets/javascripts/admin/components/flagged-post-title.js.es6 +++ /dev/null @@ -1,3 +0,0 @@ -export default Ember.Component.extend({ - tagName: "h3" -}); diff --git a/app/assets/javascripts/admin/components/flagged-post.js.es6 b/app/assets/javascripts/admin/components/flagged-post.js.es6 deleted file mode 100644 index 03edebab6cb..00000000000 --- a/app/assets/javascripts/admin/components/flagged-post.js.es6 +++ /dev/null @@ -1,58 +0,0 @@ -import showModal from "discourse/lib/show-modal"; -import computed from "ember-addons/ember-computed-decorators"; - -export default Ember.Component.extend({ - adminTools: Ember.inject.service(), - expanded: false, - tagName: "div", - classNameBindings: [ - ":flagged-post", - "flaggedPost.hidden:hidden-post", - "flaggedPost.deleted" - ], - - canAct: Ember.computed.alias("actableFilter"), - - @computed("filter") - actableFilter(filter) { - return filter === "active"; - }, - - removeAfter(promise) { - return promise.then(() => this.attrs.removePost()); - }, - - _spawnModal(name, model, modalClass) { - let controller = showModal(name, { model, admin: true, modalClass }); - controller.removeAfter = p => this.removeAfter(p); - }, - - actions: { - removeAfter(promise) { - return this.removeAfter(promise); - }, - - disagree() { - this.removeAfter(this.get("flaggedPost").disagreeFlags()); - }, - - defer() { - this.removeAfter(this.get("flaggedPost").deferFlags()); - }, - - expand() { - this.get("flaggedPost") - .expandHidden() - .then(() => { - this.set("expanded", true); - }); - }, - - showModerationHistory() { - this.get("adminTools").showModerationHistory({ - filter: "post", - post_id: this.get("flaggedPost.id") - }); - } - } -}); diff --git a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 index b4d82aba8b3..d042e99a742 100644 --- a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 +++ b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 @@ -2,6 +2,7 @@ import computed from "ember-addons/ember-computed-decorators"; const ACTIONS = ["delete", "edit", "none"]; export default Ember.Component.extend({ + postId: null, postAction: null, postEdit: null, diff --git a/app/assets/javascripts/admin/components/user-flag-percentage.js.es6 b/app/assets/javascripts/admin/components/user-flag-percentage.js.es6 index cd6814ce799..cad6cd84755 100644 --- a/app/assets/javascripts/admin/components/user-flag-percentage.js.es6 +++ b/app/assets/javascripts/admin/components/user-flag-percentage.js.es6 @@ -9,7 +9,7 @@ export default Ember.Component.extend({ }, // We do a little logic to choose which icon to display and which text - @computed("user.flags_agreed", "user.flags_disagreed", "user.flags_ignored") + @computed("agreed", "disagreed", "ignored") percentage(agreed, disagreed, ignored) { let total = agreed + disagreed + ignored; let result = { total }; diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index 8c3b95bd13b..3d5c25279ca 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -13,7 +13,6 @@ export default Ember.Controller.extend(CanCheckEmails, { availableGroups: null, userTitleValue: null, - showApproval: setting("must_approve_users"), showBadges: setting("enable_badges"), hasLockedTrustLevel: Ember.computed.notEmpty( "model.manual_locked_trust_level" @@ -215,9 +214,6 @@ export default Ember.Controller.extend(CanCheckEmails, { target_user: this.get("model.username") }); }, - showFlagsReceived() { - this.get("adminTools").showFlagsReceived(this.get("model")); - }, showSuspendModal() { this.get("adminTools").showSuspendModal(this.get("model")); }, diff --git a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 index 630c1c113e7..ed6f4d4ca6d 100644 --- a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 @@ -12,31 +12,7 @@ export default Ember.Controller.extend(CanCheckEmails, { refreshing: false, listFilter: null, selectAll: false, - - queryNew: Ember.computed.equal("query", "new"), - queryPending: Ember.computed.equal("query", "pending"), - queryHasApproval: Ember.computed.or("queryNew", "queryPending"), - showApproval: Ember.computed.and( - "siteSettings.must_approve_users", - "queryHasApproval" - ), searchHint: i18n("search_hint"), - hasSelection: Ember.computed.gt("selectedCount", 0), - - selectedCount: function() { - var model = this.get("model"); - if (!model || !model.length) return 0; - return model.filterBy("selected").length; - }.property("model.@each.selected"), - - selectAllChanged: function() { - var val = this.get("selectAll"); - this.get("model").forEach(function(user) { - if (user.get("can_approve")) { - user.set("selected", val); - } - }); - }.observes("selectAll"), title: function() { return I18n.t("admin.users.titles." + this.get("query")); @@ -60,34 +36,8 @@ export default Ember.Controller.extend(CanCheckEmails, { }, actions: { - approveUsers: function() { - AdminUser.bulkApprove(this.get("model").filterBy("selected")); - this._refreshUsers(); - }, - rejectUsers: function() { - var maxPostAge = this.siteSettings.delete_user_max_post_age; - var controller = this; - AdminUser.bulkReject(this.get("model").filterBy("selected")).then( - function(result) { - var message = I18n.t("admin.users.reject_successful", { - count: result.success - }); - if (result.failed > 0) { - message += - " " + - I18n.t("admin.users.reject_failures", { count: result.failed }); - message += - " " + - I18n.t("admin.user.delete_forbidden", { count: maxPostAge }); - } - bootbox.alert(message); - controller._refreshUsers(); - } - ); - }, - - toggleEmailVisibility: function() { + toggleEmailVisibility() { this.toggleProperty("showEmails"); this._refreshUsers(); } diff --git a/app/assets/javascripts/admin/controllers/modals/admin-flags-received.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-flags-received.js.es6 deleted file mode 100644 index f4c8226733b..00000000000 --- a/app/assets/javascripts/admin/controllers/modals/admin-flags-received.js.es6 +++ /dev/null @@ -1,17 +0,0 @@ -export default Ember.Controller.extend({ - loadingFlags: null, - user: null, - - onShow() { - this.set("loadingFlags", true); - this.store - .findAll("flagged-post", { - filter: "without_custom", - user_id: this.get("model.id") - }) - .then(result => { - this.set("loadingFlags", false); - this.set("flaggedPosts", result); - }); - } -}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 deleted file mode 100644 index bce965fc46d..00000000000 --- a/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 +++ /dev/null @@ -1,21 +0,0 @@ -import ModalFunctionality from "discourse/mixins/modal-functionality"; - -export default Ember.Controller.extend(ModalFunctionality, { - loading: null, - historyTarget: null, - history: null, - - onShow() { - this.set("loading", true); - this.set("history", null); - }, - - loadHistory(target) { - this.store - .findAll("moderation-history", target) - .then(result => { - this.set("history", result); - }) - .finally(() => this.set("loading", false)); - } -}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 index c4ad822485b..40b3aa640ff 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 @@ -29,7 +29,7 @@ export default Ember.Controller.extend(PenaltyController, { silenced_till: this.get("silenceUntil"), reason: this.get("reason"), message: this.get("message"), - post_id: this.get("post.id"), + post_id: this.get("postId"), post_action: this.get("postAction"), post_edit: this.get("postEdit") }); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 index d1d6de7f4b4..180b470d492 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 @@ -30,7 +30,7 @@ export default Ember.Controller.extend(PenaltyController, { suspend_until: this.get("suspendUntil"), reason: this.get("reason"), message: this.get("message"), - post_id: this.get("post.id"), + post_id: this.get("postId"), post_action: this.get("postAction"), post_edit: this.get("postEdit") }); diff --git a/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 b/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 index a5b62a0143f..efa71a2bebd 100644 --- a/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 +++ b/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 @@ -7,7 +7,7 @@ export default Ember.Mixin.create(ModalFunctionality, { postEdit: null, postAction: null, user: null, - post: null, + postId: null, successCallback: null, resetModal() { @@ -15,7 +15,7 @@ export default Ember.Mixin.create(ModalFunctionality, { reason: null, message: null, loadingUser: true, - post: null, + postId: null, postEdit: null, postAction: "delete", before: null, diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 7e45667c25f..bc1cba2f411 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -573,36 +573,6 @@ const AdminUser = Discourse.User.extend({ }); AdminUser.reopenClass({ - bulkApprove(users) { - users.forEach(user => { - user.setProperties({ - approved: true, - can_approve: false, - selected: false - }); - }); - - return ajax("/admin/users/approve-bulk", { - type: "PUT", - data: { users: users.map(u => u.id) } - }).finally(() => bootbox.alert(I18n.t("admin.user.approve_bulk_success"))); - }, - - bulkReject(users) { - users.forEach(user => { - user.set("can_approve", false); - user.set("selected", false); - }); - - return ajax("/admin/users/reject-bulk", { - type: "DELETE", - data: { - users: users.map(u => u.id), - context: window.location.pathname - } - }); - }, - find(user_id) { return ajax("/admin/users/" + user_id + ".json").then(result => { result.loadedDetails = true; diff --git a/app/assets/javascripts/admin/models/flagged-post.js.es6 b/app/assets/javascripts/admin/models/flagged-post.js.es6 deleted file mode 100644 index 13d69bf6650..00000000000 --- a/app/assets/javascripts/admin/models/flagged-post.js.es6 +++ /dev/null @@ -1,166 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import Post from "discourse/models/post"; -import computed from "ember-addons/ember-computed-decorators"; -import { popupAjaxError } from "discourse/lib/ajax-error"; - -export default Post.extend({ - @computed - summary() { - return _(this.post_actions) - .groupBy(function(a) { - return a.post_action_type_id; - }) - .map(function(v, k) { - return I18n.t("admin.flags.summary.action_type_" + k, { - count: v.length - }); - }) - .join(","); - }, - - @computed("last_revised_at", "post_actions.@each.created_at") - wasEdited(lastRevisedAt) { - if (Ember.isEmpty(this.get("last_revised_at"))) { - return false; - } - lastRevisedAt = Date.parse(lastRevisedAt); - const postActions = this.get("post_actions") || []; - return postActions.some(postAction => { - return Date.parse(postAction.created_at) < lastRevisedAt; - }); - }, - - @computed("post_actions") - hasDisposedBy() { - return this.get("post_actions").some(action => action.disposed_by); - }, - - @computed("post_actions.@each.name_key") - flaggedForSpam() { - return this.get("post_actions").every(action => action.name_key === "spam"); - }, - - @computed("post_actions.@each.targets_topic") - topicFlagged() { - return _.any(this.get("post_actions"), function(action) { - return action.targets_topic; - }); - }, - - @computed("post_actions.@each.targets_topic") - postAuthorFlagged() { - return _.any(this.get("post_actions"), function(action) { - return !action.targets_topic; - }); - }, - - @computed("flaggedForSpam") - canDeleteAsSpammer(flaggedForSpam) { - return ( - flaggedForSpam && - this.get("user.can_delete_all_posts") && - this.get("user.can_be_deleted") - ); - }, - - deletePost() { - if (this.get("post_number") === 1) { - return ajax("/t/" + this.topic_id, { type: "DELETE", cache: false }); - } else { - return ajax("/posts/" + this.id, { type: "DELETE", cache: false }); - } - }, - - disagreeFlags() { - return ajax("/admin/flags/disagree/" + this.id, { - type: "POST", - cache: false - }).catch(popupAjaxError); - }, - - deferFlags(deletePost) { - const action = () => { - return ajax("/admin/flags/defer/" + this.id, { - type: "POST", - cache: false, - data: { delete_post: deletePost } - }); - }; - - if (deletePost && this._hasDeletableReplies()) { - return this._actOnFlagAndDeleteReplies(action); - } else { - return action().catch(popupAjaxError); - } - }, - - agreeFlags(actionOnPost) { - const action = () => { - return ajax("/admin/flags/agree/" + this.id, { - type: "POST", - cache: false, - data: { action_on_post: actionOnPost } - }); - }; - - if (actionOnPost === "delete" && this._hasDeletableReplies()) { - return this._actOnFlagAndDeleteReplies(action); - } else { - return action().catch(popupAjaxError); - } - }, - - _hasDeletableReplies() { - return this.get("post_number") > 1 && this.get("reply_count") > 0; - }, - - _actOnFlagAndDeleteReplies(action) { - return new Ember.RSVP.Promise((resolve, reject) => { - return ajax(`/posts/${this.id}/reply-ids/all.json`) - .then(replies => { - const buttons = []; - - buttons.push({ - label: I18n.t("no_value"), - callback() { - action() - .then(resolve) - .catch(error => { - popupAjaxError(error); - reject(); - }); - } - }); - - buttons.push({ - label: I18n.t("yes_value"), - class: "btn-danger", - callback() { - Post.deleteMany(replies.map(r => r.id), { - agreeWithFirstReplyFlag: false - }) - .then(action) - .then(resolve) - .catch(error => { - popupAjaxError(error); - reject(); - }); - } - }); - - bootbox.dialog( - I18n.t("admin.flags.delete_replies", { count: replies.length }), - buttons - ); - }) - .catch(error => { - popupAjaxError(error); - reject(); - }); - }); - }, - - postHidden: Ember.computed.alias("hidden"), - - deleted: Ember.computed.or("deleted_at", "topic_deleted_at") -}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 deleted file mode 100644 index 3f78906e3fe..00000000000 --- a/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 +++ /dev/null @@ -1,8 +0,0 @@ -export default Discourse.Route.extend({ - redirect() { - let segment = this.siteSettings.flags_default_topics - ? "topics" - : "postsActive"; - this.replaceWith(`adminFlags.${segment}`); - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-topics-show.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-topics-show.js.es6 deleted file mode 100644 index 879222c4958..00000000000 --- a/app/assets/javascripts/admin/routes/admin-flags-topics-show.js.es6 +++ /dev/null @@ -1,20 +0,0 @@ -import { loadTopicView } from "discourse/models/topic"; - -export default Ember.Route.extend({ - model(params) { - let topicRecord = this.store.createRecord("topic", { id: params.id }); - let topic = loadTopicView(topicRecord).then(() => topicRecord); - - return Ember.RSVP.hash({ - topic, - flaggedPosts: this.store.findAll("flagged-post", { - filter: "active", - topic_id: params.id - }) - }); - }, - - setupController(controller, hash) { - controller.setProperties(hash); - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 3ec2cc9c8cc..b302b1b9c9b 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -120,18 +120,6 @@ export default function() { } ); - this.route( - "adminFlags", - { path: "/flags", resetNamespace: true }, - function() { - this.route("postsActive", { path: "active" }); - this.route("postsOld", { path: "old" }); - this.route("topics", { path: "topics" }, function() { - this.route("show", { path: ":id" }); - }); - } - ); - this.route( "adminLogs", { path: "/logs", resetNamespace: true }, diff --git a/app/assets/javascripts/admin/services/admin-tools.js.es6 b/app/assets/javascripts/admin/services/admin-tools.js.es6 index 4a055b0daf0..7f3b4d88a81 100644 --- a/app/assets/javascripts/admin/services/admin-tools.js.es6 +++ b/app/assets/javascripts/admin/services/admin-tools.js.es6 @@ -26,10 +26,6 @@ export default Ember.Service.extend({ }); }, - showFlagsReceived(user) { - showModal(`admin-flags-received`, { admin: true, model: user }); - }, - checkSpammer(userId) { return AdminUser.find(userId).then(au => this.spammerDetails(au)); }, @@ -53,12 +49,7 @@ export default Ember.Service.extend({ admin: true, modalClass: `${type}-user-modal` }); - if (opts.post) { - controller.setProperties({ - post: opts.post, - postEdit: opts.post.get("raw") - }); - } + controller.setProperties({ postId: opts.postId, postEdit: opts.postEdit }); return (user.adminUserView ? Ember.RSVP.resolve(user) @@ -81,11 +72,6 @@ export default Ember.Service.extend({ this._showControlModal("suspend", user, opts); }, - showModerationHistory(target) { - let controller = showModal("admin-moderation-history", { admin: true }); - controller.loadHistory(target); - }, - _deleteSpammer(adminUser) { // Try loading the email if the site supports it let tryEmail = this.siteSettings.moderators_view_emails diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 415239a6565..4dc2dc3061a 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -19,7 +19,6 @@ {{#if currentUser.admin}} {{nav-item route='adminEmail' label='admin.email.title'}} {{/if}} - {{nav-item route='adminFlags' label='admin.flags.title'}} {{nav-item route='adminLogs' label='admin.logs.title'}} {{#if currentUser.admin}} {{nav-item route='adminCustomize' label='admin.customize.title'}} diff --git a/app/assets/javascripts/admin/templates/components/flag-user-lists.hbs b/app/assets/javascripts/admin/templates/components/flag-user-lists.hbs index 2bf86a7bf68..62224b13c14 100644 --- a/app/assets/javascripts/admin/templates/components/flag-user-lists.hbs +++ b/app/assets/javascripts/admin/templates/components/flag-user-lists.hbs @@ -8,7 +8,10 @@
- {{{flaggedPost.excerpt}}} - {{i18n "admin.flags.show_full"}} -
- {{/if}} - {{/if}} -{{i18n 'admin.flags.no_results'}}
-{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/flagged-topic-users.hbs b/app/assets/javascripts/admin/templates/components/flagged-topic-users.hbs deleted file mode 100644 index ad9f40585a0..00000000000 --- a/app/assets/javascripts/admin/templates/components/flagged-topic-users.hbs +++ /dev/null @@ -1,5 +0,0 @@ -{{#each users as |u|}} - {{#link-to 'adminUser' u.id u.username class="flagged-topic-user"}} - {{avatar u imageSize="small"}} - {{/link-to}} -{{/each}} diff --git a/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs b/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs deleted file mode 100644 index bf2ddc0f4cf..00000000000 --- a/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs +++ /dev/null @@ -1,17 +0,0 @@ -{{i18n "admin.flags.flagged_topics.topic"}} | -{{i18n "admin.flags.flagged_topics.type"}} | -{{I18n "admin.flags.flagged_topics.users"}} | -{{i18n "admin.flags.flagged_topics.last_flagged"}} | -- - - {{#each flaggedTopics as |ft|}} - |
---|---|---|---|---|
-
- {{topic-status topic=ft.topic}}
- {{replace-emoji ft.topic.fancy_title}}
-
- |
-
- {{#each ft.flag_counts as |fc|}}
-
- {{post-action-title fc.post_action_type_id fc.name_key}}
- x{{fc.count}}
-
- {{/each}}
- |
- - {{flagged-topic-users users=ft.users tagName=""}} - | -- {{format-age ft.last_flag_at}} - | -- {{#link-to - "adminFlags.topics.show" - ft.id - class="btn d-button no-text btn-small btn-primary show-details" - title=(i18n "admin.flags.show_details")}} - {{d-icon "list"}} - {{i18n "admin.flags.details"}} - {{/link-to}} - | -
{{i18n "admin.logs.created_at"}} | -{{i18n "admin.logs.action"}} | -{{i18n "admin.moderation_history.performed_by"}} | -
---|
{{input type="checkbox" checked=selectAll}} | - {{/if}} {{admin-directory-toggle field="username" i18nKey='username' order=order ascending=ascending}} {{admin-directory-toggle field="email" i18nKey='email' order=order ascending=ascending}} {{admin-directory-toggle field="last_emailed" i18nKey='admin.users.last_emailed' order=order ascending=ascending}} @@ -35,7 +25,7 @@ {{admin-directory-toggle field="posts_read" i18nKey="admin.user.posts_read_count" order=order ascending=ascending}} {{admin-directory-toggle field="read_time" i18nKey="admin.user.time_read" order=order ascending=ascending}} {{admin-directory-toggle field="created" i18nKey="created" order=order ascending=ascending}} - {{#if showApproval}} + {{#if siteSettings.must_approve_users}}{{i18n 'admin.users.approved'}} | {{/if}}@@ -43,13 +33,6 @@ {{#each model as |user|}} | |||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
- {{#if user.can_approve}} - {{input type="checkbox" checked=user.selected}} - {{/if}} - | - {{/if}}
{{avatar user imageSize="small"}}
@@ -88,15 +71,10 @@
{{{format-duration user.created_at_age}}}
|
- {{#if showApproval}}
- - {{#if user.approved}} - {{i18n 'yes_value'}} - {{else}} - {{i18n 'no_value'}} - {{/if}} - | + {{#if siteSettings.must_approve_users}} +{{i18n-yes-no user.approved}} | {{/if}} +
{{#if user.admin}}
{{d-icon "shield-alt" title="admin.title" }}
diff --git a/app/assets/javascripts/admin/templates/users-list.hbs b/app/assets/javascripts/admin/templates/users-list.hbs
index d4d5be049f8..681eb945a52 100644
--- a/app/assets/javascripts/admin/templates/users-list.hbs
+++ b/app/assets/javascripts/admin/templates/users-list.hbs
@@ -3,9 +3,6 @@
-
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs
new file mode 100644
index 00000000000..1c9736a41f6
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-bundled-action.hbs
@@ -0,0 +1,17 @@
+{{#if multiple}}
+ {{dropdown-select-box
+ headerIcon=bundle.icon
+ class="reviewable-action-dropdown"
+ nameProperty="label"
+ title=bundle.label
+ content=bundle.actions
+ onSelect=(action "performById")
+ disabled=reviewableUpdating}}
+{{else}}
+ {{d-button
+ class=(concat "reviewable-action " (dasherize first.id))
+ icon=first.icon
+ action=(action "perform" first)
+ translatedLabel=first.label
+ disabled=reviewableUpdating}}
+{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-conversation-post.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-conversation-post.hbs
new file mode 100644
index 00000000000..3b57076c19d
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-conversation-post.hbs
@@ -0,0 +1,5 @@
+{{#if post}}
+
- {{#user-link user=post.user}}
- {{avatar post.user imageSize="large"}}
- {{/user-link}}
-
-
-
-
-
-
- {{#user-link user=post.user}}
- {{post.user.username}}
- {{/user-link}}
- {{#if post.user.silenced}}
- {{d-icon "ban" title="user.silenced_tooltip"}}
- {{/if}}
-
-
-
- {{age-with-tooltip post.created_at}}
-
-
-
- {{#if editTitleAndCategory}}
-
- {{text-field value=buffered.title maxlength=siteSettings.max_topic_title_length}}
-
- {{category-chooser value=buffered.category_id}}
- {{else}}
-
- {{i18n "queue.topic"}}
- {{#if post.topic}}
- {{topic-link post.topic}}
- {{else}}
- {{editables.title}}
- {{/if}}
- {{category-badge editables.category}}
-
- {{/if}}
-
-
- {{#if editing}}
- {{d-editor value=buffered.raw}}
- {{else}}
- {{cook-text editables.raw}}
- {{/if}}
-
-
- {{#if showTags}}
-
- {{#each tags as |t|}}
- {{discourse-tag t}}
- {{/each}}
-
- {{else if editTags}}
- {{tag-chooser tags=buffered.tags categoryId=buffered.category_id}}
- {{/if}}
-
-
- {{#if editing}}
- {{d-button action=(action "confirmEdit")
- label="queue.confirm"
- disabled=post.isSaving
- class="btn-primary confirm"}}
- {{d-button action=(action "cancelEdit")
- label="queue.cancel"
- icon="times"
- disabled=post.isSaving
- class="btn-danger cancel"}}
- {{else}}
- {{d-button action=(action "approve")
- disabled=post.isSaving
- label="queue.approve"
- icon="check"
- class="btn-primary approve"}}
- {{d-button action=(action "reject")
- disabled=post.isSaving
- label="queue.reject"
- icon="times"
- class="btn-danger reject"}}
- {{#if post.can_delete_user}}
- {{d-button action=(action "deleteUser")
- disabled=post.isSaving
- label="queue.delete_user"
- icon="trash-alt"
- class="btn-danger delete-user"}}
- {{/if}}
- {{d-button action=(action "edit")
- disabled=post.isSaving
- label="queue.edit"
- icon="pencil-alt"
- class="edit"}}
- {{/if}}
-
-
+ {{#link-to 'user' post.user class="username"}}@{{post.user.username}}{{/link-to}} {{{post.excerpt}}}
+
+{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-field-category.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-field-category.hbs
new file mode 100644
index 00000000000..4ecdea1f6d8
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-field-category.hbs
@@ -0,0 +1 @@
+{{category-chooser value=value onChooseCategory=categoryChanged}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-field-editor.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-field-editor.hbs
new file mode 100644
index 00000000000..befb8bc08ae
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-field-editor.hbs
@@ -0,0 +1 @@
+{{d-editor value=value change=valueChanged}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-field-tags.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-field-tags.hbs
new file mode 100644
index 00000000000..6175da44a3c
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-field-tags.hbs
@@ -0,0 +1 @@
+{{mini-tag-chooser tags=value onChangeTags=valueChanged}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-field-text.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-field-text.hbs
new file mode 100644
index 00000000000..2b3e5f0f8e4
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-field-text.hbs
@@ -0,0 +1 @@
+{{input value=value change=valueChanged class='reviewable-input-text'}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-field-textarea.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-field-textarea.hbs
new file mode 100644
index 00000000000..1f1856ffc67
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-field-textarea.hbs
@@ -0,0 +1 @@
+{{textarea value=value change=valueChanged class="reviewable-input-textarea"}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs
new file mode 100644
index 00000000000..f7dc947c225
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs
@@ -0,0 +1,23 @@
+
+ {{#user-link user=reviewable.target_created_by}}
+ {{avatar reviewable.target_created_by imageSize="large"}}
+ {{/user-link}}
+
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-histories.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-histories.hbs
new file mode 100644
index 00000000000..318ce235f55
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-histories.hbs
@@ -0,0 +1,23 @@
+{{#if filteredHistories}}
+
+
+ {{#user-link user=reviewable.target_created_by}}
+ {{reviewable.target_created_by.username}}
+ {{/user-link}}
+
+
+
+ {{reviewable-topic-link topic=reviewable.topic}}
+
+
+ {{{reviewable.cooked}}}
+
+
+ {{yield}}
+
+
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs
new file mode 100644
index 00000000000..54a03fcd7c7
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs
@@ -0,0 +1,37 @@
+
+
+
+ {{reviewable.humanType}}
+
+ {{category-badge reviewable.category}}
+
+
+
+ {{#link-to 'review.show' reviewable.id}}{{age-with-tooltip reviewable.created_at}}{{/link-to}}
+
+ {{format-score reviewable.score}}
+
+
+ {{#if reviewable.approved}}
+ {{d-icon "check"}} {{i18n "review.statuses.approved.title"}}
+ {{else if reviewable.rejected}}
+ {{d-icon "times"}} {{i18n "review.statuses.rejected.title"}}
+ {{else if reviewable.ignored}}
+ {{d-icon "external-link-alt"}} {{i18n "review.statuses.ignored.title"}}
+ {{/if}}
+
+
+ {{#if editing}}
+
+
+ {{#each reviewable.editable_fields as |f|}}
+
+ {{else}}
+ {{#component reviewableComponent reviewable=reviewable tagName=''}}
+ {{reviewable-scores scores=reviewable.reviewable_scores}}
+ {{reviewable-histories histories=reviewable.reviewable_histories}}
+ {{/component}}
+ {{/if}}
+
+ {{component
+ (concat "reviewable-field-" f.type)
+ tagName=''
+ value=(editable-value reviewable f.id)
+ tagCategoryId=reviewable.category.id
+ valueChanged=(action "valueChanged" f.id)
+ categoryChanged=(action "categoryChanged")}}
+
+ {{/each}}
+
+ {{#if editing}}
+ {{d-button
+ class="btn-primary reviewable-action save-edit"
+ disabled=updating
+ icon="check"
+ action=(action "saveEdit")
+ label="review.save"}}
+ {{d-button
+ class="btn-danger reviewable-action cancel-edit"
+ disabled=updating
+ icon="times"
+ action=(action "cancelEdit")
+ label="review.cancel"}}
+ {{else}}
+ {{#each reviewable.bundled_actions as |bundle|}}
+ {{reviewable-bundled-action
+ bundle=bundle
+ performAction=(action "perform")
+ reviewableUpdating=updating}}
+ {{/each}}
+
+ {{#if reviewable.can_edit}}
+ {{d-button
+ class="reviewable-action edit"
+ disabled=updating
+ icon="pencil-alt"
+ action=(action "edit")
+ label="review.edit"}}
+ {{/if}}
+ {{/if}}
+
+
+
+ {{#user-link user=reviewable.created_by}}
+ {{avatar reviewable.created_by imageSize="large"}}
+ {{/user-link}}
+
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-scores.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-scores.hbs
new file mode 100644
index 00000000000..b9a6bf0046e
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-scores.hbs
@@ -0,0 +1,41 @@
+{{#if scores}}
+
+
+ {{#user-link user=reviewable.created_by}}
+ {{reviewable.created_by.username}}
+ {{/user-link}}
+ {{#if reviewable.created_by.silenced}}
+ {{d-icon "ban" title="user.silenced_tooltip"}}
+ {{/if}}
+
+
+
+ {{#reviewable-topic-link topic=reviewable.topic}}
+ {{i18n "review.new_topic"}}
+ {{reviewable.payload.title}}
+ {{/reviewable-topic-link}}
+
+
+ {{cook-text reviewable.payload.raw}}
+
+
+ {{#if reviewable.payload.tags}}
+
+ {{#each reviewable.payload.tags as |t|}}
+ {{discourse-tag t}}
+ {{/each}}
+
+ {{/if}}
+
+ {{yield}}
+
+ {{#if topic}}
+ {{i18n "review.topic"}}
+ {{topic-status topic=topic}}
+ {{topic-link topic}}
+ {{i18n "review.topic_replies" count=topic.reply_count}}
+ {{else}}
+ {{yield}}
+ {{/if}}
+
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-user.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-user.hbs
new file mode 100644
index 00000000000..0afc052a447
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-user.hbs
@@ -0,0 +1,8 @@
+
+
diff --git a/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs b/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs
index ca50e9ade61..46f37bfe804 100644
--- a/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs
+++ b/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs
@@ -1,8 +1,8 @@
{{#d-modal-body}}
-
+ {{reviewable.username}}
+ {{reviewable.email}}
+
+
+ {{yield}}
+{{{description}}} +{{i18n "review.approval.description"}} -{{{i18n "queue.approval.pending_posts" count=model.pending_count}}} +{{{i18n "review.approval.pending_posts" count=model.pending_count}}} {{/d-modal-body}}
- {{d-button action=(route-action "closeModal") class="btn-primary" label="queue.approval.ok"}}
+ {{d-button action=(route-action "closeModal") class="btn-primary" label="review.approval.ok"}}
diff --git a/app/assets/javascripts/discourse/templates/queued-posts.hbs b/app/assets/javascripts/discourse/templates/queued-posts.hbs
deleted file mode 100644
index a5984c1add4..00000000000
--- a/app/assets/javascripts/discourse/templates/queued-posts.hbs
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
diff --git a/app/assets/javascripts/discourse/templates/review-index.hbs b/app/assets/javascripts/discourse/templates/review-index.hbs
new file mode 100644
index 00000000000..b1110bef60b
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/review-index.hbs
@@ -0,0 +1,83 @@
+
- {{#each model as |post|}}
- {{queued-post post=post currentlyEditing=editing removePost=(route-action "removePost" post)}}
- {{else}}
-
-{{i18n "queue.none"}} - {{/each}} - - {{d-button action=(route-action "refresh") label="refresh" icon="sync" disabled=model.refreshing class="btn-default" id='refresh-queued'}} -
+
diff --git a/app/assets/javascripts/discourse/templates/review-show.hbs b/app/assets/javascripts/discourse/templates/review-show.hbs
new file mode 100644
index 00000000000..6014a7d4d83
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/review-show.hbs
@@ -0,0 +1 @@
+{{reviewable-item reviewable=reviewable}}
diff --git a/app/assets/javascripts/discourse/templates/review-topics.hbs b/app/assets/javascripts/discourse/templates/review-topics.hbs
new file mode 100644
index 00000000000..7fc8f8be7d3
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/review-topics.hbs
@@ -0,0 +1,45 @@
+
+
+
+ {{#if reviewables}}
+ {{#load-more selector=".reviewable-item" action=(action "loadMore")}}
+
+
+
+ {{#each reviewables as |r|}}
+ {{reviewable-item reviewable=r remove=(action "remove")}}
+ {{/each}}
+
+ {{/load-more}}
+ {{conditional-loading-spinner condition=reviewables.loadingMore}}
+ {{else}}
+
+ {{i18n "review.none"}}
+
+ {{/if}}
+
+
+
+
+ {{combo-box value=filterStatus content=statuses}}
+
+
+ {{#if filtersExpanded}}
+
+
+ {{combo-box value=filterType content=allTypes none="review.filters.type.all"}}
+
+
+
+
+ {{input value=filterScore class="score-filter"}}
+
+
+
+
+ {{category-chooser none="category.all" value=filterCategoryId}}
+
+
+
+ {{i18n "review.filtered_user"}}
+ {{user-selector
+ excludeCurrentUser=false
+ usernames=filterUsername
+ fullWidthWrap="true"
+ class="user-selector"
+ single="true"
+ canReceiveUpdates="true"}}
+
+
+ {{#if filterTopic}}
+
+ {{i18n "review.filtered_topic"}}
+ {{d-button label="review.show_all_topics" icon="times" action=(action "resetTopic")}}
+
+ {{/if}}
+ {{/if}}
+
+
+ {{d-button
+ icon="sync"
+ label="review.filters.refresh"
+
+ class="btn-primary refresh" action=(action "refresh")}}
+
+ {{#if site.mobileView}}
+ {{d-button
+ label="show_help"
+ icon=toggleFiltersIcon
+ class="btn-default expand-secondary-filters"
+ action=(action "toggleFilters")}}
+ {{/if}}
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/review.hbs b/app/assets/javascripts/discourse/templates/review.hbs
new file mode 100644
index 00000000000..c24cd68950a
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/review.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs
index 75212e13a78..9b09579936f 100644
--- a/app/assets/javascripts/discourse/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/templates/topic.hbs
@@ -213,14 +213,13 @@
{{#if model.pending_posts_count}}
+ {{i18n "review.none"}}
+
+ {{/if}}
+
- {{{i18n "queue.has_pending_posts" count=model.pending_posts_count}}}
+
{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/user.hbs b/app/assets/javascripts/discourse/templates/user.hbs
index 1a7afc307d8..c8442628897 100644
--- a/app/assets/javascripts/discourse/templates/user.hbs
+++ b/app/assets/javascripts/discourse/templates/user.hbs
@@ -10,9 +10,9 @@
{{/if}}
{{#if model.number_of_flagged_posts}}
+ {{{i18n "review.topic_has_pending" count=model.pending_posts_count}}}
+
- {{#if currentUser.show_queued_posts}}
- {{#link-to "queued-posts"}}
- {{d-icon "check"}}
- {{i18n "queue.view_pending"}}
- {{/link-to}}
- {{/if}}
+ {{#link-to 'review' (query-params topic_id=model.id type="ReviewableQueuedPost" status="pending")}}
+ {{i18n "review.view_pending"}}
+ {{/link-to}}
- {{#link-to 'user.flaggedPosts' model}}
+ {{#link-to 'review' (query-params username=model.username status='all' type='ReviewableFlaggedPost')}}
{{model.number_of_flagged_posts}}{{i18n 'user.staff_counters.flagged_posts'}}
- {{/link-to}}
+ {{/link-to}}
{{/if}}
{{#if model.number_of_deleted_posts}}
diff --git a/app/assets/javascripts/discourse/widgets/button.js.es6 b/app/assets/javascripts/discourse/widgets/button.js.es6
index f7349227a3d..ee6f8c46d2e 100644
--- a/app/assets/javascripts/discourse/widgets/button.js.es6
+++ b/app/assets/javascripts/discourse/widgets/button.js.es6
@@ -1,6 +1,7 @@
import { createWidget } from "discourse/widgets/widget";
import { iconNode } from "discourse-common/lib/icon-library";
import { h } from "virtual-dom";
+import DiscourseURL from "discourse/lib/url";
export const ButtonClass = {
tagName: "button.widget-button.btn",
@@ -83,6 +84,10 @@ export const ButtonClass = {
this.sendWidgetAction(attrs.secondaryAction);
}
+ if (attrs.url) {
+ return DiscourseURL.routeTo(attrs.url);
+ }
+
if (attrs.sendActionEvent) {
return this.sendWidgetAction(attrs.action, e);
}
diff --git a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6
index a640da5b3f4..24032f83041 100644
--- a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6
@@ -46,8 +46,7 @@ export default createWidget("hamburger-menu", {
},
adminLinks() {
- const { currentUser, siteSettings } = this;
- let flagsPath = siteSettings.flags_default_topics ? "topics" : "active";
+ const { currentUser } = this;
const links = [
{
@@ -55,27 +54,16 @@ export default createWidget("hamburger-menu", {
className: "admin-link",
icon: "wrench",
label: "admin_title"
- },
- {
- href: `/admin/flags/${flagsPath}`,
- className: "flagged-posts-link",
- icon: "flag",
- label: "flags_title",
- badgeClass: "flagged-posts",
- badgeTitle: "notifications.total_flagged",
- badgeCount: "site_flagged_posts_count"
}
];
- if (currentUser.show_queued_posts) {
- links.push({
- route: "queued-posts",
- className: "queued-posts-link",
- label: "queue.title",
- badgeCount: "post_queue_new_count",
- badgeClass: "queued-posts"
- });
- }
+ links.push({
+ route: "review",
+ className: "review",
+ label: "review.title",
+ badgeCount: "reviewable_count",
+ badgeClass: "reviewables"
+ });
if (currentUser.admin) {
links.push({
diff --git a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6
index 4dd16031d1d..fd2907d4550 100644
--- a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6
@@ -6,10 +6,7 @@ createWidget(
"post-admin-menu-button",
jQuery.extend(ButtonClass, {
tagName: "li.btn",
- click() {
- this.sendWidgetAction("closeAdminMenu");
- return this.sendWidgetAction(this.attrs.action);
- }
+ secondaryAction: "closeAdminMenu"
})
);
@@ -23,8 +20,8 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
contents.push({
icon: "list",
className: "btn-default",
- label: "admin.flags.moderation_history",
- action: "showModerationHistory"
+ label: "review.moderation_history",
+ url: `/review?topic_id=${attrs.topicId}&status=all`
});
}
diff --git a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6
index 2d6e06502ba..15d9781dc61 100644
--- a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6
@@ -14,6 +14,7 @@ createWidget("admin-menu-button", {
this.attach("button", {
className,
action: attrs.action,
+ url: attrs.url,
icon: attrs.icon,
label: attrs.fullLabel || `topic.${attrs.label}`,
secondaryAction: "hideAdminMenu"
@@ -252,10 +253,10 @@ export default createWidget("topic-admin-menu", {
if (this.currentUser.get("staff")) {
buttons.push({
- action: "showModerationHistory",
- buttonClass: "btn-default",
icon: "list",
- fullLabel: "admin.flags.moderation_history"
+ buttonClass: "btn-default",
+ fullLabel: "review.moderation_history",
+ url: `/review?topic_id=${topic.id}&status=all`
});
}
diff --git a/app/assets/javascripts/polyfills.js b/app/assets/javascripts/polyfills.js
index 69c3987afe8..a4a90dfe2c5 100644
--- a/app/assets/javascripts/polyfills.js
+++ b/app/assets/javascripts/polyfills.js
@@ -178,4 +178,5 @@ if (!Array.prototype.find) {
writable: true
});
}
+
/* eslint-enable */
diff --git a/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6
deleted file mode 100644
index 8ef25f08631..00000000000
--- a/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6
+++ /dev/null
@@ -1,131 +0,0 @@
-import DropdownSelectBox from "select-kit/components/dropdown-select-box";
-import computed from "ember-addons/ember-computed-decorators";
-
-export default DropdownSelectBox.extend({
- pluginApiIdentifiers: ["admin-agree-flag-dropdown"],
- classNames: ["agree-flag", "admin-agree-flag-dropdown"],
- adminTools: Ember.inject.service(),
- nameProperty: "label",
- allowInitialValueMutation: false,
- headerIcon: "thumbs-o-up",
-
- computeHeaderContent() {
- let content = this._super(...arguments);
- content.name = `${I18n.t("admin.flags.agree")}...`;
- return content;
- },
-
- @computed("adminTools", "post.user")
- spammerDetails(adminTools, user) {
- return adminTools.spammerDetails(user);
- },
-
- canDeleteSpammer: Ember.computed.and(
- "spammerDetails.canDelete",
- "post.flaggedForSpam"
- ),
-
- computeContent() {
- const content = [];
- const post = this.get("post");
- const canDeleteSpammer = this.get("canDeleteSpammer");
-
- if (post.user_deleted) {
- content.push({
- icon: "far-eye",
- id: "confirm-agree-restore",
- action: () => this.send("perform", "restore"),
- label: I18n.t("admin.flags.agree_flag_restore_post"),
- description: I18n.t("admin.flags.agree_flag_restore_post_title")
- });
- } else {
- if (!post.get("postHidden")) {
- content.push({
- icon: "far-eye-slash",
- action: () => this.send("perform", "hide"),
- id: "confirm-agree-hide",
- label: I18n.t("admin.flags.agree_flag_hide_post"),
- description: I18n.t("admin.flags.agree_flag_hide_post_title")
- });
- }
- }
-
- content.push({
- icon: "thumbs-o-up",
- id: "confirm-agree-keep",
- description: I18n.t("admin.flags.agree_flag_title"),
- action: () => this.send("perform", "keep"),
- label: I18n.t("admin.flags.agree_flag")
- });
-
- content.push({
- icon: "ban",
- id: "confirm-agree-suspend",
- description: I18n.t("admin.flags.agree_flag_suspend_title"),
- action: () => this.send("showSuspendModal"),
- label: I18n.t("admin.flags.agree_flag_suspend")
- });
-
- content.push({
- icon: "microphone-slash",
- id: "confirm-agree-silence",
- description: I18n.t("admin.flags.agree_flag_silence_title"),
- action: () => this.send("showSilenceModal"),
- label: I18n.t("admin.flags.agree_flag_silence")
- });
-
- if (canDeleteSpammer) {
- content.push({
- title: I18n.t("admin.flags.delete_spammer_title"),
- icon: "exclamation-triangle",
- id: "delete-spammer",
- action: () => this.send("deleteSpammer"),
- label: I18n.t("admin.flags.delete_spammer")
- });
- }
-
- return content;
- },
-
- mutateValue(value) {
- const computedContentItem = this.get("computedContent").findBy(
- "value",
- value
- );
- Ember.get(computedContentItem, "originalContent.action")();
- },
-
- actions: {
- deleteSpammer() {
- let spammerDetails = this.get("spammerDetails");
- this.attrs.removeAfter(spammerDetails.deleteUser());
- },
-
- showSuspendModal() {
- let post = this.get("post");
- let user = post.get("user");
- this.get("adminTools").showSuspendModal(user, {
- post,
- before: () => {
- return this.attrs.removeAfter(post.agreeFlags("suspended"));
- }
- });
- },
-
- showSilenceModal() {
- let post = this.get("post");
- let user = post.get("user");
- this.get("adminTools").showSilenceModal(user, {
- post,
- before: () => {
- return this.attrs.removeAfter(post.agreeFlags("silenced"));
- }
- });
- },
-
- perform(action) {
- let flaggedPost = this.get("post");
- this.attrs.removeAfter(flaggedPost.agreeFlags(action));
- }
- }
-});
diff --git a/app/assets/javascripts/select-kit/components/admin-delete-flag-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/admin-delete-flag-dropdown.js.es6
deleted file mode 100644
index 416df62d782..00000000000
--- a/app/assets/javascripts/select-kit/components/admin-delete-flag-dropdown.js.es6
+++ /dev/null
@@ -1,84 +0,0 @@
-import DropdownSelectBox from "select-kit/components/dropdown-select-box";
-import computed from "ember-addons/ember-computed-decorators";
-const { get } = Ember;
-
-export default DropdownSelectBox.extend({
- classNames: ["delete-flag", "admin-delete-flag-dropdown"],
- adminTools: Ember.inject.service(),
- nameProperty: "label",
- headerIcon: "far-trash-alt",
-
- computeHeaderContent() {
- let content = this._super(...arguments);
- content.name = `${I18n.t("admin.flags.delete")}...`;
- return content;
- },
-
- @computed("adminTools", "post.user")
- spammerDetails(adminTools, user) {
- return adminTools.spammerDetails(user);
- },
-
- canDeleteSpammer: Ember.computed.and(
- "spammerDetails.canDelete",
- "post.flaggedForSpam"
- ),
-
- computeContent() {
- const content = [];
- const canDeleteSpammer = this.get("canDeleteSpammer");
-
- content.push({
- icon: "external-link-alt",
- id: "delete-defer",
- action: () => this.send("deletePostDeferFlag"),
- label: I18n.t("admin.flags.delete_post_defer_flag"),
- description: I18n.t("admin.flags.delete_post_defer_flag_title")
- });
-
- content.push({
- icon: "thumbs-o-up",
- id: "delete-agree",
- action: () => this.send("deletePostAgreeFlag"),
- label: I18n.t("admin.flags.delete_post_agree_flag"),
- description: I18n.t("admin.flags.delete_post_agree_flag_title")
- });
-
- if (canDeleteSpammer) {
- content.push({
- title: I18n.t("admin.flags.delete_post_agree_flag_title"),
- icon: "exclamation-triangle",
- id: "delete-spammer",
- action: () => this.send("deleteSpammer"),
- label: I18n.t("admin.flags.delete_spammer")
- });
- }
-
- return content;
- },
-
- mutateValue(value) {
- const computedContentItem = this.get("computedContent").findBy(
- "value",
- value
- );
- get(computedContentItem, "originalContent.action")();
- },
-
- actions: {
- deleteSpammer() {
- let spammerDetails = this.get("spammerDetails");
- this.attrs.removeAfter(spammerDetails.deleteUser());
- },
-
- deletePostDeferFlag() {
- let flaggedPost = this.get("post");
- this.attrs.removeAfter(flaggedPost.deferFlags(true));
- },
-
- deletePostAgreeFlag() {
- let flaggedPost = this.get("post");
- this.attrs.removeAfter(flaggedPost.agreeFlags("delete"));
- }
- }
-});
diff --git a/app/assets/javascripts/select-kit/components/category-chooser.js.es6 b/app/assets/javascripts/select-kit/components/category-chooser.js.es6
index f0ae60f6931..6a3b541fd96 100644
--- a/app/assets/javascripts/select-kit/components/category-chooser.js.es6
+++ b/app/assets/javascripts/select-kit/components/category-chooser.js.es6
@@ -105,6 +105,12 @@ export default ComboBoxComponent.extend({
}
},
+ didClearSelection() {
+ if (this.attrs.onChooseCategory) {
+ this.attrs.onChooseCategory(null);
+ }
+ },
+
computeContent() {
return this.categoriesByScope(this.get("scopedCategoryId"));
},
diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6
index eb26c062d67..3be3b65fd1a 100644
--- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6
+++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6
@@ -210,6 +210,7 @@ export default ComboBox.extend(TagsMixin, {
// TODO: FIX buffered-proxy.js to support arrays
this.get("tags").removeObjects(tags);
this.set("tags", this.get("tags").slice(0));
+ this._tagsChanged();
this.set(
"searchDebounce",
@@ -221,9 +222,17 @@ export default ComboBox.extend(TagsMixin, {
this.destroyTags(tags);
},
+ _tagsChanged() {
+ if (this.attrs.onChangeTags) {
+ this.attrs.onChangeTags({ target: { value: this.get("tags") } });
+ }
+ },
+
actions: {
onSelect(tag) {
this.set("tags", makeArray(this.get("tags")).concat(tag));
+ this._tagsChanged();
+
this._prepareSearch(this.get("filter"));
this.autoHighlight();
},
diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6
index cad55bf463f..aa620ad0245 100644
--- a/app/assets/javascripts/select-kit/components/select-kit.js.es6
+++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6
@@ -401,6 +401,8 @@ export default Ember.Component.extend(
willSelect() {},
didSelect() {},
+ didClearSelection() {},
+
willCreate() {},
didCreate() {},
@@ -461,6 +463,7 @@ export default Ember.Component.extend(
clearSelection() {
this.deselect(this.get("selection"));
this.focusFilterOrHeader();
+ this.didClearSelection();
},
actions: {
diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss
index c23f1800d6e..20c8fc58051 100644
--- a/app/assets/stylesheets/common.scss
+++ b/app/assets/stylesheets/common.scss
@@ -5,8 +5,6 @@
@import "common/foundation/mixins";
@import "common/foundation/variables";
@import "common/foundation/spacing";
-@import "common/select-kit/admin-agree-flag-dropdown";
-@import "common/select-kit/admin-delete-flag-dropdown";
@import "common/select-kit/categories-admin-dropdown";
@import "common/select-kit/category-chooser";
@import "common/select-kit/category-drop";
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index def018a7360..d42562f4635 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -723,15 +723,6 @@ section.details {
}
}
-#selected-controls {
- background-color: $tertiary-low;
- padding: 8px;
- min-height: 27px;
- position: fixed;
- bottom: 0;
- width: 1075px;
-}
-
.user-controls {
padding: 5px;
clear: both;
@@ -973,7 +964,6 @@ table#user-badges {
@import "common/admin/dashboard_next";
@import "common/admin/settings";
@import "common/admin/users";
-@import "common/admin/moderation_history";
@import "common/admin/suspend";
@import "common/admin/badges";
@import "common/admin/emails";
diff --git a/app/assets/stylesheets/common/admin/flagging.scss b/app/assets/stylesheets/common/admin/flagging.scss
index f1010ddc35f..f630655869b 100644
--- a/app/assets/stylesheets/common/admin/flagging.scss
+++ b/app/assets/stylesheets/common/admin/flagging.scss
@@ -1,3 +1,4 @@
+
.flagged-post.hidden-post {
.flagged-post-excerpt,
.flagged-post-avatar {
@@ -113,25 +114,6 @@
.flag-user-extra {
display: flex;
align-items: center;
-
- .user-flag-percentage {
- display: flex;
- align-items: center;
- margin-left: 0.5em;
-
- .percentage-label {
- margin-right: 0.25em;
- &.agreed {
- color: $success;
- }
- &.disagreed {
- color: $danger;
- }
- &.ignored {
- color: $primary-medium;
- }
- }
- }
}
}
diff --git a/app/assets/stylesheets/common/admin/moderation_history.scss b/app/assets/stylesheets/common/admin/moderation_history.scss
deleted file mode 100644
index e6cc1573292..00000000000
--- a/app/assets/stylesheets/common/admin/moderation_history.scss
+++ /dev/null
@@ -1,30 +0,0 @@
-.moderation-history {
- width: 100%;
- td.date {
- padding-right: 1em;
- }
- td,
- th {
- padding-bottom: 0.5em;
- vertical-align: top;
- }
- .history-item-action {
- .action-details {
- margin: 1em 0;
- color: $primary-medium;
- white-space: pre-wrap;
- line-height: $line-height-small;
- width: 300px;
- }
- }
-
- .history-item-actor {
- a {
- display: flex;
- align-items: center;
- span {
- margin-left: 0.5em;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss
index ae71cd54c43..84342e829e6 100644
--- a/app/assets/stylesheets/common/base/header.scss
+++ b/app/assets/stylesheets/common/base/header.scss
@@ -78,7 +78,7 @@
.drop-down,
.panel-body {
.flagged-posts,
- .queued-posts {
+ .reviewables {
background: $danger;
min-width: 6px;
}
diff --git a/app/assets/stylesheets/common/base/reviewables.scss b/app/assets/stylesheets/common/base/reviewables.scss
new file mode 100644
index 00000000000..0656f122b2c
--- /dev/null
+++ b/app/assets/stylesheets/common/base/reviewables.scss
@@ -0,0 +1,266 @@
+.reviewable {
+ .reviewable-container {
+ display: flex;
+ flex-direction: row;
+
+ .reviewable-list {
+ flex: 1;
+ box-sizing: border-box;
+ }
+
+ .reviewable-filters {
+ width: 250px;
+ height: 100%;
+ box-sizing: border-box;
+ }
+
+ .reviewable-list + .reviewable-filters {
+ margin-left: 1em;
+ }
+ }
+}
+
+.reviewable-user-details {
+ margin: 0.5em 0;
+}
+
+.no-review {
+ margin-top: 1em;
+}
+
+.reviewable-filters {
+ background-color: $primary-very-low;
+ padding: 0.5em 1em 1em 1em;
+ margin-bottom: 1em;
+
+ .reviewable-filter {
+ display: flex;
+ flex-direction: column;
+ margin: 0 0 1em 0;
+
+ .filter-label {
+ margin: 0 0 0.5em 0;
+ }
+
+ .score-filter {
+ margin: 0;
+ width: 100%;
+ }
+
+ .category-chooser {
+ width: 100%;
+ }
+ }
+}
+
+.reviewable-topics {
+ width: 100%;
+
+ tbody {
+ td {
+ padding: 0.5em;
+ }
+ }
+
+ .reviewable-details {
+ display: flex;
+ justify-content: flex-end;
+
+ .btn {
+ display: flex;
+ align-items: center;
+ }
+ }
+}
+
+.reviewable-filters {
+ .topic-filter .btn {
+ display: flex;
+ width: auto;
+ }
+
+ .refresh {
+ height: 1em;
+ display: flex;
+ }
+
+ .score-filter {
+ width: 5em;
+ }
+}
+.user-flag-percentage {
+ display: flex;
+ align-items: center;
+ margin-left: 0.5em;
+
+ .percentage-label {
+ margin-right: 0.25em;
+ &.agreed {
+ color: $success;
+ }
+ &.disagreed {
+ color: $danger;
+ }
+ &.ignored {
+ color: $primary-medium;
+ }
+ }
+ .d-icon {
+ font-size: 0.9em;
+ }
+}
+
+.reviewable-item {
+ margin-bottom: 1em;
+ border-bottom: 1px solid dark-light-choose($primary-low, $secondary-low);
+
+ .topic-statuses {
+ float: none;
+ }
+
+ .reviewable-meta-data {
+ color: dark-light-choose($primary-medium, $secondary-medium);
+ display: flex;
+ width: 100%;
+ margin-bottom: 0.5em;
+ font-size: $font-down-1;
+ align-items: center;
+ .reviewable-type {
+ margin-right: 1em;
+ }
+ .created-at {
+ a {
+ color: dark-light-choose($primary-medium, $secondary-medium);
+ }
+ margin-right: 1em;
+ }
+ .score {
+ margin-right: 1em;
+ }
+ .status {
+ color: dark-light-choose($primary-high, $secondary-high);
+ }
+ }
+
+ .reviewable-contents {
+ display: flex;
+ }
+
+ .reviewable-actions {
+ margin-top: 0.5em;
+ display: flex;
+
+ .reviewable-action,
+ .reviewable-action-dropdown {
+ margin-right: 0.5em;
+
+ &.delete-user {
+ @extend .btn-danger;
+ }
+ }
+ }
+ padding-bottom: 1em;
+}
+
+.reviewable-histories {
+ margin-top: 1em;
+}
+
+.reviewable-scores,
+.reviewable-histories {
+ min-width: 50%;
+
+ .user {
+ display: flex;
+ align-items: center;
+
+ .user-flag-percentage {
+ margin-left: 0.5em;
+ }
+ }
+ > tr > th {
+ text-align: left;
+ }
+ > tr > th,
+ > tr > td {
+ padding: 0.25em;
+ }
+}
+
+.reviewable-conversation {
+ margin: 0.5em 0;
+
+ .reviewable-conversation-post {
+ max-width: $topic-body-width;
+ margin-bottom: 0.5em;
+
+ .username {
+ font-weight: bold;
+ margin-right: 0.25em;
+ }
+ }
+
+ .controls {
+ margin-top: 0.25em;
+ }
+}
+
+.reviewable-queued-post,
+.reviewable-flagged-post {
+ .reviewable-contents {
+ margin-top: 1em;
+ }
+
+ .post-title {
+ background-color: yellow;
+ }
+ .created-by {
+ margin-right: 1em;
+ }
+
+ .post-contents {
+ width: 100%;
+ }
+ .post-topic {
+ font-weight: bold;
+ color: $primary-medium;
+ margin-bottom: 1em;
+ }
+}
+
+.reviewable-item {
+ .post-body {
+ max-width: $topic-body-width;
+ max-height: 300px;
+ overflow-y: auto;
+
+ p,
+ aside {
+ margin: 0 0 1em 0;
+ }
+
+ p {
+ word-break: break-all;
+ }
+ }
+}
+
+.editable-fields {
+ width: 100%;
+ .editable-field {
+ .mini-tag-chooser {
+ margin: 0;
+ }
+
+ .reviewable-input-text {
+ width: 100%;
+ margin-bottom: 0;
+ }
+
+ .reviewable-input-textarea {
+ width: 100%;
+ height: 10em;
+ }
+ margin-bottom: 0.5em;
+ }
+}
diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss
index de3a1759af9..b54bca6097b 100644
--- a/app/assets/stylesheets/common/base/topic.scss
+++ b/app/assets/stylesheets/common/base/topic.scss
@@ -154,11 +154,11 @@ a.badge-category {
}
.has-pending-posts {
+ display: flex;
+ justify-content: space-between;
+
padding: 0.5em;
background-color: $highlight-medium;
- a[href] {
- float: right;
- }
margin-top: 1em;
max-width: 757px;
}
diff --git a/app/assets/stylesheets/common/printer-friendly.scss b/app/assets/stylesheets/common/printer-friendly.scss
index 6efbb961793..38b13c29b1f 100644
--- a/app/assets/stylesheets/common/printer-friendly.scss
+++ b/app/assets/stylesheets/common/printer-friendly.scss
@@ -28,7 +28,6 @@
.edit-topic,
a.reply-to-tab,
a.reply-new,
- div.has-pending-posts,
div.time-gap,
#bottom,
#footer,
diff --git a/app/assets/stylesheets/common/select-kit/admin-agree-flag-dropdown.scss b/app/assets/stylesheets/common/select-kit/admin-agree-flag-dropdown.scss
deleted file mode 100644
index e6a4055c1ef..00000000000
--- a/app/assets/stylesheets/common/select-kit/admin-agree-flag-dropdown.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-.select-kit {
- &.dropdown-select-box {
- &.admin-agree-flag-dropdown {
- .select-kit-body {
- width: 485px;
- max-width: 485px;
- }
- .select-kit-row[data-value="delete-spammer"] .texts .name,
- .select-kit-row[data-value="delete-spammer"] .icons .d-icon {
- color: $danger;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/common/select-kit/admin-delete-flag-dropdown.scss b/app/assets/stylesheets/common/select-kit/admin-delete-flag-dropdown.scss
deleted file mode 100644
index c76f9e6dd21..00000000000
--- a/app/assets/stylesheets/common/select-kit/admin-delete-flag-dropdown.scss
+++ /dev/null
@@ -1,16 +0,0 @@
-.select-kit {
- &.dropdown-select-box {
- width: auto;
- &.admin-delete-flag-dropdown {
- .dropdown-select-box-header .btn {
- background: $danger;
- color: white;
- }
-
- .select-kit-row[data-value="delete-spammer"] .texts .name,
- .select-kit-row[data-value="delete-spammer"] .icons .d-icon {
- color: $danger;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss
index 4e4a7e4cbd6..76012fc9c09 100644
--- a/app/assets/stylesheets/desktop.scss
+++ b/app/assets/stylesheets/desktop.scss
@@ -17,7 +17,6 @@
@import "desktop/upload";
@import "desktop/user";
@import "desktop/history";
-@import "desktop/queued-posts";
@import "desktop/group";
@import "desktop/admin_customize";
diff --git a/app/assets/stylesheets/desktop/queued-posts.scss b/app/assets/stylesheets/desktop/queued-posts.scss
deleted file mode 100644
index 88f600fc8a4..00000000000
--- a/app/assets/stylesheets/desktop/queued-posts.scss
+++ /dev/null
@@ -1,53 +0,0 @@
-.queued-posts {
- .queued-post {
- padding: 1em 0;
-
- .poster {
- width: 70px;
- float: left;
- }
- .post-info {
- display: inline-block;
- float: right;
- font-size: $font-down-1;
- margin-top: 1px;
- span {
- color: dark-light-choose($primary-medium, $secondary-medium);
- }
- }
-
- .cooked {
- width: $topic-body-width;
- float: left;
-
- .d-editor-input {
- width: 98%;
- height: 15em;
- }
- }
-
- .tag-chooser {
- width: 100%;
- margin-bottom: 0.5em;
-
- .select-kit-collection {
- padding: 0;
- }
- }
-
- .queue-controls {
- button {
- float: left;
- margin-right: 0.5em;
- }
- }
- .post-title {
- color: $primary-medium;
- font-weight: bold;
-
- .badge-wrapper {
- margin-left: 1em;
- }
- }
- }
-}
diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss
index 12cc495d606..daff963152e 100644
--- a/app/assets/stylesheets/mobile.scss
+++ b/app/assets/stylesheets/mobile.scss
@@ -29,6 +29,7 @@
@import "mobile/admin_report_table";
@import "mobile/admin_report_counters";
@import "mobile/menu-panel";
+@import "mobile/reviewables";
// Import all component-specific files
@import "mobile/components/*";
diff --git a/app/assets/stylesheets/mobile/reviewables.scss b/app/assets/stylesheets/mobile/reviewables.scss
new file mode 100644
index 00000000000..4d8d074d0d2
--- /dev/null
+++ b/app/assets/stylesheets/mobile/reviewables.scss
@@ -0,0 +1,76 @@
+.reviewable {
+ .reviewable-container {
+ flex-direction: column;
+
+ .reviewable-list {
+ order: 2;
+ width: 100%;
+ }
+
+ .reviewable-filters {
+ order: 1;
+ margin: 0;
+ padding: 0.5em;
+ width: 100%;
+ }
+
+ .reviewable-list + .reviewable-filters {
+ margin: 0 0 0.5em 0;
+ }
+ }
+
+ .reviewable-scores {
+ width: 100%;
+ }
+}
+
+.reviewable-filters {
+ background-color: $primary-very-low;
+ padding: 0.5em 1em 1em 1em;
+ margin-bottom: 1em;
+
+ .reviewable-filters-actions {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .reviewable-filter {
+ margin: 0 0 0.5em 0;
+
+ .filter-label {
+ margin: 0;
+ }
+ }
+}
+
+.reviewable-contents {
+ .post-body {
+ max-width: 295px;
+
+ p {
+ overflow-x: scroll;
+ }
+ }
+}
+
+.reviewable-actions {
+ .reviewable-action,
+ .reviewable-action-dropdown .dropdown-select-box-header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .d-icon {
+ margin: 0;
+ }
+ }
+
+ .reviewable-action,
+ .reviewable-action-dropdown {
+ flex: 1;
+ }
+
+ .reviewable-action-dropdown:last-of-type {
+ margin-right: 0;
+ }
+}
diff --git a/app/controllers/admin/flags_controller.rb b/app/controllers/admin/flags_controller.rb
index 5df6bcd0f06..75e97015779 100644
--- a/app/controllers/admin/flags_controller.rb
+++ b/app/controllers/admin/flags_controller.rb
@@ -7,8 +7,7 @@ class Admin::FlagsController < Admin::AdminController
end
def index
- # we may get out of sync, fix it here
- PostAction.update_flagged_posts_count
+ Discourse.deprecate("FlagsController#index has been deprecated, please use the Reviewable API instead", since: "2.3.0beta5", drop_from: "2.4")
offset = params[:offset].to_i
per_page = Admin::FlagsController.flags_per_page
@@ -19,47 +18,40 @@ class Admin::FlagsController < Admin::AdminController
user_id: params[:user_id],
offset: offset,
topic_id: params[:topic_id],
- per_page: per_page,
- rest_api: params[:rest_api].present?
+ per_page: per_page
)
- if params[:rest_api]
- meta = {
- types: {
- disposed_by: 'user'
- }
+ meta = {
+ types: {
+ disposed_by: 'user'
}
+ }
- if (total_rows || 0) > (offset + per_page)
- meta[:total_rows_flagged_posts] = total_rows
- meta[:load_more_flagged_posts] = admin_flags_filtered_path(
- filter: params[:filter],
- offset: offset + per_page,
- rest_api: params[:rest_api],
- topic_id: params[:topic_id]
- )
- end
-
- render_json_dump(
- {
- flagged_posts: posts,
- topics: serialize_data(topics, FlaggedTopicSerializer),
- users: serialize_data(users, FlaggedUserSerializer),
- post_actions: post_actions
- },
- rest_serializer: true,
- meta: meta
- )
- else
- render_json_dump(
- posts: posts,
- topics: serialize_data(topics, FlaggedTopicSerializer),
- users: serialize_data(users, FlaggedUserSerializer)
+ next_segment = offset + per_page
+ if (total_rows || 0) > next_segment
+ meta[:total_rows_flagged_posts] = total_rows
+ meta[:load_more_flagged_posts] = admin_flags_filtered_path(
+ filter: params[:filter],
+ offset: next_segment,
+ topic_id: params[:topic_id]
)
end
+
+ render_json_dump(
+ {
+ flagged_posts: posts,
+ topics: serialize_data(topics, FlaggedTopicSerializer),
+ users: serialize_data(users, FlaggedUserSerializer),
+ post_actions: post_actions
+ },
+ rest_serializer: true,
+ meta: meta
+ )
end
def agree
+ Discourse.deprecate("FlagsController#agree has been deprecated, please use the Reviewable API instead", since: "2.3.0beta5", drop_from: "2.4")
+
params.permit(:id, :action_on_post)
post = Post.find(params[:id])
@@ -71,65 +63,70 @@ class Admin::FlagsController < Admin::AdminController
user: current_user
)
- post_action_type = PostAction.post_action_type_for_post(post.id)
-
- if !post_action_type
- render_json_error(
- I18n.t("flags.errors.already_handled"),
- status: 409
- )
- return
- end
+ reviewable = post.reviewable_flag
+ return render_json_error(I18n.t("flags.errors.already_handled"), status: 409) if reviewable.blank?
keep_post = ['silenced', 'suspended', 'keep'].include?(params[:action_on_post])
delete_post = params[:action_on_post] == "delete"
restore_post = params[:action_on_post] == "restore"
if delete_post
- # PostDestroy calls PostAction.agree_flags!
+ # PostDestroy automatically agrees with flags
destroy_post(post)
elsif restore_post
- PostAction.agree_flags!(post, current_user, delete_post)
- PostDestroyer.new(current_user, post).recover
+ reviewable.perform(current_user, :agree_and_restore)
else
- PostAction.agree_flags!(post, current_user, delete_post)
- if !keep_post
- PostAction.hide_post!(post, post_action_type)
- end
+ reviewable.perform(
+ current_user,
+ :agree_and_keep,
+ post_was_deleted: delete_post,
+ hide_post: !keep_post
+ )
end
render body: nil
end
def disagree
+ Discourse.deprecate("FlagsController#disagree has been deprecated, please use the Reviewable API instead", since: "2.3.0beta5", drop_from: "2.4")
params.permit(:id)
post = Post.find(params[:id])
- DiscourseEvent.trigger(
- :before_staff_flag_action,
- type: 'disagree',
- post: post,
- user: current_user
- )
+ if reviewable = post.reviewable_flag
+ DiscourseEvent.trigger(
+ :before_staff_flag_action,
+ type: 'disagree',
+ post: post,
+ user: current_user
+ )
- PostAction.clear_flags!(post, current_user)
+ if post.hidden?
+ reviewable.perform(current_user, :disagree_and_restore)
+ else
+ reviewable.perform(current_user, :disagree)
+ end
+ end
render body: nil
end
def defer
+ Discourse.deprecate("FlagsController#defer has been deprecated, please use the Reviewable API instead", since: "2.3.0beta5", drop_from: "2.4")
+
params.permit(:id, :delete_post)
post = Post.find(params[:id])
- DiscourseEvent.trigger(
- :before_staff_flag_action,
- type: 'defer',
- post: post,
- user: current_user
- )
+ if reviewable = post.reviewable_flag
+ DiscourseEvent.trigger(
+ :before_staff_flag_action,
+ type: 'defer',
+ post: post,
+ user: current_user
+ )
- PostAction.defer_flags!(post, current_user, params[:delete_post])
- destroy_post(post) if params[:delete_post]
+ reviewable.perform(current_user, :ignore, post_was_deleted: params[:delete_post])
+ destroy_post(post) if params[:delete_post]
+ end
render body: nil
end
diff --git a/app/controllers/admin/moderation_history_controller.rb b/app/controllers/admin/moderation_history_controller.rb
deleted file mode 100644
index d5516dd0e05..00000000000
--- a/app/controllers/admin/moderation_history_controller.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-class Admin::ModerationHistoryController < Admin::AdminController
-
- def index
- history_filter = params[:filter]
- raise Discourse::NotFound unless ['post', 'topic'].include?(history_filter)
-
- query = UserHistory.where(
- action: UserHistory.actions.only(
- :delete_user,
- :suspend_user,
- :silence_user,
- :delete_post,
- :delete_topic,
- :post_approved,
- ).values
- )
-
- case history_filter
- when 'post'
- raise Discourse::NotFound if params[:post_id].blank?
- query = query.where(post_id: params[:post_id])
- when 'topic'
- raise Discourse::NotFound if params[:topic_id].blank?
- query = query.where(
- "topic_id = ? OR post_id IN (?)",
- params[:topic_id],
- Post.with_deleted.where(topic_id: params[:topic_id]).pluck(:id)
- )
- end
- query = query.includes(:acting_user)
- query = query.order(:created_at)
-
- render_serialized(
- query,
- UserHistorySerializer,
- root: 'moderation_history',
- rest_serializer: true
- )
- end
-
-end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 98eec91baf2..f231f612302 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -288,15 +288,14 @@ class Admin::UsersController < Admin::AdminController
end
def approve
- guardian.ensure_can_approve!(@user)
- @user.approve(current_user)
+ Discourse.deprecate("AdminUsersController#approve is deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+ Reviewable.bulk_perform_targets(current_user, :approve, 'ReviewableUser', [@user.id])
render body: nil
end
def approve_bulk
- User.where(id: params[:users]).each do |u|
- u.approve(current_user) if guardian.can_approve?(u)
- end
+ Discourse.deprecate("AdminUsersController#approve_bulk is deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+ Reviewable.bulk_perform_targets(current_user, :approve, 'ReviewableUser', params[:users])
render body: nil
end
@@ -366,7 +365,10 @@ class Admin::UsersController < Admin::AdminController
)
end
+ # Kept for backwards compatibility, but is replaced by the Reviewable Queue
def reject_bulk
+ Discourse.deprecate("AdminUsersController#reject_bulk is deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+
success_count = 0
d = UserDestroyer.new(current_user)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 007288e7779..6fd9b79daf3 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -600,7 +600,16 @@ class ApplicationController < ActionController::Base
opts = { status: opts } if opts.is_a?(Integer)
opts.fetch(:headers, {}).each { |name, value| headers[name.to_s] = value }
- render json: MultiJson.dump(create_errors_json(obj, opts)), status: opts[:status] || 422
+ render(
+ json: MultiJson.dump(create_errors_json(obj, opts)),
+ status: opts[:status] || status_code(obj)
+ )
+ end
+
+ def status_code(obj)
+ return 403 if obj.try(:forbidden)
+ return 404 if obj.try(:not_found)
+ 422
end
def success_json
@@ -761,7 +770,7 @@ class ApplicationController < ActionController::Base
protected
- def render_post_json(post, add_raw = true)
+ def render_post_json(post, add_raw: true)
post_serializer = PostSerializer.new(post, scope: guardian, root: false)
post_serializer.add_raw = add_raw
diff --git a/app/controllers/post_actions_controller.rb b/app/controllers/post_actions_controller.rb
index 81b30d6345b..e401856584a 100644
--- a/app/controllers/post_actions_controller.rb
+++ b/app/controllers/post_actions_controller.rb
@@ -9,60 +9,52 @@ class PostActionsController < ApplicationController
def create
raise Discourse::NotFound if @post.blank?
- taken = PostAction.counts_for([@post], current_user)[@post.id]
-
- guardian.ensure_post_can_act!(
+ creator = PostActionCreator.new(
+ current_user,
@post,
- PostActionType.types[@post_action_type_id],
- opts: {
- is_warning: params[:is_warning],
- taken_actions: taken
- }
+ @post_action_type_id,
+ is_warning: params[:is_warning],
+ message: params[:message],
+ take_action: params[:take_action] == 'true',
+ flag_topic: params[:flag_topic] == 'true'
)
+ result = creator.perform
- args = {}
- args[:message] = params[:message] if params[:message].present?
- args[:is_warning] = params[:is_warning] if params[:is_warning].present? && guardian.is_staff?
- args[:take_action] = true if guardian.is_staff? && params[:take_action] == 'true'
- args[:flag_topic] = true if params[:flag_topic] == 'true'
-
- begin
- post_action = PostAction.act(current_user, @post, @post_action_type_id, args)
- rescue PostAction::FailedToCreatePost => e
- return render_json_error(e.message)
- end
-
- if post_action.blank? || post_action.errors.present?
- render_json_error(post_action)
+ if result.failed?
+ render_json_error(result)
else
# We need to reload or otherwise we are showing the old values on the front end
@post.reload
if @post_action_type_id == PostActionType.types[:like]
- limiter = post_action.post_action_rate_limiter
+ limiter = result.post_action.post_action_rate_limiter
response.headers['Discourse-Actions-Remaining'] = limiter.remaining.to_s
response.headers['Discourse-Actions-Max'] = limiter.max.to_s
end
- render_post_json(@post, _add_raw = false)
+ render_post_json(@post, add_raw: false)
end
end
def destroy
- post_action = current_user.post_actions.find_by(post_id: params[:id].to_i, post_action_type_id: @post_action_type_id, deleted_at: nil)
- raise Discourse::NotFound if post_action.blank?
+ result = PostActionDestroyer.new(
+ current_user,
+ Post.find_by(id: params[:id].to_i),
+ @post_action_type_id
+ ).perform
- guardian.ensure_can_delete!(post_action)
-
- PostAction.remove_act(current_user, @post, post_action.post_action_type_id)
-
- @post.reload
- render_post_json(@post, _add_raw = false)
+ if result.failed?
+ render_json_error(result)
+ else
+ render_post_json(result.post, add_raw: false)
+ end
end
def defer_flags
guardian.ensure_can_defer_flags!(@post)
- PostAction.defer_flags!(@post, current_user)
+ if reviewable = @post.reviewable_flag
+ reviewable.perform(current_user, :ignore)
+ end
render json: { success: true }
end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 8317bb665c8..ce084cc08e9 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -1,5 +1,6 @@
require_dependency 'new_post_manager'
require_dependency 'post_creator'
+require_dependency 'post_action_destroyer'
require_dependency 'post_destroyer'
require_dependency 'post_merger'
require_dependency 'distributed_memoizer'
@@ -478,7 +479,8 @@ class PostsController < ApplicationController
def bookmark
if params[:bookmarked] == "true"
post = find_post_from_params
- PostAction.act(current_user, post, PostActionType.types[:bookmark])
+ result = PostActionCreator.create(current_user, post, :bookmark)
+ return render_json_error(result) if result.failed?
else
post_action = PostAction.find_by(post_id: params[:post_id], user_id: current_user.id)
raise Discourse::NotFound unless post_action
@@ -486,7 +488,8 @@ class PostsController < ApplicationController
post = Post.with_deleted.find_by(id: post_action&.post_id)
raise Discourse::NotFound unless post
- PostAction.remove_act(current_user, post, PostActionType.types[:bookmark])
+ result = PostActionDestroyer.destroy(current_user, post, :bookmark)
+ return render_json_error(result) if result.failed?
end
topic_user = TopicUser.get(post.topic, current_user)
diff --git a/app/controllers/queued_posts_controller.rb b/app/controllers/queued_posts_controller.rb
index 6b9dadb7cc7..e2afe3d590c 100644
--- a/app/controllers/queued_posts_controller.rb
+++ b/app/controllers/queued_posts_controller.rb
@@ -5,49 +5,50 @@ class QueuedPostsController < ApplicationController
before_action :ensure_staff
def index
- state = QueuedPost.states[(params[:state] || 'new').to_sym]
- state ||= QueuedPost.states[:new]
+ Discourse.deprecate("QueuedPostController#index is deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
- @queued_posts = QueuedPost.visible.where(state: state).includes(:topic, :user).order(:created_at)
- render_serialized(@queued_posts,
+ status = params[:state] || 'pending'
+ status = 'pending' if status == 'new'
+
+ reviewables = Reviewable.list_for(current_user, status: status.to_sym, type: ReviewableQueuedPost.name)
+ render_serialized(reviewables,
QueuedPostSerializer,
root: :queued_posts,
rest_serializer: true,
refresh_queued_posts: "/queued_posts?status=new")
-
end
def update
- qp = QueuedPost.where(id: params[:id]).first
-
- return render_json_error I18n.t('queue.not_found') if qp.blank?
+ Discourse.deprecate("QueuedPostController#update is deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+ reviewable = Reviewable.find_by(id: params[:id])
+ raise Discourse::NotFound if reviewable.blank?
update_params = params[:queued_post]
- qp.raw = update_params[:raw] if update_params[:raw].present?
- if qp.topic_id.blank? && params[:queued_post][:state].blank?
- qp.post_options['title'] = update_params[:title] if update_params[:title].present?
- qp.post_options['category'] = update_params[:category_id].to_i if update_params[:category_id].present?
- qp.post_options['tags'] = update_params[:tags]
+ reviewable.payload['raw'] = update_params[:raw] if update_params[:raw].present?
+ if reviewable.topic_id.blank? && update_params[:state].blank?
+ reviewable.payload['title'] = update_params[:title] if update_params[:title].present?
+ reviewable.payload['tags'] = update_params[:tags]
+ reviewable.category_id = update_params[:category_id].to_i if update_params[:category_id].present?
end
- qp.save(validate: false)
+ reviewable.save(validate: false)
- state = params[:queued_post][:state]
+ state = update_params[:state]
begin
if state == 'approved'
- qp.approve!(current_user)
+ reviewable.perform(current_user, :approve)
elsif state == 'rejected'
- qp.reject!(current_user)
- if params[:queued_post][:delete_user] == 'true' && guardian.can_delete_user?(qp.user)
- UserDestroyer.new(current_user).destroy(qp.user, user_deletion_opts)
+ reviewable.perform(current_user, :reject)
+ if update_params[:delete_user] == 'true' && guardian.can_delete_user?(reviewable.created_by)
+ UserDestroyer.new(current_user).destroy(reviewable.created_by, user_deletion_opts)
end
end
rescue StandardError => e
return render_json_error e.message
end
- render_serialized(qp, QueuedPostSerializer, root: :queued_posts)
+ render_serialized(reviewable, QueuedPostSerializer, root: :queued_posts)
end
private
diff --git a/app/controllers/reviewables_controller.rb b/app/controllers/reviewables_controller.rb
new file mode 100644
index 00000000000..2a18b64fd47
--- /dev/null
+++ b/app/controllers/reviewables_controller.rb
@@ -0,0 +1,168 @@
+class ReviewablesController < ApplicationController
+ requires_login
+
+ PER_PAGE = 10
+
+ before_action :version_required, only: [:update, :perform]
+
+ def index
+ min_score = params[:min_score].nil? ? SiteSetting.min_score_default_visibility : params[:min_score].to_f
+ offset = params[:offset].to_i
+
+ if params[:type].present?
+ raise Discourse::InvalidParameter.new(:type) unless Reviewable.valid_type?(params[:type])
+ end
+
+ status = (params[:status] || 'pending').to_sym
+ raise Discourse::InvalidParameter.new(:status) unless allowed_statuses.include?(status)
+
+ topic_id = params[:topic_id] ? params[:topic_id].to_i : nil
+ category_id = params[:category_id] ? params[:category_id].to_i : nil
+
+ filters = {
+ status: status,
+ category_id: category_id,
+ topic_id: topic_id,
+ min_score: min_score,
+ username: params[:username],
+ type: params[:type]
+ }
+
+ total_rows = Reviewable.list_for(current_user, filters).count
+ reviewables = Reviewable.list_for(current_user, filters.merge(limit: PER_PAGE, offset: offset)).to_a
+
+ # This is a bit awkward, but ActiveModel serializers doesn't seem to serialize STI. Note `hash`
+ # is mutated by the serializer and contains the side loaded records which must be merged in the end.
+ hash = {}
+ json = {
+ reviewables: reviewables.map! do |r|
+ result = r.serializer.new(r, root: nil, hash: hash, scope: guardian).as_json
+ hash[:bundled_actions].uniq!
+ (hash['actions'] || []).uniq!
+ result
+ end,
+ meta: filters.merge(
+ total_rows_reviewables: total_rows, types: meta_types, reviewable_types: Reviewable.types
+ )
+ }
+ if (offset + PER_PAGE) < total_rows
+ json[:meta][:load_more_reviewables] = review_path(filters.merge(offset: offset + PER_PAGE))
+ end
+ json.merge!(hash)
+
+ render_json_dump(json, rest_serializer: true)
+ end
+
+ def topics
+ topic_ids = Set.new
+
+ stats = {}
+ unique_users = {}
+
+ # topics isn't indexed on `reviewable_score` and doesn't know what the current user can see,
+ # so let's query from the inside out.
+ Reviewable.viewable_by(current_user).pending.each do |r|
+ topic_ids << r.topic_id
+
+ meta = stats[r.topic_id] ||= { count: 0, unique_users: 0 }
+ users = unique_users[r.topic_id] ||= Set.new
+
+ r.reviewable_scores.each do |rs|
+ users << rs.user_id
+ meta[:count] += 1
+ end
+ meta[:unique_users] = users.size
+ end
+
+ topics = Topic.where(id: topic_ids).order('reviewable_score DESC')
+ render_serialized(topics, ReviewableTopicSerializer, root: 'reviewable_topics', stats: stats)
+ end
+
+ def show
+ reviewable = find_reviewable
+
+ render_serialized(
+ reviewable,
+ reviewable.serializer,
+ rest_serializer: true,
+ root: 'reviewable',
+ meta: {
+ types: meta_types
+ }
+ )
+ end
+
+ def update
+ reviewable = find_reviewable
+ editable = reviewable.editable_for(guardian)
+ raise Discourse::InvalidAccess.new unless editable.present?
+
+ # Validate parameters are all editable
+ edit_params = params[:reviewable] || {}
+ edit_params.each do |name, value|
+ if value.is_a?(ActionController::Parameters)
+ value.each do |pay_name, pay_value|
+ raise Discourse::InvalidAccess.new unless editable.has?("#{name}.#{pay_name}")
+ end
+ else
+ raise Discourse::InvalidAccess.new unless editable.has?(name)
+ end
+ end
+
+ begin
+ if reviewable.update_fields(edit_params, current_user, version: params[:version].to_i)
+ result = edit_params.merge(version: reviewable.version)
+ render json: result
+ else
+ render_json_error(reviewable.errors)
+ end
+ rescue Reviewable::UpdateConflict
+ return render_json_error(I18n.t('reviewables.conflict'), status: 409)
+ end
+ end
+
+ def perform
+ args = { version: params[:version].to_i }
+
+ begin
+ result = find_reviewable.perform(current_user, params[:action_id].to_sym, args)
+ rescue Reviewable::InvalidAction => e
+ # Consider InvalidAction an InvalidAccess
+ raise Discourse::InvalidAccess.new(e.message)
+ rescue Reviewable::UpdateConflict
+ return render_json_error(I18n.t('reviewables.conflict'), status: 409)
+ end
+
+ if result.success?
+ render_serialized(result, ReviewablePerformResultSerializer)
+ else
+ render_json_error(result)
+ end
+ end
+
+protected
+
+ def find_reviewable
+ reviewable = Reviewable.viewable_by(current_user).where(id: params[:reviewable_id]).first
+ raise Discourse::NotFound.new if reviewable.blank?
+ reviewable
+ end
+
+ def allowed_statuses
+ @allowed_statuses ||= (%i[reviewed all] + Reviewable.statuses.keys)
+ end
+
+ def version_required
+ if params[:version].blank?
+ render_json_error(I18n.t('reviewables.missing_version'), status: 422)
+ end
+ end
+
+ def meta_types
+ {
+ created_by: 'user',
+ target_created_by: 'user'
+ }
+ end
+
+end
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index f65daef0a18..016e127bf21 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -5,6 +5,7 @@ require_dependency 'topics_bulk_action'
require_dependency 'discourse_event'
require_dependency 'rate_limiter'
require_dependency 'topic_publisher'
+require_dependency 'post_action_destroyer'
class TopicsController < ApplicationController
requires_login only: [
@@ -430,7 +431,7 @@ class TopicsController < ApplicationController
.where(user_id: current_user.id)
.where('topic_id = ?', topic.id).each do |pa|
- PostAction.remove_act(current_user, pa.post, PostActionType.types[:bookmark])
+ PostActionDestroyer.destroy(current_user, pa.post, :bookmark)
end
render body: nil
@@ -483,9 +484,8 @@ class TopicsController < ApplicationController
topic = Topic.find(params[:topic_id].to_i)
first_post = topic.ordered_posts.first
- guardian.ensure_can_see!(first_post)
-
- PostAction.act(current_user, first_post, PostActionType.types[:bookmark])
+ result = PostActionCreator.create(current_user, first_post, :bookmark)
+ return render_json_error(result) if result.failed?
render body: nil
end
diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb
index 2d26bbbf34d..a66528dcee2 100644
--- a/app/controllers/user_actions_controller.rb
+++ b/app/controllers/user_actions_controller.rb
@@ -22,15 +22,7 @@ class UserActionsController < ApplicationController
acting_username: params[:acting_username]
}
- # Pending is restricted
- stream = if opts[:action_types].include?(UserAction::PENDING)
- guardian.ensure_can_see_notifications!(user)
- UserAction.stream_queued(opts)
- else
- UserAction.stream(opts)
- end
-
- stream = stream.to_a
+ stream = UserAction.stream(opts).to_a
if stream.length == 0 && (help_key = params['no_results_help_key'])
if user.id == guardian.user.try(:id)
help_key += ".self"
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index d55d07a99b3..012dc86983b 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -354,10 +354,7 @@ class UsersController < ApplicationController
user = User.new(new_user_params) if user.nil?
# Handle API approval
- if user.approved
- user.approved_by_id ||= current_user.id
- user.approved_at ||= Time.zone.now
- end
+ ReviewableUser.set_approved_fields!(user, current_user) if user.approved?
# Handle custom fields
user_fields = UserField.all
diff --git a/app/jobs/base.rb b/app/jobs/base.rb
index b57abae3e20..a5df165a0af 100644
--- a/app/jobs/base.rb
+++ b/app/jobs/base.rb
@@ -305,7 +305,6 @@ module Jobs
end
klass.client_push(hash)
-
else
# Otherwise execute the job right away
opts.delete(:delay_for)
diff --git a/app/jobs/regular/notify_reviewable.rb b/app/jobs/regular/notify_reviewable.rb
new file mode 100644
index 00000000000..734121a5e10
--- /dev/null
+++ b/app/jobs/regular/notify_reviewable.rb
@@ -0,0 +1,52 @@
+class Jobs::NotifyReviewable < Jobs::Base
+
+ def execute(args)
+ reviewable = Reviewable.find_by(id: args[:reviewable_id])
+ return unless reviewable.present?
+
+ @contacted = Set.new
+
+ notify_admins
+ notify_moderators if reviewable.reviewable_by_moderator?
+ notify_group(reviewable.reviewable_by_group) if reviewable.reviewable_by_group.present?
+ end
+
+protected
+
+ def users
+ return User if @contacted.blank?
+ User.where("id NOT IN (?)", @contacted)
+ end
+
+ def pending
+ Reviewable.default_visible.pending
+ end
+
+ def notify_admins
+ notify(pending.count, users.admins.pluck(:id))
+ end
+
+ def notify_moderators
+ user_ids = users.moderators.pluck(:id)
+ notify(pending.where(reviewable_by_moderator: true).count, user_ids)
+ end
+
+ def notify_group(group)
+ @group_counts = {}
+ group.users.includes(:group_users).where("users.id NOT IN (?)", @contacted).each do |u|
+ reviewable_count = u.group_users.map { |gu| count_for_group(gu.group_id) }.sum
+ MessageBus.publish("/reviewable_counts", { reviewable_count: reviewable_count }, user_ids: [u.id])
+ end
+ end
+
+ def count_for_group(group_id)
+ @group_counts[group_id] ||= pending.where(reviewable_by_group_id: group_id).count
+ end
+
+ def notify(count, user_ids)
+ data = { reviewable_count: count }
+ MessageBus.publish("/reviewable_counts", data, user_ids: user_ids)
+ @contacted += user_ids
+ end
+
+end
diff --git a/app/jobs/regular/process_post.rb b/app/jobs/regular/process_post.rb
index ef06ea52dc2..d6b3afa4d27 100644
--- a/app/jobs/regular/process_post.rb
+++ b/app/jobs/regular/process_post.rb
@@ -42,7 +42,7 @@ module Jobs
s = post.cooked
s << " #{post.topic.title}" if post.post_number == 1
if !args[:bypass_bump] && WordWatcher.new(s).should_flag?
- PostAction.act(Discourse.system_user, post, PostActionType.types[:inappropriate]) rescue PostAction::AlreadyActed
+ PostActionCreator.create(Discourse.system_user, post, :inappropriate)
end
end
end
diff --git a/app/jobs/regular/toggle_topic_closed.rb b/app/jobs/regular/toggle_topic_closed.rb
index 99a446af106..bb3412fc2d6 100644
--- a/app/jobs/regular/toggle_topic_closed.rb
+++ b/app/jobs/regular/toggle_topic_closed.rb
@@ -15,7 +15,7 @@ module Jobs
user = topic_timer.user
if Guardian.new(user).can_close?(topic)
- if state == false && PostAction.auto_close_threshold_reached?(topic)
+ if state == false && topic.auto_close_threshold_reached?
topic.set_or_create_timer(
TopicTimer.types[:open],
SiteSetting.num_hours_to_close_topic,
diff --git a/app/jobs/regular/topic_action_converter.rb b/app/jobs/regular/topic_action_converter.rb
new file mode 100644
index 00000000000..eb7a43ab1be
--- /dev/null
+++ b/app/jobs/regular/topic_action_converter.rb
@@ -0,0 +1,18 @@
+class Jobs::TopicActionConverter < Jobs::Base
+
+ # Re-creating all the user actions could be very slow, so let's do it in a job
+ # to avoid a N+1 query on a front facing operation.
+ def execute(args)
+ topic = Topic.find_by(id: args[:topic_id])
+ return if topic.blank?
+
+ UserAction.where(
+ target_topic_id: topic.id,
+ action_type: [UserAction::GOT_PRIVATE_MESSAGE, UserAction::NEW_PRIVATE_MESSAGE]).find_each do |ua|
+ UserAction.remove_action!(ua.attributes.symbolize_keys.slice(:action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id))
+ end
+ topic.posts.each { |post| UserActionManager.post_created(post) }
+ UserActionManager.topic_created(topic)
+ end
+
+end
diff --git a/app/jobs/regular/truncate_user_flag_stats.rb b/app/jobs/regular/truncate_user_flag_stats.rb
new file mode 100644
index 00000000000..9de009a7524
--- /dev/null
+++ b/app/jobs/regular/truncate_user_flag_stats.rb
@@ -0,0 +1,48 @@
+class Jobs::TruncateUserFlagStats < Jobs::Base
+
+ def self.truncate_to
+ 100
+ end
+
+ # To give users a chance to improve, we limit their flag stats to the last N flags
+ def execute(args)
+ raise Discourse::InvalidParameters.new(:user_ids) unless args[:user_ids].present?
+
+ args[:user_ids].each do |u|
+ user_stat = UserStat.find_by(user_id: u)
+ next if user_stat.blank?
+
+ total = user_stat.flags_agreed + user_stat.flags_disagreed + user_stat.flags_ignored
+ next if total < self.class.truncate_to
+
+ params = ReviewableScore.statuses.slice(:agreed, :disagreed, :ignored).
+ merge(user_id: u, truncate_to: self.class.truncate_to)
+
+ result = DB.query(<<~SQL, params)
+ SELECT SUM(CASE WHEN x.status = :agreed THEN 1 ELSE 0 END) AS agreed,
+ SUM(CASE WHEN x.status = :disagreed THEN 1 ELSE 0 END) AS disagreed,
+ SUM(CASE WHEN x.status = :ignored THEN 1 ELSE 0 END) AS ignored
+ FROM (
+ SELECT rs.status
+ FROM reviewable_scores AS rs
+ INNER JOIN reviewables AS r ON r.id = rs.reviewable_id
+ INNER JOIN posts AS p ON p.id = r.target_id
+ WHERE rs.user_id = :user_id
+ AND r.type = 'ReviewableFlaggedPost'
+ AND rs.status IN (:agreed, :disagreed, :ignored)
+ AND rs.user_id <> p.user_id
+ ORDER BY rs.created_at DESC
+ LIMIT :truncate_to
+ ) AS x
+ SQL
+
+ user_stat.update_columns(
+ flags_agreed: result[0].agreed || 0,
+ flags_disagreed: result[0].disagreed || 0,
+ flags_ignored: result[0].ignored || 0,
+ )
+ end
+
+ end
+
+end
diff --git a/app/jobs/scheduled/auto_queue_handler.rb b/app/jobs/scheduled/auto_queue_handler.rb
index 6e201b06cec..ff7d6ea8536 100644
--- a/app/jobs/scheduled/auto_queue_handler.rb
+++ b/app/jobs/scheduled/auto_queue_handler.rb
@@ -8,23 +8,16 @@ module Jobs
def execute(args)
return unless SiteSetting.auto_handle_queued_age.to_i > 0
- guardian = Guardian.new(Discourse.system_user)
-
- # Flags
- flags = FlagQuery.flagged_post_actions(filter: 'active')
- .where('post_actions.created_at < ?', SiteSetting.auto_handle_queued_age.to_i.days.ago)
-
- Post.where(id: flags.pluck(:post_id).uniq).each do |post|
- PostAction.defer_flags!(post, Discourse.system_user)
- end
-
- # Posts
- queued_posts = QueuedPost.visible
- .where(state: QueuedPost.states[:new])
+ Reviewable
+ .where(status: Reviewable.statuses[:pending])
.where('created_at < ?', SiteSetting.auto_handle_queued_age.to_i.days.ago)
+ .each do |reviewable|
- queued_posts.each do |queued_post|
- queued_post.reject!(Discourse.system_user)
+ if reviewable.is_a?(ReviewableFlaggedPost)
+ reviewable.perform(Discourse.system_user, :ignore)
+ else
+ reviewable.perform(Discourse.system_user, :reject)
+ end
end
end
end
diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb
index 2043c43bccf..b002f040c50 100644
--- a/app/jobs/scheduled/clean_up_uploads.rb
+++ b/app/jobs/scheduled/clean_up_uploads.rb
@@ -76,7 +76,7 @@ module Jobs
result.find_each do |upload|
if upload.sha1.present?
encoded_sha = Base62.encode(upload.sha1.hex)
- next if QueuedPost.where("raw LIKE '%#{upload.sha1}%' OR raw LIKE '%#{encoded_sha}%'").exists?
+ next if ReviewableQueuedPost.where("payload->>'raw' LIKE '%#{upload.sha1}%' OR payload->>'raw' LIKE '%#{encoded_sha}%'").exists?
next if Draft.where("data LIKE '%#{upload.sha1}%' OR data LIKE '%#{encoded_sha}%'").exists?
upload.destroy
else
diff --git a/app/jobs/scheduled/pending_flags_reminder.rb b/app/jobs/scheduled/pending_flags_reminder.rb
index 4910b6a4b04..da38e2bc2e6 100644
--- a/app/jobs/scheduled/pending_flags_reminder.rb
+++ b/app/jobs/scheduled/pending_flags_reminder.rb
@@ -3,60 +3,53 @@ require_dependency 'flag_query'
module Jobs
class PendingFlagsReminder < Jobs::Scheduled
-
every 1.hour
+ attr_reader :sent_reminder
+
def execute(args)
+ @sent_reminder = false
+
if SiteSetting.notify_about_flags_after > 0
- flagged_posts_count = PostAction.flagged_posts_count
- return unless flagged_posts_count > 0
-
- flag_ids = pending_flag_ids
- if flag_ids.size > 0 && last_notified_id.to_i < flag_ids.max
+ reviewable_ids = Reviewable
+ .pending
+ .default_visible
+ .where('latest_score < ?', SiteSetting.notify_about_flags_after.to_i.hours.ago)
+ .order('id DESC')
+ .pluck(:id)
+ if reviewable_ids.size > 0 && self.class.last_notified_id < reviewable_ids[0]
usernames = active_moderator_usernames
mentions = usernames.size > 0 ? "@#{usernames.join(', @')} " : ""
- PostCreator.create(
+ @sent_reminder = PostCreator.create(
Discourse.system_user,
target_group_names: Group[:moderators].name,
archetype: Archetype.private_message,
subtype: TopicSubtype.system_message,
- title: I18n.t('flags_reminder.subject_template', count: flagged_posts_count),
+ title: I18n.t('flags_reminder.subject_template', count: reviewable_ids.size),
raw: mentions + I18n.t('flags_reminder.flags_were_submitted', count: SiteSetting.notify_about_flags_after, base_path: Discourse.base_path)
- )
+ ).present?
- self.last_notified_id = flag_ids.max
+ self.class.last_notified_id = reviewable_ids[0]
end
end
end
- def pending_flag_ids
- by_post = {}
-
- FlagQuery.flagged_post_actions(filter: 'active')
- .where('post_actions.created_at < ?', SiteSetting.notify_about_flags_after.to_i.hours.ago)
- .pluck(:post_id, :id)
- .each do |row|
-
- by_post[row[0]] ||= []
- by_post[row[0]] << row[1]
- end
-
- by_post.delete_if { |post_id, flags| flags.size < SiteSetting.min_flags_staff_visibility }
- by_post.values.flatten.uniq
+ def self.last_notified_id
+ $redis.get(last_notified_key).to_i
end
- def last_notified_id
- $redis.get(self.class.last_notified_key)&.to_i
- end
-
- def last_notified_id=(arg)
- $redis.set(self.class.last_notified_key, arg)
+ def self.last_notified_id=(arg)
+ $redis.set(last_notified_key, arg)
end
def self.last_notified_key
- "last_notified_pending_flag_id".freeze
+ "last_notified_reviewable_id".freeze
+ end
+
+ def self.clear_key
+ $redis.del(last_notified_key)
end
def active_moderator_usernames
diff --git a/app/jobs/scheduled/pending_queued_posts_reminder.rb b/app/jobs/scheduled/pending_queued_posts_reminder.rb
index 867918dcef4..01e2156b50a 100644
--- a/app/jobs/scheduled/pending_queued_posts_reminder.rb
+++ b/app/jobs/scheduled/pending_queued_posts_reminder.rb
@@ -25,7 +25,9 @@ module Jobs
end
def should_notify_ids
- QueuedPost.new_posts.visible.where('created_at < ?', SiteSetting.notify_about_queued_posts_after.hours.ago).pluck(:id)
+ ReviewableQueuedPost.where(status: Reviewable.statuses[:pending]).where(
+ 'created_at < ?', SiteSetting.notify_about_queued_posts_after.hours.ago
+ ).pluck(:id)
end
def last_notified_id
diff --git a/app/models/ignored_user.rb b/app/models/ignored_user.rb
index 34c6bdc995b..58853f8c63b 100644
--- a/app/models/ignored_user.rb
+++ b/app/models/ignored_user.rb
@@ -7,11 +7,11 @@ end
#
# Table name: ignored_users
#
-# id :integer not null, primary key
-# user_id :integer not null
+# id :bigint(8) not null, primary key
+# user_id :integer not null
# ignored_user_id :integer not null
-# created_at :datetime not null
-# updated_at :datetime not null
+# created_at :datetime not null
+# updated_at :datetime not null
#
# Indexes
#
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 6e022424381..e845247d615 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -209,9 +209,7 @@ class Invite < ActiveRecord::Base
def self.redeem_from_email(email)
invite = Invite.find_by(email: Email.downcase(email))
- if invite
- InviteRedeemer.new(invite).redeem
- end
+ InviteRedeemer.new(invite).redeem if invite
invite
end
diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb
index 2f63d50805e..23a6f01a5e0 100644
--- a/app/models/invite_redeemer.rb
+++ b/app/models/invite_redeemer.rb
@@ -32,9 +32,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
user = User.new(user_params) if user.nil?
if !SiteSetting.must_approve_users? || (SiteSetting.must_approve_users? && invite.invited_by.staff?)
- user.approved = true
- user.approved_by_id = invite.invited_by_id
- user.approved_at = Time.zone.now
+ ReviewableUser.set_approved_fields!(user, invite.invited_by)
end
user_fields = UserField.all
@@ -132,8 +130,13 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
end
def approve_account_if_needed
- if get_existing_user
- invited_user.approve(invite.invited_by, false)
+ if invited_user.present? && reviewable_user = ReviewableUser.find_by(target: invited_user)
+ reviewable_user.perform(
+ invite.invited_by,
+ :approve,
+ send_email: false,
+ approved_by_invite: true
+ )
end
end
diff --git a/app/models/post.rb b/app/models/post.rb
index 8e03538f527..c7382046567 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -55,7 +55,6 @@ class Post < ActiveRecord::Base
validates_with ::Validators::PostValidator, unless: :skip_validation
after_save :index_search
- after_save :create_user_action
# We can pass several creating options to a post via attributes
attr_accessor :image_sizes, :quoted_post_numbers, :no_bump, :invalidate_oneboxes, :cooking_options, :skip_unique_check, :skip_validation
@@ -194,7 +193,6 @@ class Post < ActiveRecord::Base
def recover!
super
- update_flagged_posts_count
recover_public_post_actions
TopicLink.extract_from(self)
QuotedPost.extract_from(self)
@@ -378,10 +376,6 @@ class Post < ActiveRecord::Base
])
end
- def update_flagged_posts_count
- PostAction.update_flagged_posts_count
- end
-
def delete_post_notices
self.custom_fields.delete("post_notice_type")
self.custom_fields.delete("post_notice_time")
@@ -462,8 +456,49 @@ class Post < ActiveRecord::Base
post_actions.active.where(post_action_type_id: PostActionType.flag_types_without_custom.values)
end
- def has_active_flag?
- active_flags.count != 0
+ def reviewable_flag
+ ReviewableFlaggedPost.pending.find_by(target: self)
+ end
+
+ def hide!(post_action_type_id, reason = nil)
+ return if hidden?
+
+ reason ||= hidden_at ?
+ Post.hidden_reasons[:flag_threshold_reached_again] :
+ Post.hidden_reasons[:flag_threshold_reached]
+
+ hiding_again = hidden_at.present?
+
+ self.hidden = true
+ self.hidden_at = Time.zone.now
+ self.hidden_reason_id = reason
+ save!
+
+ Topic.where(
+ "id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)",
+ topic_id: topic_id
+ ).update_all(visible: false)
+
+ # inform user
+ if user.present?
+ options = {
+ url: url,
+ edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts,
+ flag_reason: I18n.t(
+ "flag_reasons.#{PostActionType.types[post_action_type_id]}",
+ locale: SiteSetting.default_locale,
+ base_path: Discourse.base_path
+ )
+ }
+
+ Jobs.enqueue_in(
+ 5.seconds,
+ :send_system_message,
+ user_id: user.id,
+ message_type: hiding_again ? :post_hidden_again : :post_hidden,
+ message_options: options
+ )
+ end
end
def unhide!
@@ -826,10 +861,6 @@ class Post < ActiveRecord::Base
SearchIndexer.index(self)
end
- def create_user_action
- UserActionCreator.log_post(self)
- end
-
def locked?
locked_by_id.present?
end
diff --git a/app/models/post_action.rb b/app/models/post_action.rb
index c814f55efea..343964b8997 100644
--- a/app/models/post_action.rb
+++ b/app/models/post_action.rb
@@ -1,10 +1,9 @@
require_dependency 'rate_limiter'
require_dependency 'system_message'
+require_dependency 'post_action_creator'
+require_dependency 'post_action_destroyer'
class PostAction < ActiveRecord::Base
- class AlreadyActed < StandardError; end
- class FailedToCreatePost < StandardError; end
-
include RateLimiter::OnCreateRecord
include Trashable
@@ -22,79 +21,7 @@ class PostAction < ActiveRecord::Base
scope :active, -> { where(disagreed_at: nil, deferred_at: nil, agreed_at: nil, deleted_at: nil) }
after_save :update_counters
- after_save :enforce_rules
- after_save :create_user_action
- after_save :update_notifications
- after_create :create_notifications
- after_commit :notify_subscribers
-
- def disposed_by_id
- disagreed_by_id || agreed_by_id || deferred_by_id
- end
-
- def disposed_at
- disagreed_at || agreed_at || deferred_at
- end
-
- def disposition
- return :disagreed if disagreed_at
- return :agreed if agreed_at
- return :deferred if deferred_at
- nil
- end
-
- def self.flag_count_by_date(start_date, end_date, category_id = nil)
- result = where('post_actions.created_at >= ? AND post_actions.created_at <= ?', start_date, end_date)
- result = result.where(post_action_type_id: PostActionType.flag_types_without_custom.values)
- result = result.joins(post: :topic).where("topics.category_id = ?", category_id) if category_id
- result.group('date(post_actions.created_at)')
- .order('date(post_actions.created_at)')
- .count
- end
-
- # Forums can choose to apply a minimum number of flags required before it shows up in
- # the admin interface. One exception is posts hidden by tl3/tl4 - we want those to
- # show up even if the minimum visibility is not met.
- def self.apply_minimum_visibility(relation)
- return relation unless SiteSetting.min_flags_staff_visibility > 1
-
- params = {
- min_flags: SiteSetting.min_flags_staff_visibility,
- hidden_reasons: Post.hidden_reasons.only(:flagged_by_tl3_user, :flagged_by_tl4_user).values
- }
-
- relation.having(<<~SQL, params)
- (COUNT(*) >= :min_flags) OR
- (SUM(CASE
- WHEN posts.hidden_reason_id IN (:hidden_reasons) THEN 1
- ELSE 0
- END) > 0)
- SQL
- end
-
- def self.update_flagged_posts_count
- flagged_relation = PostAction.active
- .flags
- .joins(post: :topic)
- .where('posts.deleted_at' => nil)
- .where('topics.deleted_at' => nil)
- .where('posts.user_id > 0')
- .group("posts.id")
-
- flagged_relation = apply_minimum_visibility(flagged_relation)
-
- posts_flagged_count = flagged_relation
- .pluck("posts.id")
- .count
-
- $redis.set('posts_flagged_count', posts_flagged_count)
- user_ids = User.staff.pluck(:id)
- MessageBus.publish('/flagged_counts', { total: posts_flagged_count }, user_ids: user_ids)
- end
-
- def self.flagged_posts_count
- $redis.get('posts_flagged_count').to_i
- end
+ validate :ensure_unique_actions, on: :create
def self.counts_for(collection, user)
return {} if collection.blank? || !user
@@ -138,23 +65,6 @@ class PostAction < ActiveRecord::Base
map
end
- def self.active_flags_counts_for(collection)
- return {} if collection.blank?
-
- collection_ids = collection.map(&:id)
-
- post_actions = PostAction.active.flags.where(post_id: collection_ids)
-
- user_actions = {}
- post_actions.each do |post_action|
- user_actions[post_action.post_id] ||= {}
- user_actions[post_action.post_id][post_action.post_action_type_id] ||= []
- user_actions[post_action.post_id][post_action.post_action_type_id] << post_action
- end
-
- user_actions
- end
-
def self.count_per_day_for_type(post_action_type, opts = nil)
opts ||= {}
result = unscoped.where(post_action_type_id: post_action_type)
@@ -166,96 +76,6 @@ class PostAction < ActiveRecord::Base
.count
end
- def self.agree_flags!(post, moderator, delete_post = false)
- actions = PostAction.active
- .where(post_id: post.id)
- .where(post_action_type_id: PostActionType.notify_flag_types.values)
-
- trigger_spam = false
- actions.each do |action|
- action.agreed_at = Time.zone.now
- action.agreed_by_id = moderator.id
- # so callback is called
- action.save
- action.add_moderator_post_if_needed(moderator, :agreed, delete_post)
- trigger_spam = true if action.post_action_type_id == PostActionType.types[:spam]
- end
-
- # Update the flags_agreed user stat
- UserStat.where(user_id: actions.map(&:user_id)).update_all("flags_agreed = flags_agreed + 1")
-
- DiscourseEvent.trigger(:confirmed_spam_post, post) if trigger_spam
-
- if actions.first.present?
- DiscourseEvent.trigger(:flag_reviewed, post)
- DiscourseEvent.trigger(:flag_agreed, actions.first)
- end
-
- update_flagged_posts_count
- end
-
- def self.clear_flags!(post, moderator)
- # -1 is the automatic system cleary
- action_type_ids =
- if moderator.id == Discourse::SYSTEM_USER_ID
- PostActionType.auto_action_flag_types.values
- else
- PostActionType.notify_flag_type_ids
- end
-
- actions = PostAction.active.where(post_id: post.id).where(post_action_type_id: action_type_ids)
-
- actions.each do |action|
- action.disagreed_at = Time.zone.now
- action.disagreed_by_id = moderator.id
- # so callback is called
- action.save
- action.add_moderator_post_if_needed(moderator, :disagreed)
- end
-
- # Update the flags_disagreed user stat
- UserStat.where(user_id: actions.map(&:user_id)).update_all("flags_disagreed = flags_disagreed + 1")
-
- # reset all cached counters
- cached = {}
- action_type_ids.each do |atid|
- column = "#{PostActionType.types[atid]}_count"
- cached[column] = 0 if ActiveRecord::Base.connection.column_exists?(:posts, column)
- end
-
- Post.with_deleted.where(id: post.id).update_all(cached)
-
- if actions.first.present?
- DiscourseEvent.trigger(:flag_reviewed, post)
- DiscourseEvent.trigger(:flag_disagreed, actions.first)
- end
-
- update_flagged_posts_count
-
- undo_hide_and_silence(post)
- end
-
- def self.defer_flags!(post, moderator, delete_post = false)
- actions = PostAction.active
- .where(post_id: post.id)
- .where(post_action_type_id: PostActionType.notify_flag_type_ids)
-
- actions.each do |action|
- action.deferred_at = Time.zone.now
- action.deferred_by_id = moderator.id
- # so callback is called
- action.save
- action.add_moderator_post_if_needed(moderator, :deferred, delete_post)
- end
-
- if actions.first.present?
- DiscourseEvent.trigger(:flag_reviewed, post)
- DiscourseEvent.trigger(:flag_deferred, actions.first)
- end
-
- update_flagged_posts_count
- end
-
def add_moderator_post_if_needed(moderator, disposition, delete_post = false)
return if !SiteSetting.auto_respond_to_flag_actions
return if related_post.nil? || related_post.topic.nil?
@@ -272,116 +92,21 @@ class PostAction < ActiveRecord::Base
topic.posts.where("user_id IN (SELECT id FROM users WHERE moderator OR admin) OR (post_type != :regular_post_type)", regular_post_type: Post.types[:regular]).exists?
end
- def self.create_message_for_post_action(user, post, post_action_type_id, opts)
- post_action_type = PostActionType.types[post_action_type_id]
-
- return unless opts[:message] && [:notify_moderators, :notify_user, :spam].include?(post_action_type)
-
- title = I18n.t("post_action_types.#{post_action_type}.email_title", title: post.topic.title, locale: SiteSetting.default_locale)
- body = I18n.t("post_action_types.#{post_action_type}.email_body", message: opts[:message], link: "#{Discourse.base_url}#{post.url}", locale: SiteSetting.default_locale)
- warning = opts[:is_warning] if opts[:is_warning].present?
- title = title.truncate(SiteSetting.max_topic_title_length, separator: /\s/)
-
- opts = {
- archetype: Archetype.private_message,
- is_warning: warning,
- title: title,
- raw: body
- }
-
- if [:notify_moderators, :spam].include?(post_action_type)
- opts[:subtype] = TopicSubtype.notify_moderators
- opts[:target_group_names] = target_moderators
- else
- opts[:subtype] = TopicSubtype.notify_user
-
- opts[:target_usernames] =
- if post_action_type == :notify_user
- post.user.username
- elsif post_action_type != :notify_moderators
- # this is a hack to allow a PM with no recipients, we should think through
- # a cleaner technique, a PM with myself is valid for flagging
- 'x'
- end
- end
-
- PostCreator.new(user, opts).create!&.id
- end
-
def self.limit_action!(user, post, post_action_type_id)
RateLimiter.new(user, "post_action-#{post.id}_#{post_action_type_id}", 4, 1.minute).performed!
end
- def self.act(user, post, post_action_type_id, opts = {})
- limit_action!(user, post, post_action_type_id)
+ def self.act(created_by, post, post_action_type_id, opts = {})
+ Discourse.deprecate("PostAction.act is deprecated. Use `PostActionCreator` instead.", output_in_test: true)
- begin
- related_post_id = create_message_for_post_action(user, post, post_action_type_id, opts)
- rescue ActiveRecord::RecordNotSaved => e
- raise FailedToCreatePost.new(e.message)
- end
+ result = PostActionCreator.new(
+ created_by,
+ post,
+ post_action_type_id,
+ message: opts[:message]
+ ).perform
- staff_took_action = opts[:take_action] || false
-
- targets_topic =
- if opts[:flag_topic] && post.topic
- post.topic.reload.posts_count != 1
- end
-
- where_attrs = {
- post_id: post.id,
- user_id: user.id,
- post_action_type_id: post_action_type_id
- }
-
- action_attrs = {
- staff_took_action: staff_took_action,
- related_post_id: related_post_id,
- targets_topic: !!targets_topic
- }
-
- # First try to revive a trashed record
- post_action = PostAction.where(where_attrs)
- .with_deleted
- .where("deleted_at IS NOT NULL")
- .first
-
- if post_action
- post_action.recover!
- action_attrs.each { |attr, val| post_action.send("#{attr}=", val) }
- post_action.save
- PostActionNotifier.post_action_created(post_action)
- else
- post_action = create(where_attrs.merge(action_attrs))
- if post_action && post_action.errors.count == 0
- BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: post_action)
- end
- end
-
- if post_action && PostActionType.notify_flag_type_ids.include?(post_action_type_id)
- DiscourseEvent.trigger(:flag_created, post_action)
- end
-
- GivenDailyLike.increment_for(user.id) if post_action_type_id == PostActionType.types[:like]
-
- # agree with other flags
- if staff_took_action
- PostAction.agree_flags!(post, user)
- post_action.try(:update_counters)
- end
-
- post_action
- rescue ActiveRecord::RecordNotUnique
- # can happen despite being .create
- # since already bookmarked
- PostAction.where(where_attrs).first
- end
-
- def self.undo_hide_and_silence(post)
- return unless post.hidden?
-
- post.unhide!
- UserSilencer.unsilence(post.user) if UserSilencer.was_silenced_for?(post)
+ result.success? ? result.post_action : nil
end
def self.copy(original_post, target_post)
@@ -398,16 +123,12 @@ class PostAction < ActiveRecord::Base
end
def self.remove_act(user, post, post_action_type_id)
+ Discourse.deprecate(
+ "PostAction.remove_act is deprecated. Use `PostActionDestroyer` instead.",
+ output_in_test: true
+ )
- limit_action!(user, post, post_action_type_id)
-
- finder = PostAction.where(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id)
- finder = finder.with_deleted.includes(:post) if user.try(:staff?)
- if action = finder.first
- action.remove_act!(user)
- action.post.unhide! if action.staff_took_action
- GivenDailyLike.decrement_for(user.id) if post_action_type_id == PostActionType.types[:like]
- end
+ PostActionDestroyer.new(user, post, post_action_type_id).perform
end
def remove_act!(user)
@@ -458,43 +179,18 @@ class PostAction < ActiveRecord::Base
end
end
- before_create do
+ def ensure_unique_actions
post_action_type_ids = is_flag? ? PostActionType.notify_flag_types.values : post_action_type_id
- raise AlreadyActed if PostAction.where(user_id: user_id)
+
+ acted = PostAction.where(user_id: user_id)
.where(post_id: post_id)
.where(post_action_type_id: post_action_type_ids)
.where(deleted_at: nil)
.where(disagreed_at: nil)
.where(targets_topic: targets_topic)
.exists?
- end
- # Returns the flag counts for a post, taking into account that some users
- # can weigh flags differently.
- def self.flag_counts_for(post_id)
- params = {
- post_id: post_id,
- post_action_types: PostActionType.auto_action_flag_types.values,
- flags_required_to_hide_post: SiteSetting.flags_required_to_hide_post
- }
-
- DB.query_single(<<~SQL, params)
- SELECT COALESCE(SUM(CASE
- WHEN pa.disagreed_at IS NOT NULL AND pa.staff_took_action THEN :flags_required_to_hide_post
- WHEN pa.disagreed_at IS NOT NULL AND NOT pa.staff_took_action THEN 1
- ELSE 0
- END),0) AS old_flags,
- COALESCE(SUM(CASE
- WHEN pa.disagreed_at IS NULL AND pa.staff_took_action THEN :flags_required_to_hide_post
- WHEN pa.disagreed_at IS NULL AND NOT pa.staff_took_action THEN 1
- ELSE 0
- END), 0) AS new_flags
- FROM post_actions AS pa
- INNER JOIN users AS u ON u.id = pa.user_id
- WHERE pa.post_id = :post_id
- AND pa.post_action_type_id in (:post_action_types)
- AND pa.deleted_at IS NULL
- SQL
+ errors.add(:post_action_type_id) if acted
end
def post_action_type_key
@@ -536,159 +232,7 @@ class PostAction < ActiveRecord::Base
Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count]
end
- if PostActionType.notify_flag_type_ids.include?(post_action_type_id)
- PostAction.update_flagged_posts_count
- end
-
end
-
- def enforce_rules
- post = Post.with_deleted.where(id: post_id).first
- PostAction.auto_close_if_threshold_reached(post.topic)
- PostAction.auto_hide_if_needed(user, post, post_action_type_key)
- SpamRule::AutoSilence.new(post.user, post).perform
- end
-
- def create_user_action
- if is_bookmark? || is_like?
- UserActionCreator.log_post_action(self)
- end
- end
-
- def update_notifications
- if self.deleted_at.present?
- PostActionNotifier.post_action_deleted(self)
- end
- end
-
- def create_notifications
- PostActionNotifier.post_action_created(self)
- end
-
- def notify_subscribers
- if (is_like? || is_flag?) && post
- post.publish_change_to_clients! :acted
- end
- end
-
- MAXIMUM_FLAGS_PER_POST = 3
-
- def self.auto_close_threshold_reached?(topic)
- return if topic.user&.staff?
- flags = PostAction.active
- .flags
- .joins(:post)
- .where("posts.topic_id = ?", topic.id)
- .where("post_actions.user_id > 0")
- .group("post_actions.user_id")
- .pluck("post_actions.user_id, COUNT(post_id)")
-
- # we need a minimum number of unique flaggers
- return if flags.count < SiteSetting.num_flaggers_to_close_topic
- # we need a minimum number of flags
- return if flags.sum { |f| f[1] } < SiteSetting.num_flags_to_close_topic
-
- true
- end
-
- def self.auto_close_if_threshold_reached(topic)
- return if topic.nil? || topic.closed?
- return unless auto_close_threshold_reached?(topic)
-
- # the threshold has been reached, we will close the topic waiting for intervention
- topic.update_status("closed", true, Discourse.system_user,
- message: I18n.t(
- "temporarily_closed_due_to_flags",
- count: SiteSetting.num_hours_to_close_topic
- )
- )
-
- topic.set_or_create_timer(
- TopicTimer.types[:open],
- SiteSetting.num_hours_to_close_topic,
- by_user: Discourse.system_user
- )
- end
-
- def self.auto_hide_if_needed(acting_user, post, post_action_type)
- return if post.hidden?
- return if (!acting_user.staff?) && post.user&.staff?
-
- if post_action_type == :spam &&
- acting_user.has_trust_level?(TrustLevel[3]) &&
- post.user&.trust_level == TrustLevel[0]
-
- hide_post!(post, post_action_type, Post.hidden_reasons[:flagged_by_tl3_user])
-
- elsif PostActionType.auto_action_flag_types.include?(post_action_type)
-
- if acting_user.has_trust_level?(TrustLevel[4]) &&
- !acting_user.staff? &&
- post.user&.trust_level != TrustLevel[4]
-
- hide_post!(post, post_action_type, Post.hidden_reasons[:flagged_by_tl4_user])
- elsif SiteSetting.flags_required_to_hide_post > 0
-
- _old_flags, new_flags = PostAction.flag_counts_for(post.id)
-
- if new_flags >= SiteSetting.flags_required_to_hide_post
- hide_post!(post, post_action_type, guess_hide_reason(post))
- end
- end
- end
- end
-
- def self.hide_post!(post, post_action_type, reason = nil)
- return if post.hidden
-
- unless reason
- reason = guess_hide_reason(post)
- end
-
- hiding_again = post.hidden_at.present?
-
- post.hidden = true
- post.hidden_at = Time.zone.now
- post.hidden_reason_id = reason
- post.save
-
- 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)
-
- # inform user
- if post.user
- options = {
- url: post.url,
- edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts,
- flag_reason: I18n.t(
- "flag_reasons.#{post_action_type}",
- locale: SiteSetting.default_locale,
- base_path: Discourse.base_path
- )
- }
-
- Jobs.enqueue_in(5.seconds, :send_system_message,
- user_id: post.user.id,
- message_type: hiding_again ? :post_hidden_again : :post_hidden,
- message_options: options)
- end
- update_flagged_posts_count
- end
-
- def self.guess_hide_reason(post)
- post.hidden_at ?
- Post.hidden_reasons[:flag_threshold_reached_again] :
- Post.hidden_reasons[:flag_threshold_reached]
- end
-
- def self.post_action_type_for_post(post_id)
- post_action = PostAction.find_by(deferred_at: nil, post_id: post_id, post_action_type_id: PostActionType.notify_flag_types.values, deleted_at: nil)
- PostActionType.types[post_action.post_action_type_id] if post_action
- end
-
- def self.target_moderators
- Group[:moderators].name
- end
-
end
# == Schema Information
diff --git a/app/models/post_action_type.rb b/app/models/post_action_type.rb
index 031c1e11972..92c1702b14f 100644
--- a/app/models/post_action_type.rb
+++ b/app/models/post_action_type.rb
@@ -22,21 +22,21 @@ class PostActionType < ActiveRecord::Base
3,
:off_topic,
notify_type: true,
- auto_action_type: true
+ auto_action_type: true,
)
@flag_settings.add(
4,
:inappropriate,
topic_type: true,
notify_type: true,
- auto_action_type: true
+ auto_action_type: true,
)
@flag_settings.add(
8,
:spam,
topic_type: true,
notify_type: true,
- auto_action_type: true
+ auto_action_type: true,
)
@flag_settings.add(
6,
@@ -125,11 +125,12 @@ end
#
# Table name: post_action_types
#
-# name_key :string(50) not null
-# is_flag :boolean default(FALSE), not null
-# icon :string(20)
-# created_at :datetime not null
-# updated_at :datetime not null
-# id :integer not null, primary key
-# position :integer default(0), not null
+# name_key :string(50) not null
+# is_flag :boolean default(FALSE), not null
+# icon :string(20)
+# created_at :datetime not null
+# updated_at :datetime not null
+# id :integer not null, primary key
+# position :integer default(0), not null
+# score_bonus :float default(0.0), not null
#
diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb
deleted file mode 100644
index 247db155a1c..00000000000
--- a/app/models/queued_post.rb
+++ /dev/null
@@ -1,158 +0,0 @@
-class QueuedPost < ActiveRecord::Base
-
- class InvalidStateTransition < StandardError; end
-
- belongs_to :user
- belongs_to :topic
- belongs_to :approved_by, class_name: "User"
- belongs_to :rejected_by, class_name: "User"
-
- after_commit :trigger_queued_post_event, on: :create
-
- def create_pending_action
- UserAction.log_action!(action_type: UserAction::PENDING,
- user_id: user_id,
- acting_user_id: user_id,
- target_topic_id: topic_id,
- queued_post_id: id)
- end
-
- def trigger_queued_post_event
- DiscourseEvent.trigger(:queued_post_created, self)
- true
- end
-
- def self.states
- @states ||= Enum.new(:new, :approved, :rejected)
- end
-
- # By default queues are hidden from moderators
- def self.visible_queues
- @visible_queues ||= Set.new(['default'])
- end
-
- def self.visible
- where(queue: visible_queues.to_a)
- end
-
- def self.new_posts
- where(state: states[:new])
- end
-
- def self.new_count
- new_posts.visible.count
- end
-
- def visible?
- QueuedPost.visible_queues.include?(queue)
- end
-
- def self.broadcast_new!
- msg = { post_queue_new_count: QueuedPost.new_count }
- MessageBus.publish('/queue_counts', msg, user_ids: User.staff.pluck(:id))
- end
-
- def reject!(rejected_by)
- change_to!(:rejected, rejected_by)
- StaffActionLogger.new(rejected_by).log_post_rejected(self)
- DiscourseEvent.trigger(:rejected_post, self)
- end
-
- def create_options
- opts = { raw: raw }
- opts.merge!(post_options.symbolize_keys)
-
- opts[:cooking_options].symbolize_keys! if opts[:cooking_options]
- opts[:topic_id] = topic_id if topic_id
- opts
- end
-
- def approve!(approved_by)
- created_post = nil
-
- creator = PostCreator.new(user, create_options.merge(
- skip_validations: true,
- skip_jobs: true,
- skip_events: true
- ))
-
- QueuedPost.transaction do
- change_to!(:approved, approved_by)
-
- UserSilencer.unsilence(user, approved_by) if user.silenced?
-
- created_post = creator.create
-
- unless created_post && creator.errors.blank?
- raise StandardError.new(creator.errors.full_messages.join(" "))
- else
- # Log post approval
- StaffActionLogger.new(approved_by).log_post_approved(created_post)
- end
- end
-
- if create_options[:tags].present? &&
- created_post.post_number == 1 &&
- created_post.topic.tags.blank?
- DiscourseTagging.tag_topic_by_names(created_post.topic, Guardian.new(approved_by), create_options[:tags])
- end
-
- # Do sidekiq work outside of the transaction
- creator.enqueue_jobs
- creator.trigger_after_events
-
- DiscourseEvent.trigger(:approved_post, self, created_post)
- created_post
- end
-
- private
-
- def change_to!(state, changed_by)
- state_val = QueuedPost.states[state]
-
- updates = { state: state_val,
- "#{state}_by_id" => changed_by.id,
- "#{state}_at" => Time.now }
-
- # We use an update with `row_count` trick here to avoid stampeding requests to
- # update the same row simultaneously. Only one state change should go through and
- # we can use the DB to enforce this
- row_count = QueuedPost.where('id = ? AND state <> ?', id, state_val).update_all(updates)
- raise InvalidStateTransition.new if row_count == 0
-
- if [:rejected, :approved].include?(state)
- UserAction.where(queued_post_id: id).destroy_all
- end
-
- # Update the record in memory too, and clear the dirty flag
- updates.each { |k, v| send("#{k}=", v) }
- changes_applied
-
- QueuedPost.broadcast_new! if visible?
- end
-
-end
-
-# == Schema Information
-#
-# Table name: queued_posts
-#
-# id :integer not null, primary key
-# queue :string not null
-# state :integer not null
-# user_id :integer not null
-# raw :text not null
-# post_options :json not null
-# topic_id :integer
-# approved_by_id :integer
-# approved_at :datetime
-# rejected_by_id :integer
-# rejected_at :datetime
-# created_at :datetime not null
-# updated_at :datetime not null
-#
-# Indexes
-#
-# by_queue_status (queue,state,created_at)
-# by_queue_status_topic (topic_id,queue,state,created_at)
-#
diff --git a/app/models/report.rb b/app/models/report.rb
index 78c09753189..9a5726e6c72 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -510,7 +510,7 @@ class Report
end
if report.facets.include?(:total)
- report.total = subject_class.count
+ report.total = subject_class.count
end
if report.facets.include?(:prev30Days)
@@ -553,10 +553,19 @@ class Report
report.icon = 'flag'
report.higher_is_better = false
- basic_report_about report, PostAction, :flag_count_by_date, report.start_date, report.end_date, report.category_id
- countable = PostAction.where(post_action_type_id: PostActionType.flag_types_without_custom.values)
- countable = countable.joins(post: :topic).merge(Topic.in_category_and_subcategories(report.category_id)) if report.category_id
- add_counts report, countable, 'post_actions.created_at'
+ basic_report_about(
+ report,
+ ReviewableFlaggedPost,
+ :count_by_date,
+ report.start_date,
+ report.end_date,
+ report.category_id
+ )
+
+ countable = ReviewableFlaggedPost.scores_with_topics
+ countable.merge!(Topic.in_category_and_subcategories(report.category_id)) if report.category_id
+
+ add_counts report, countable, 'reviewable_scores.created_at'
end
def self.report_likes(report)
@@ -1280,7 +1289,7 @@ class Report
report.modes = [:table]
- report.dates_filtering = false
+ report.dates_filtering = true
report.labels = [
{
@@ -1314,25 +1323,36 @@ class Report
},
]
+ statuses = ReviewableScore.statuses
+
+ agreed = "SUM(CASE WHEN rs.status = #{statuses[:agreed]} THEN 1 ELSE 0 END)::numeric"
+ disagreed = "SUM(CASE WHEN rs.status = #{statuses[:disagreed]} THEN 1 ELSE 0 END)::numeric"
+ ignored = "SUM(CASE WHEN rs.status = #{statuses[:ignored]} THEN 1 ELSE 0 END)::numeric"
+
sql = <<~SQL
SELECT u.id,
u.username,
u.uploaded_avatar_id as avatar_id,
CASE WHEN u.silenced_till IS NOT NULL THEN 't' ELSE 'f' END as silenced,
- us.flags_disagreed AS disagreed_flags,
- us.flags_agreed AS agreed_flags,
- us.flags_ignored AS ignored_flags,
- ROUND((1-(us.flags_agreed::numeric / us.flags_disagreed::numeric)) *
- (us.flags_disagreed - us.flags_agreed)) AS score
+ #{disagreed} AS disagreed_flags,
+ #{agreed} AS agreed_flags,
+ #{ignored} AS ignored_flags,
+ ROUND((1-(#{agreed} / #{disagreed})) * (#{disagreed} - #{agreed})) AS score
FROM users AS u
- INNER JOIN user_stats AS us ON us.user_id = u.id
- WHERE u.id <> -1
- AND flags_disagreed > flags_agreed
+ INNER JOIN reviewable_scores AS rs ON rs.user_id = u.id
+ WHERE u.id > 0
+ AND rs.created_at >= :start_date
+ AND rs.created_at <= :end_date
+ GROUP BY u.id,
+ u.username,
+ u.uploaded_avatar_id,
+ u.silenced_till
+ HAVING #{disagreed} > #{agreed}
ORDER BY score DESC
LIMIT 100
SQL
- DB.query(sql).each do |row|
+ DB.query(sql, start_date: report.start_date, end_date: report.end_date).each do |row|
flagger = {}
flagger[:user_id] = row.id
flagger[:username] = row.username
diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb
new file mode 100644
index 00000000000..431060bdf53
--- /dev/null
+++ b/app/models/reviewable.rb
@@ -0,0 +1,471 @@
+require_dependency 'enum'
+require_dependency 'reviewable/actions'
+require_dependency 'reviewable/conversation'
+require_dependency 'reviewable/editable_fields'
+require_dependency 'reviewable/perform_result'
+require_dependency 'reviewable_serializer'
+
+class Reviewable < ActiveRecord::Base
+ class UpdateConflict < StandardError; end
+
+ class InvalidAction < StandardError
+ def initialize(action_id, klass)
+ @action_id, @klass = action_id, klass
+ super("Can't peform `#{action_id}` on #{klass.name}")
+ end
+ end
+
+ validates_presence_of :type, :status, :created_by_id
+ belongs_to :target, polymorphic: true
+ belongs_to :created_by, class_name: 'User'
+ belongs_to :target_created_by, class_name: 'User'
+ belongs_to :reviewable_by_group, class_name: 'Group'
+
+ # Optional, for filtering
+ belongs_to :topic
+ belongs_to :category
+
+ has_many :reviewable_histories
+ has_many :reviewable_scores
+
+ after_create do
+ DiscourseEvent.trigger(:reviewable_created, self)
+ Jobs.enqueue(:notify_reviewable, reviewable_id: self.id) if pending?
+ log_history(:created, created_by)
+ end
+
+ def self.statuses
+ @statuses ||= Enum.new(
+ pending: 0,
+ approved: 1,
+ rejected: 2,
+ ignored: 3,
+ deleted: 4
+ )
+ end
+
+ # Generate `pending?`, `rejected?`, etc helper methods
+ statuses.each do |name, id|
+ define_method("#{name}?") { status == id }
+ self.class.define_method(name) { where(status: id) }
+ end
+
+ def self.default_visible
+ where("score >= ?", SiteSetting.min_score_default_visibility)
+ end
+
+ def self.valid_type?(type)
+ return false unless type =~ /^Reviewable[A-Za-z]+$/
+ type.constantize <= Reviewable
+ rescue NameError
+ false
+ end
+
+ def self.types
+ %w[ReviewableFlaggedPost ReviewableQueuedPost ReviewableUser]
+ end
+
+ # Create a new reviewable, or if the target has already been reviewed return it to the
+ # pending state and re-use it.
+ #
+ # You probably want to call this to create your reviewable rather than `.create`.
+ def self.needs_review!(
+ target: nil,
+ topic: nil,
+ created_by:,
+ payload: nil,
+ reviewable_by_moderator: false,
+ potential_spam: true
+ )
+ target_created_by_id = target.is_a?(Post) ? target.user_id : nil
+
+ topic = target.topic if topic.blank? && target.is_a?(Post)
+ category_id = topic.category_id if topic.present?
+
+ create!(
+ target: target,
+ target_created_by_id: target_created_by_id,
+ topic: topic,
+ created_by: created_by,
+ category_id: category_id,
+ reviewable_by_moderator: reviewable_by_moderator,
+ payload: payload,
+ potential_spam: potential_spam
+ )
+ rescue ActiveRecord::RecordNotUnique
+ updates = {
+ status: statuses[:pending]
+ }
+ updates[:potential_spam] = true if potential_spam
+ where(target: target).update_all(updates)
+ find_by(target: target).tap { |r| r.log_history(:transitioned, created_by) }
+ end
+
+ def add_score(
+ user,
+ reviewable_score_type,
+ created_at: nil,
+ take_action: false,
+ meta_topic_id: nil,
+ force_review: false
+ )
+
+ type_bonus = PostActionType.where(id: reviewable_score_type).pluck(:score_bonus)[0] || 0
+ take_action_bonus = take_action ? 5.0 : 0.0
+ sub_total = (ReviewableScore.user_flag_score(user) + type_bonus + take_action_bonus)
+
+ # We can force a reviewable to hit the threshold, for example with queued posts
+ if force_review && sub_total < SiteSetting.min_score_default_visibility
+ sub_total = SiteSetting.min_score_default_visibility
+ end
+
+ rs = reviewable_scores.create!(
+ user: user,
+ status: ReviewableScore.statuses[:pending],
+ reviewable_score_type: reviewable_score_type,
+ score: sub_total,
+ meta_topic_id: meta_topic_id,
+ take_action_bonus: take_action_bonus,
+ created_at: created_at || Time.zone.now
+ )
+
+ update(score: self.score + rs.score, latest_score: rs.created_at)
+ topic.update(reviewable_score: topic.reviewable_score + rs.score) if topic
+
+ rs
+ end
+
+ def history
+ reviewable_histories.order(:created_at)
+ end
+
+ def log_history(reviewable_history_type, performed_by, edited: nil)
+ reviewable_histories.create!(
+ reviewable_history_type: ReviewableHistory.types[reviewable_history_type],
+ status: status,
+ created_by: performed_by,
+ edited: edited
+ )
+ end
+
+ def actions_for(guardian, args = nil)
+ args ||= {}
+ Actions.new(self, guardian).tap { |a| build_actions(a, guardian, args) }
+ end
+
+ def editable_for(guardian, args = nil)
+ args ||= {}
+ EditableFields.new(self, guardian, args).tap { |a| build_editable_fields(a, guardian, args) }
+ end
+
+ # subclasses must implement "build_actions" to list the actions they're capable of
+ def build_actions(actions, guardian, args)
+ raise NotImplementedError
+ end
+
+ # subclasses can implement "build_editable_fields" to list stuff that can be edited
+ def build_editable_fields(actions, guardian, args)
+ end
+
+ def update_fields(params, performed_by, version: nil)
+ return true if params.blank?
+
+ (params[:payload] || {}).each { |k, v| self.payload[k] = v }
+ self.category_id = params[:category_id] if params.has_key?(:category_id)
+
+ result = false
+
+ Reviewable.transaction do
+ increment_version!(version)
+ changes_json = changes.as_json
+ changes_json.delete('version')
+
+ result = save
+ log_history(:edited, performed_by, edited: changes_json) if result
+ end
+
+ result
+ end
+
+ # Delegates to a `perform_#{action_id}` method, which returns a `PerformResult` with
+ # the result of the operation and whether the status of the reviewable changed.
+ def perform(performed_by, action_id, args = nil)
+ args ||= {}
+
+ # Ensure the user has access to the action
+ actions = actions_for(Guardian.new(performed_by), args)
+ raise InvalidAction.new(action_id, self.class) unless actions.has?(action_id)
+
+ perform_method = "perform_#{action_id}".to_sym
+ raise InvalidAction.new(action_id, self.class) unless respond_to?(perform_method)
+
+ result = nil
+ Reviewable.transaction do
+ increment_version!(args[:version])
+ result = send(perform_method, performed_by, args)
+
+ if result.success?
+ transition_to(result.transition_to, performed_by) if result.transition_to
+ update_flag_stats(**result.update_flag_stats) if result.update_flag_stats
+
+ recalculate_score if result.recalculate_score
+ end
+ end
+ result
+ end
+
+ def transition_to(status_symbol, performed_by)
+ was_pending = pending?
+
+ self.status = Reviewable.statuses[status_symbol]
+ save!
+ log_history(:transitioned, performed_by)
+ DiscourseEvent.trigger(:reviewable_transitioned_to, status_symbol, self)
+
+ if score_status = ReviewableScore.score_transitions[status_symbol]
+ reviewable_scores.pending.update_all(
+ status: score_status,
+ reviewed_by_id: performed_by.id,
+ reviewed_at: Time.zone.now
+ )
+ end
+
+ Jobs.enqueue(:notify_reviewable, reviewable_id: self.id) if was_pending
+ end
+
+ def post_options
+ Discourse.deprecate(
+ "Reviewable#post_options is deprecated. Please use #payload instead.",
+ output_in_test: true
+ )
+ end
+
+ def self.bulk_perform_targets(performed_by, action, type, target_ids, args = nil)
+ args ||= {}
+ viewable_by(performed_by).where(type: type, target_id: target_ids).each do |r|
+ r.perform(performed_by, action, args)
+ end
+ end
+
+ def self.viewable_by(user, order: nil, preload: true)
+ return none unless user.present?
+
+ result = self.order(order || 'score desc, created_at desc')
+
+ if preload
+ result = result.includes(
+ { created_by: :user_stat },
+ :topic,
+ :target,
+ :target_created_by,
+ :reviewable_histories
+ ).includes(reviewable_scores: { user: :user_stat, meta_topic: :posts })
+ end
+ return result if user.admin?
+
+ result.where(
+ '(reviewable_by_moderator AND :staff) OR (reviewable_by_group_id IN (:group_ids))',
+ staff: user.staff?,
+ group_ids: user.group_users.pluck(:group_id)
+ ).where("category_id IS NULL OR category_id IN (?)", Guardian.new(user).allowed_category_ids)
+ end
+
+ def self.pending_count(user)
+ list_for(user).count
+ end
+
+ def self.list_for(
+ user,
+ status: :pending,
+ category_id: nil,
+ topic_id: nil,
+ type: nil,
+ limit: nil,
+ offset: nil,
+ min_score: nil,
+ username: nil
+ )
+ min_score ||= SiteSetting.min_score_default_visibility
+
+ order = (status == :pending) ? 'score DESC, created_at DESC' : 'created_at DESC'
+
+ if username.present?
+ user_id = User.find_by_username(username)&.id
+ return [] if user_id.blank?
+ end
+
+ return [] if user.blank?
+ result = viewable_by(user, order: order)
+
+ result = by_status(result, status)
+ result = result.where(type: type) if type
+ result = result.where(category_id: category_id) if category_id
+ result = result.where(topic_id: topic_id) if topic_id
+ result = result.where("score >= ?", min_score) if min_score
+
+ # If a reviewable doesn't have a target, allow us to filter on who created that reviewable.
+ if user_id
+ result = result.where(
+ "(target_created_by_id IS NULL AND created_by_id = :user_id) OR (target_created_by_id = :user_id)",
+ user_id: user_id
+ )
+ end
+
+ result = result.limit(limit) if limit
+ result = result.offset(offset) if offset
+ result
+ end
+
+ def serializer
+ self.class.serializer_for(self)
+ end
+
+ def self.lookup_serializer_for(type)
+ "#{type}Serializer".constantize
+ rescue NameError
+ ReviewableSerializer
+ end
+
+ def self.serializer_for(reviewable)
+ type = reviewable.type
+ @@serializers ||= {}
+ @@serializers[type] ||= lookup_serializer_for(type)
+ end
+
+ def create_result(status, transition_to = nil)
+ result = PerformResult.new(self, status)
+ result.transition_to = transition_to
+ yield result if block_given?
+ result
+ end
+
+ def self.scores_with_topics
+ ReviewableScore.joins(reviewable: :topic).where("reviewables.type = ?", name)
+ end
+
+ def self.count_by_date(start_date, end_date, category_id = nil)
+ scores_with_topics
+ .where('reviewable_scores.created_at BETWEEN ? AND ?', start_date, end_date)
+ .where("topics.category_id = COALESCE(?, topics.category_id)", category_id)
+ .group("date(reviewable_scores.created_at)")
+ .order('date(reviewable_scores.created_at)')
+ .count
+ end
+
+protected
+
+ def recalculate_score
+ # Recalculate the pending score and return it
+ result = DB.query(<<~SQL, id: self.id, pending: ReviewableScore.statuses[:pending])
+ UPDATE reviewables
+ SET score = COALESCE((
+ SELECT sum(score)
+ FROM reviewable_scores AS rs
+ WHERE rs.reviewable_id = :id
+ ), 0.0)
+ WHERE id = :id
+ RETURNING score
+ SQL
+
+ # Update topic score
+ DB.query(<<~SQL, topic_id: topic_id, pending: Reviewable.statuses[:pending])
+ UPDATE topics
+ SET reviewable_score = COALESCE((
+ SELECT SUM(score)
+ FROM reviewables AS r
+ WHERE r.topic_id = :topic_id
+ ), 0.0)
+ WHERE id = :topic_id
+ SQL
+
+ self.score = result[0].score
+ end
+
+ def increment_version!(version = nil)
+ version_result = nil
+
+ if version
+ version_result = DB.query_single(
+ "UPDATE reviewables SET version = version + 1 WHERE id = :id AND version = :version RETURNING version",
+ version: version,
+ id: self.id
+ )
+ else
+ # We didn't supply a version to update safely, so just increase it
+ version_result = DB.query_single(
+ "UPDATE reviewables SET version = version + 1 WHERE id = :id RETURNING version",
+ id: self.id
+ )
+ end
+
+ if version_result && version_result[0]
+ self.version = version_result[0]
+ else
+ raise UpdateConflict.new
+ end
+ end
+
+ def self.by_status(partial_result, status)
+ return partial_result if status == :all
+
+ if status == :reviewed
+ partial_result.where(status: [statuses[:approved], statuses[:rejected], statuses[:ignored]])
+ else
+ partial_result.where(status: statuses[status])
+ end
+ end
+
+private
+
+ def update_flag_stats(status:, user_ids:)
+ return unless [:agreed, :disagreed, :ignored].include?(status)
+
+ # Don't count self-flags
+ user_ids -= [post&.user_id]
+ return if user_ids.blank?
+
+ result = DB.query(<<~SQL, user_ids: user_ids)
+ UPDATE user_stats
+ SET flags_#{status} = flags_#{status} + 1
+ WHERE user_id IN (:user_ids)
+ RETURNING user_id, flags_agreed + flags_disagreed + flags_ignored AS total
+ SQL
+
+ Jobs.enqueue(
+ :truncate_user_flag_stats,
+ user_ids: result.select { |r| r.total > Jobs::TruncateUserFlagStats.truncate_to }.map(&:user_id)
+ )
+ end
+end
+
+# == Schema Information
+#
+# Table name: reviewables
+#
+# id :bigint(8) not null, primary key
+# type :string not null
+# status :integer default(0), not null
+# created_by_id :integer not null
+# reviewable_by_moderator :boolean default(FALSE), not null
+# reviewable_by_group_id :integer
+# claimed_by_id :integer
+# category_id :integer
+# topic_id :integer
+# score :float default(0.0), not null
+# potential_spam :boolean default(FALSE), not null
+# target_id :integer
+# target_type :string
+# target_created_by_id :integer
+# payload :json
+# version :integer default(0), not null
+# latest_score :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_reviewables_on_status_and_created_at (status,created_at)
+# index_reviewables_on_status_and_score (status,score)
+# index_reviewables_on_status_and_type (status,type)
+# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
+#
diff --git a/app/models/reviewable_flagged_post.rb b/app/models/reviewable_flagged_post.rb
new file mode 100644
index 00000000000..4270aafcf6e
--- /dev/null
+++ b/app/models/reviewable_flagged_post.rb
@@ -0,0 +1,274 @@
+require_dependency 'reviewable'
+
+class ReviewableFlaggedPost < Reviewable
+
+ def self.counts_for(posts)
+ result = {}
+
+ counts = DB.query(<<~SQL, pending: Reviewable.statuses[:pending])
+ SELECT r.target_id AS post_id,
+ rs.reviewable_score_type,
+ count(*) as total
+ FROM reviewables AS r
+ INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND r.status = :pending
+ GROUP BY r.target_id, rs.reviewable_score_type
+ SQL
+
+ counts.each do |c|
+ result[c.post_id] ||= {}
+ result[c.post_id][c.reviewable_score_type] = c.total
+ end
+
+ result
+ end
+
+ def post
+ @post ||= (target || Post.with_deleted.find_by(id: target_id))
+ end
+
+ def build_actions(actions, guardian, args)
+ return unless pending?
+ return if post.blank?
+
+ agree = actions.add_bundle("#{id}-agree", icon: 'thumbs-up', label: 'reviewables.actions.agree.title')
+
+ build_action(actions, :agree_and_keep, icon: 'thumbs-up', bundle: agree)
+ if guardian.can_suspend?(target_created_by)
+ build_action(actions, :agree_and_suspend, icon: 'ban', bundle: agree, client_action: 'suspend')
+ build_action(actions, :agree_and_silence, icon: 'microphone-slash', bundle: agree, client_action: 'silence')
+ end
+
+ if can_delete_spammer = potential_spam? && guardian.can_delete_all_posts?(target_created_by)
+ build_action(
+ actions,
+ :delete_spammer,
+ icon: 'exclamation-triangle',
+ bundle: agree,
+ confirm: true
+ )
+ end
+
+ if post.user_deleted?
+ build_action(actions, :agree_and_restore, icon: 'far-eye', bundle: agree)
+ elsif !post.hidden?
+ build_action(actions, :agree_and_hide, icon: 'far-eye-slash', bundle: agree)
+ end
+
+ if post.hidden?
+ build_action(actions, :disagree_and_restore, icon: 'thumbs-down')
+ else
+ build_action(actions, :disagree, icon: 'thumbs-down')
+ end
+
+ build_action(actions, :ignore, icon: 'external-link-alt')
+
+ if guardian.is_staff?
+ delete = actions.add_bundle("#{id}-delete", icon: "far-trash-alt", label: "reviewables.actions.delete.title")
+ build_action(actions, :delete_and_ignore, icon: 'external-link-alt', bundle: delete)
+ build_action(actions, :delete_and_agree, icon: 'thumbs-up', bundle: delete)
+ end
+ end
+
+ def perform_ignore(performed_by, args)
+ actions = PostAction.active
+ .where(post_id: target_id)
+ .where(post_action_type_id: PostActionType.notify_flag_type_ids)
+
+ actions.each do |action|
+ action.deferred_at = Time.zone.now
+ action.deferred_by_id = performed_by.id
+ # so callback is called
+ action.save
+ action.add_moderator_post_if_needed(performed_by, :ignored, args[:post_was_deleted])
+ end
+
+ if actions.first.present?
+ DiscourseEvent.trigger(:flag_reviewed, post)
+ DiscourseEvent.trigger(:flag_deferred, actions.first)
+ end
+
+ create_result(:success, :ignored) do |result|
+ result.update_flag_stats = { status: :ignored, user_ids: actions.map(&:user_id) }
+ result.recalculate_score = true
+ end
+ end
+
+ # Penalties are handled by the modal after the action is performed
+ def perform_agree_and_keep(performed_by, args)
+ agree(performed_by, args)
+ end
+
+ def perform_agree_and_suspend(performed_by, args)
+ agree(performed_by, args)
+ end
+
+ def perform_agree_and_silence(performed_by, args)
+ agree(performed_by, args)
+ end
+
+ def perform_delete_spammer(performed_by, args)
+ UserDestroyer.new(performed_by).destroy(
+ post.user,
+ delete_posts: true,
+ prepare_for_destroy: true,
+ block_email: true,
+ block_urls: true,
+ block_ip: true,
+ delete_as_spammer: true,
+ context: "review"
+ )
+
+ agree(performed_by, args)
+ end
+
+ def perform_agree_and_hide(performed_by, args)
+ agree(performed_by, args) do |pa|
+ post.hide!(pa.post_action_type_id)
+ end
+ end
+
+ def perform_agree_and_restore(performed_by, args)
+ agree(performed_by, args) do
+ PostDestroyer.new(performed_by, post).recover
+ end
+ end
+
+ def perform_disagree_and_restore(performed_by, args)
+ result = perform_disagree(performed_by, args)
+ PostDestroyer.new(performed_by, post).recover
+ result
+ end
+
+ def perform_disagree(performed_by, args)
+ # -1 is the automatic system clear
+ action_type_ids =
+ if performed_by.id == Discourse::SYSTEM_USER_ID
+ PostActionType.auto_action_flag_types.values
+ else
+ PostActionType.notify_flag_type_ids
+ end
+
+ actions = PostAction.active.where(post_id: target_id).where(post_action_type_id: action_type_ids)
+
+ actions.each do |action|
+ action.disagreed_at = Time.zone.now
+ action.disagreed_by_id = performed_by.id
+ # so callback is called
+ action.save
+ action.add_moderator_post_if_needed(performed_by, :disagreed)
+ end
+
+ # reset all cached counters
+ cached = {}
+ action_type_ids.each do |atid|
+ column = "#{PostActionType.types[atid]}_count"
+ cached[column] = 0 if ActiveRecord::Base.connection.column_exists?(:posts, column)
+ end
+
+ Post.with_deleted.where(id: target_id).update_all(cached)
+
+ if actions.first.present?
+ DiscourseEvent.trigger(:flag_reviewed, post)
+ DiscourseEvent.trigger(:flag_disagreed, actions.first)
+ end
+
+ # Undo hide/silence if applicable
+ if post&.hidden?
+ post.unhide!
+ UserSilencer.unsilence(post.user) if UserSilencer.was_silenced_for?(post)
+ end
+
+ create_result(:success, :rejected) do |result|
+ result.update_flag_stats = { status: :disagreed, user_ids: actions.map(&:user_id) }
+ result.recalculate_score = true
+ end
+ end
+
+ def perform_delete_and_ignore(performed_by, args)
+ result = perform_ignore(performed_by, args)
+ PostDestroyer.new(performed_by, post).destroy
+ result
+ end
+
+ def perform_delete_and_agree(performed_by, args)
+ result = agree(performed_by, args)
+ PostDestroyer.new(performed_by, post).destroy
+ result
+ end
+protected
+
+ def agree(performed_by, args)
+ actions = PostAction.active
+ .where(post_id: target_id)
+ .where(post_action_type_id: PostActionType.notify_flag_types.values)
+
+ trigger_spam = false
+ actions.each do |action|
+ action.agreed_at = Time.zone.now
+ action.agreed_by_id = performed_by.id
+ # so callback is called
+ action.save
+ action.add_moderator_post_if_needed(performed_by, :agreed, args[:post_was_deleted])
+ trigger_spam = true if action.post_action_type_id == PostActionType.types[:spam]
+ end
+
+ DiscourseEvent.trigger(:confirmed_spam_post, post) if trigger_spam
+
+ if actions.first.present?
+ DiscourseEvent.trigger(:flag_reviewed, post)
+ DiscourseEvent.trigger(:flag_agreed, actions.first)
+ yield(actions.first) if block_given?
+ end
+
+ create_result(:success, :approved) do |result|
+ result.update_flag_stats = { status: :agreed, user_ids: actions.map(&:user_id) }
+ result.recalculate_score = true
+ end
+ end
+
+ def build_action(actions, id, icon:, bundle: nil, client_action: nil, confirm: false)
+ actions.add(id, bundle: bundle) do |action|
+ prefix = "reviewables.actions.#{id}"
+ action.icon = icon
+ action.label = "#{prefix}.title"
+ action.description = "#{prefix}.description"
+ action.client_action = client_action
+ action.confirm_message = "#{prefix}.confirm" if confirm
+ end
+ end
+
+end
+
+# == Schema Information
+#
+# Table name: reviewables
+#
+# id :bigint(8) not null, primary key
+# type :string not null
+# status :integer default(0), not null
+# created_by_id :integer not null
+# reviewable_by_moderator :boolean default(FALSE), not null
+# reviewable_by_group_id :integer
+# claimed_by_id :integer
+# category_id :integer
+# topic_id :integer
+# score :float default(0.0), not null
+# potential_spam :boolean default(FALSE), not null
+# target_id :integer
+# target_type :string
+# target_created_by_id :integer
+# payload :json
+# version :integer default(0), not null
+# latest_score :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_reviewables_on_status_and_created_at (status,created_at)
+# index_reviewables_on_status_and_score (status,score)
+# index_reviewables_on_status_and_type (status,type)
+# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
+#
diff --git a/app/models/reviewable_history.rb b/app/models/reviewable_history.rb
new file mode 100644
index 00000000000..1df79daadd7
--- /dev/null
+++ b/app/models/reviewable_history.rb
@@ -0,0 +1,32 @@
+class ReviewableHistory < ActiveRecord::Base
+ belongs_to :reviewable
+ belongs_to :created_by, class_name: 'User'
+
+ def self.types
+ @types ||= Enum.new(
+ created: 0,
+ transitioned: 1,
+ edited: 2
+ )
+ end
+
+end
+
+# == Schema Information
+#
+# Table name: reviewable_histories
+#
+# id :bigint(8) not null, primary key
+# reviewable_id :integer not null
+# reviewable_history_type :integer not null
+# status :integer not null
+# created_by_id :integer not null
+# edited :json
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_reviewable_histories_on_created_by_id (created_by_id)
+# index_reviewable_histories_on_reviewable_id (reviewable_id)
+#
diff --git a/app/models/reviewable_queued_post.rb b/app/models/reviewable_queued_post.rb
new file mode 100644
index 00000000000..c60fd5d9358
--- /dev/null
+++ b/app/models/reviewable_queued_post.rb
@@ -0,0 +1,133 @@
+require_dependency 'reviewable'
+require_dependency 'user_destroyer'
+
+class ReviewableQueuedPost < Reviewable
+
+ after_create do
+ # Backwards compatibility, new code should listen for `reviewable_created`
+ DiscourseEvent.trigger(:queued_post_created, self)
+ end
+
+ def build_actions(actions, guardian, args)
+ return unless guardian.is_staff?
+
+ actions.add(:approve) unless approved?
+ actions.add(:reject) unless rejected?
+
+ if pending? && guardian.can_delete_user?(created_by)
+ actions.add(:delete_user) do |action|
+ action.icon = 'trash-alt'
+ action.label = 'reviewables.actions.delete_user.title'
+ action.confirm_message = 'reviewables.actions.delete_user.confirm'
+ end
+ end
+ end
+
+ def build_editable_fields(fields, guardian, args)
+ return unless guardian.is_staff?
+
+ # We can edit category / title if it's a new topic
+ if topic_id.blank?
+ fields.add('category_id', :category)
+ fields.add('payload.title', :text)
+ fields.add('payload.tags', :tags)
+ end
+
+ fields.add('payload.raw', :editor)
+ end
+
+ def create_options
+ result = payload.symbolize_keys
+ result[:cooking_options].symbolize_keys! if result[:cooking_options]
+ result[:topic_id] = topic_id if topic_id
+ result[:category] = category_id if category_id
+ result
+ end
+
+ def perform_approve(performed_by, args)
+ created_post = nil
+
+ creator = PostCreator.new(created_by, create_options.merge(
+ skip_validations: true,
+ skip_jobs: true,
+ skip_events: true,
+ skip_guardian: true
+ ))
+ created_post = creator.create
+
+ unless created_post && creator.errors.blank?
+ return create_result(:failure) { |r| r.errors = creator.errors }
+ end
+
+ payload['created_post_id'] = created_post.id
+ payload['created_topic_id'] = created_post.topic_id unless topic_id
+ save
+
+ UserSilencer.unsilence(created_by, performed_by) if created_by.silenced?
+
+ StaffActionLogger.new(performed_by).log_post_approved(created_post) if performed_by.staff?
+
+ # Backwards compatibility, new code should listen for `reviewable_transitioned_to`
+ DiscourseEvent.trigger(:approved_post, self, created_post)
+
+ create_result(:success, :approved) { |result| result.created_post = created_post }
+ end
+
+ def perform_reject(performed_by, args)
+ # Backwards compatibility, new code should listen for `reviewable_transitioned_to`
+ DiscourseEvent.trigger(:rejected_post, self)
+
+ StaffActionLogger.new(performed_by).log_post_rejected(self, DateTime.now) if performed_by.staff?
+
+ create_result(:success, :rejected)
+ end
+
+ def perform_delete_user(performed_by, args)
+ delete_options = {
+ context: I18n.t('reviewables.actions.delete_user.reason'),
+ delete_posts: true,
+ delete_as_spammer: true
+ }
+
+ if Rails.env.production?
+ delete_options.merge!(block_email: true, block_ip: true)
+ end
+
+ reviewable_ids = Reviewable.where(created_by: created_by).pluck(:id)
+ UserDestroyer.new(performed_by).destroy(created_by, delete_options)
+ create_result(:success) { |r| r.remove_reviewable_ids = reviewable_ids }
+ end
+
+end
+
+# == Schema Information
+#
+# Table name: reviewables
+#
+# id :bigint(8) not null, primary key
+# type :string not null
+# status :integer default(0), not null
+# created_by_id :integer not null
+# reviewable_by_moderator :boolean default(FALSE), not null
+# reviewable_by_group_id :integer
+# claimed_by_id :integer
+# category_id :integer
+# topic_id :integer
+# score :float default(0.0), not null
+# potential_spam :boolean default(FALSE), not null
+# target_id :integer
+# target_type :string
+# target_created_by_id :integer
+# payload :json
+# version :integer default(0), not null
+# latest_score :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_reviewables_on_status_and_created_at (status,created_at)
+# index_reviewables_on_status_and_score (status,score)
+# index_reviewables_on_status_and_type (status,type)
+# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
+#
diff --git a/app/models/reviewable_score.rb b/app/models/reviewable_score.rb
new file mode 100644
index 00000000000..057afb5612b
--- /dev/null
+++ b/app/models/reviewable_score.rb
@@ -0,0 +1,94 @@
+class ReviewableScore < ActiveRecord::Base
+ belongs_to :reviewable
+ belongs_to :user
+ belongs_to :reviewed_by, class_name: 'User'
+ belongs_to :meta_topic, class_name: 'Topic'
+
+ # To keep things simple the types correspond to `PostActionType` for backwards
+ # compatibility, but we can add extra reasons for scores.
+ def self.types
+ @types ||= PostActionType.flag_types.merge(
+ needs_approval: 9
+ )
+ end
+
+ def self.statuses
+ @statuses ||= Enum.new(
+ pending: 0,
+ agreed: 1,
+ disagreed: 2,
+ ignored: 3
+ )
+ end
+
+ def self.score_transitions
+ {
+ approved: statuses[:agreed],
+ rejected: statuses[:disagreed],
+ ignored: statuses[:ignored]
+ }
+ end
+
+ # Generate `pending?`, `rejected?`, etc helper methods
+ statuses.each do |name, id|
+ define_method("#{name}?") { status == id }
+ self.class.define_method(name) { where(status: id) }
+ end
+
+ def score_type
+ Reviewable::Collection::Item.new(reviewable_score_type)
+ end
+
+ def took_action?
+ take_action_bonus > 0
+ end
+
+ # A user's flag score is:
+ # 1.0 + trust_level + user_accuracy_bonus
+ # (trust_level is 5 for staff)
+ def self.user_flag_score(user)
+ 1.0 + (user.staff? ? 5.0 : user.trust_level.to_f) + user_accuracy_bonus(user)
+ end
+
+ # A user's accuracy bonus is:
+ # if 5 or less flags => 0.0
+ # if > 5 flags => (agreed flags / total flags) * 5.0
+ def self.user_accuracy_bonus(user)
+ user_stat = user&.user_stat
+ return 0.0 if user_stat.blank?
+
+ total = (user_stat.flags_agreed + user_stat.flags_disagreed + user_stat.flags_ignored).to_f
+ return 0.0 if total <= 5
+
+ (user_stat.flags_agreed / total) * 5.0
+ end
+
+ def reviewable_conversation
+ return if meta_topic.blank?
+ Reviewable::Conversation.new(meta_topic)
+ end
+
+end
+
+# == Schema Information
+#
+# Table name: reviewable_scores
+#
+# id :bigint(8) not null, primary key
+# reviewable_id :integer not null
+# user_id :integer not null
+# reviewable_score_type :integer not null
+# status :integer not null
+# score :float default(0.0), not null
+# take_action_bonus :float default(0.0), not null
+# reviewed_by_id :integer
+# reviewed_at :datetime
+# meta_topic_id :integer
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_reviewable_scores_on_reviewable_id (reviewable_id)
+# index_reviewable_scores_on_user_id (user_id)
+#
diff --git a/app/models/reviewable_user.rb b/app/models/reviewable_user.rb
new file mode 100644
index 00000000000..b1778d44575
--- /dev/null
+++ b/app/models/reviewable_user.rb
@@ -0,0 +1,84 @@
+require_dependency 'reviewable'
+
+class ReviewableUser < Reviewable
+ def self.create_for(user)
+ create(
+ created_by_id: Discourse.system_user.id,
+ target: user
+ )
+ end
+
+ def build_actions(actions, guardian, args)
+ return unless pending?
+
+ actions.add(:approve) if guardian.can_approve?(target) || args[:approved_by_invite]
+ actions.add(:reject) if guardian.can_delete_user?(target)
+ end
+
+ def perform_approve(performed_by, args)
+ ReviewableUser.set_approved_fields!(target, performed_by)
+ target.save!
+
+ DiscourseEvent.trigger(:user_approved, target)
+
+ if args[:send_email] != false && SiteSetting.must_approve_users?
+ Jobs.enqueue(
+ :critical_user_email,
+ type: :signup_after_approval,
+ user_id: target.id
+ )
+ end
+ StaffActionLogger.new(performed_by).log_user_approve(target)
+
+ create_result(:success, :approved)
+ end
+
+ def perform_reject(performed_by, args)
+ destroyer = UserDestroyer.new(performed_by)
+ destroyer.destroy(target)
+
+ create_result(:success, :rejected)
+ rescue UserDestroyer::PostsExistError
+ create_result(:failed)
+ end
+
+ # Update's the user's fields for approval but does not save. This
+ # can be used when generating a new user that is approved on create
+ def self.set_approved_fields!(user, approved_by)
+ user.approved = true
+ user.approved_by ||= approved_by
+ user.approved_at ||= Time.zone.now
+ end
+end
+
+# == Schema Information
+#
+# Table name: reviewables
+#
+# id :bigint(8) not null, primary key
+# type :string not null
+# status :integer default(0), not null
+# created_by_id :integer not null
+# reviewable_by_moderator :boolean default(FALSE), not null
+# reviewable_by_group_id :integer
+# claimed_by_id :integer
+# category_id :integer
+# topic_id :integer
+# score :float default(0.0), not null
+# potential_spam :boolean default(FALSE), not null
+# target_id :integer
+# target_type :string
+# target_created_by_id :integer
+# payload :json
+# version :integer default(0), not null
+# latest_score :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_reviewables_on_status_and_created_at (status,created_at)
+# index_reviewables_on_status_and_score (status,score)
+# index_reviewables_on_status_and_type (status,type)
+# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
+#
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 028b6fc7a55..b1f428e91d5 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -55,17 +55,18 @@ class Topic < ActiveRecord::Base
CategoryTagStat.topic_deleted(self) if self.tags.present?
end
super(trashed_by)
- update_flagged_posts_count
self.topic_embed.trash! if has_topic_embed?
end
- def recover!
+ def recover!(recovered_by = nil)
unless deleted_at.nil?
update_category_topic_count_by(1)
CategoryTagStat.topic_recovered(self) if self.tags.present?
end
- super
- update_flagged_posts_count
+
+ # Note parens are required because superclass doesn't take `recovered_by`
+ super()
+
unless (topic_embed = TopicEmbed.with_deleted.find_by_topic_id(id)).nil?
topic_embed.recover!
end
@@ -122,7 +123,6 @@ class Topic < ActiveRecord::Base
has_many :allowed_groups, through: :topic_allowed_groups, source: :group
has_many :allowed_group_users, through: :allowed_groups, source: :users
has_many :allowed_users, through: :topic_allowed_users, source: :user
- has_many :queued_posts
has_many :topic_tags
has_many :tags, through: :topic_tags, dependent: :destroy # dependent destroy applies to the topic_tags records
@@ -143,6 +143,7 @@ class Topic < ActiveRecord::Base
has_many :topic_invites
has_many :invites, through: :topic_invites, source: :invite
has_many :topic_timers, dependent: :destroy
+ has_many :reviewables
has_one :user_warning
has_one :first_post, -> { where post_number: 1 }, class_name: 'Post'
@@ -246,7 +247,6 @@ class Topic < ActiveRecord::Base
end
SearchIndexer.index(self)
- UserActionCreator.log_topic(self)
end
after_update do
@@ -313,9 +313,7 @@ class Topic < ActiveRecord::Base
end
def has_flags?
- FlagQuery.flagged_post_actions(filter: "active")
- .where("topics.id" => id)
- .exists?
+ ReviewableFlaggedPost.pending.default_visible.where(topic_id: id).exists?
end
def is_official_warning?
@@ -365,10 +363,6 @@ class Topic < ActiveRecord::Base
fancy_title
end
- def pending_posts_count
- queued_posts.new_count
- end
-
# Returns hot topics since a date for display in email digest.
def self.for_digest(user, since, opts = nil)
opts = opts || {}
@@ -803,6 +797,8 @@ class Topic < ActiveRecord::Base
cat = Category.find_by(id: new_category_id)
return false unless cat
+ reviewables.update_all(category_id: new_category_id)
+
changed_to_category(cat)
end
@@ -963,10 +959,6 @@ class Topic < ActiveRecord::Base
Topic.reset_highest(id)
end
- def update_flagged_posts_count
- PostAction.update_flagged_posts_count
- end
-
def update_action_counts
update_column(:like_count, Post.where(topic_id: id).sum(:like_count))
end
@@ -1404,6 +1396,18 @@ class Topic < ActiveRecord::Base
update!(bumped_at: post.created_at)
end
+ def auto_close_threshold_reached?
+ return if user&.staff?
+
+ scores = ReviewableScore.pending
+ .joins(:reviewable)
+ .where("reviewables.topic_id = ?", self.id)
+ .pluck("COUNT(DISTINCT reviewable_scores.user_id), COALESCE(SUM(reviewable_scores.score), 0.0)")
+ .first
+
+ scores[0] >= SiteSetting.num_flaggers_to_close_topic && scores[1] >= SiteSetting.score_to_auto_close_topic
+ end
+
private
def invite_to_private_message(invited_by, target_user, guardian)
@@ -1529,6 +1533,7 @@ end
# spam_count :integer default(0), not null
# pinned_at :datetime
# score :float
+# percent_rank :float default(1.0), not null
# subtype :string
# slug :string
# deleted_by_id :integer
@@ -1540,6 +1545,7 @@ end
# fancy_title :string(400)
# highest_staff_post_number :integer default(0), not null
# featured_link :string
+# reviewable_score :float default(0.0), not null
#
# Indexes
#
diff --git a/app/models/topic_converter.rb b/app/models/topic_converter.rb
index 76704a7a8ce..038f50cc413 100644
--- a/app/models/topic_converter.rb
+++ b/app/models/topic_converter.rb
@@ -25,20 +25,7 @@ class TopicConverter
@topic.save
update_user_stats
update_category_topic_count_by(1)
-
- # TODO: Every post in a PRIVATE MESSAGE looks the same: each is a UserAction::NEW_PRIVATE_MESSAGE.
- # So we need to remove all those user actions and re-log all the posts.
- # Post counting depends on the correct UserActions (NEW_TOPIC, REPLY), so once a private topic
- # becomes a public topic, post counts are wrong. The reverse is not so bad because
- # we don't count NEW_PRIVATE_MESSAGE in any public stats.
- # TBD: why do so many specs fail with this change?
-
- # UserAction.where(target_topic_id: @topic.id, action_type: [UserAction::GOT_PRIVATE_MESSAGE, UserAction::NEW_PRIVATE_MESSAGE]).find_each do |ua|
- # UserAction.remove_action!(ua.attributes.symbolize_keys.slice(:action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id))
- # end
- # @topic.posts.each do |post|
- # UserActionCreator.log_post(post) unless post.post_number == 1
- # end
+ Jobs.enqueue(:topic_action_converter, topic_id: @topic.id)
watch_topic(topic)
end
@@ -52,6 +39,7 @@ class TopicConverter
@topic.archetype = Archetype.private_message
add_allowed_users
@topic.save!
+ Jobs.enqueue(:topic_action_converter, topic_id: @topic.id)
watch_topic(topic)
end
@topic
diff --git a/app/models/user.rb b/app/models/user.rb
index 2acfad883b7..891665fa874 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -93,6 +93,8 @@ class User < ActiveRecord::Base
has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: 'GroupHistory'
has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: 'GroupHistory'
+ has_many :reviewable_scores, dependent: :destroy
+
delegate :last_sent_email_address, to: :email_logs
validates_presence_of :username
@@ -114,6 +116,7 @@ class User < ActiveRecord::Base
after_create :set_random_avatar
after_create :ensure_in_trust_level_group
after_create :set_default_categories_preferences
+ after_create :create_reviewable
before_save :update_username_lower
before_save :ensure_password_is_hashed
@@ -411,19 +414,22 @@ class User < ActiveRecord::Base
end
# Approve this user
- def approve(approver, send_mail = true)
- self.approved = true
- self.approved_at = Time.zone.now
- self.approved_by = approver
+ def approve(approved_by, send_mail = true)
+ Discourse.deprecate("User#approve is deprecated. Please use the Reviewable API instead.", output_in_test: true, since: "2.3.0beta5", drop_from: "2.4")
- if result = save
- send_approval_email if send_mail
- DiscourseEvent.trigger(:user_approved, self)
+ # Backwards compatibility - in case plugins or something is using the old API which accepted
+ # either a Number or object. Probably should remove at some point
+ approved_by = User.find_by(id: approved_by) if approved_by.is_a?(Numeric)
+
+ if reviewable_user = ReviewableUser.find_by(target: self)
+ result = reviewable_user.perform(approved_by, :approve, send_email: send_mail)
+ if result.success?
+ Reviewable.set_approved_fields!(self, approved_by)
+ return true
+ end
end
- StaffActionLogger.new(approver).log_user_approve(self)
-
- result
+ false
end
def self.email_hash(email)
@@ -813,7 +819,7 @@ class User < ActiveRecord::Base
def delete_posts_in_batches(guardian, batch_size = 20)
raise Discourse::InvalidAccess unless guardian.can_delete_all_posts? self
- QueuedPost.where(user_id: id).delete_all
+ Reviewable.where(created_by_id: id).delete_all
posts.order("post_number desc").limit(batch_size).each do |p|
PostDestroyer.new(guardian.user, p).destroy
@@ -974,6 +980,8 @@ class User < ActiveRecord::Base
# Flag all posts from a user as spam
def flag_linked_posts_as_spam
+ results = []
+
disagreed_flag_post_ids = PostAction.where(post_action_type_id: PostActionType.types[:spam])
.where.not(disagreed_at: nil)
.pluck(:post_id)
@@ -981,13 +989,12 @@ class User < ActiveRecord::Base
topic_links.includes(:post)
.where.not(post_id: disagreed_flag_post_ids)
.each do |tl|
- begin
- message = I18n.t('flag_reason.spam_hosts', domain: tl.domain, base_path: Discourse.base_path)
- PostAction.act(Discourse.system_user, tl.post, PostActionType.types[:spam], message: message)
- rescue PostAction::AlreadyActed
- # If the user has already acted, just ignore it
- end
+
+ message = I18n.t('flag_reason.spam_hosts', domain: tl.domain, base_path: Discourse.base_path)
+ results << PostActionCreator.create(Discourse.system_user, tl.post, :spam, message: message)
end
+
+ results
end
def has_uploaded_avatar
@@ -1231,6 +1238,20 @@ class User < ActiveRecord::Base
Group.user_trust_level_change!(id, trust_level)
end
+ def create_reviewable
+ return unless SiteSetting.must_approve_users? || SiteSetting.invite_only?
+ return if approved?
+
+ reviewable = ReviewableUser.needs_review!(target: self, created_by: Discourse.system_user, reviewable_by_moderator: true)
+ reviewable.add_score(
+ Discourse.system_user,
+ ReviewableScore.types[:needs_approval],
+ force_review: true
+ )
+
+ reviewable
+ end
+
def create_user_stat
stat = UserStat.new(new_since: Time.now)
stat.user_id = id
@@ -1310,15 +1331,6 @@ class User < ActiveRecord::Base
end
end
- def send_approval_email
- if SiteSetting.must_approve_users
- Jobs.enqueue(:critical_user_email,
- type: :signup_after_approval,
- user_id: id
- )
- end
- end
-
def set_default_categories_preferences
return if self.staged?
@@ -1465,6 +1477,7 @@ end
# staged :boolean default(FALSE), not null
# first_seen_at :datetime
# silenced_till :datetime
+# group_locked_trust_level :integer
# manual_locked_trust_level :integer
#
# Indexes
diff --git a/app/models/user_action.rb b/app/models/user_action.rb
index 8a7d8d06020..25afc8619b8 100644
--- a/app/models/user_action.rb
+++ b/app/models/user_action.rb
@@ -1,5 +1,9 @@
class UserAction < ActiveRecord::Base
+ self.ignored_columns = %w{
+ queued_post_id
+ }
+
belongs_to :user
belongs_to :target_post, class_name: "Post"
belongs_to :target_topic, class_name: "Topic"
@@ -18,14 +22,12 @@ class UserAction < ActiveRecord::Base
EDIT = 11
NEW_PRIVATE_MESSAGE = 12
GOT_PRIVATE_MESSAGE = 13
- PENDING = 14
SOLVED = 15
ASSIGNED = 16
ORDER = Hash[*[
GOT_PRIVATE_MESSAGE,
NEW_PRIVATE_MESSAGE,
- PENDING,
NEW_TOPIC,
REPLY,
RESPONSE,
@@ -149,48 +151,6 @@ class UserAction < ActiveRecord::Base
topic_archived
}.map! { |s| "NULL as #{s}" }.join(", ")
- def self.stream_queued(opts = nil)
- opts ||= {}
-
- offset = opts[:offset] || 0
- limit = opts[:limit] || 60
-
- # this is somewhat ugly, but the serializer wants all these columns
- # it is more correct to have an object with all the fields needed
- # cause then we can catch and change if we ever add columns
- builder = DB.build <<~SQL
- SELECT
- a.id,
- t.title,
- a.action_type,
- a.created_at,
- t.id topic_id,
- u.username,
- u.name,
- u.id AS user_id,
- qp.raw,
- t.category_id,
- #{NULL_QUEUED_STREAM_COLS}
- FROM user_actions as a
- JOIN queued_posts AS qp ON qp.id = a.queued_post_id
- LEFT OUTER JOIN topics t on t.id = qp.topic_id
- JOIN users u on u.id = a.user_id
- LEFT JOIN categories c on c.id = t.category_id
- /*where*/
- /*order_by*/
- /*offset*/
- /*limit*/
- SQL
-
- builder
- .where('a.user_id = :user_id', user_id: opts[:user_id].to_i)
- .where('action_type = :pending', pending: UserAction::PENDING)
- .order_by("a.created_at desc")
- .offset(offset.to_i)
- .limit(limit.to_i)
- .query
- end
-
def self.stream(opts = nil)
opts ||= {}
@@ -281,12 +241,8 @@ class UserAction < ActiveRecord::Base
def self.log_action!(hash)
required_parameters = [:action_type, :user_id, :acting_user_id]
- if hash[:action_type] == UserAction::PENDING
- required_parameters << :queued_post_id
- else
- required_parameters << :target_post_id
- required_parameters << :target_topic_id
- end
+ required_parameters << :target_post_id
+ required_parameters << :target_topic_id
require_parameters(hash, *required_parameters)
@@ -415,7 +371,6 @@ class UserAction < ActiveRecord::Base
unless guardian.can_see_notifications?(User.where(id: user_id).first)
builder.where("a.action_type not in (#{BOOKMARK})")
- builder.where('a.action_type <> :pending', pending: UserAction::PENDING)
end
if !guardian.can_see_private_messages?(user_id) || ignore_private_messages || !guardian.user
@@ -470,7 +425,6 @@ end
# acting_user_id :integer
# created_at :datetime not null
# updated_at :datetime not null
-# queued_post_id :integer
#
# Indexes
#
diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb
index 916a01edd0a..17bbb29e07b 100644
--- a/app/models/web_hook.rb
+++ b/app/models/web_hook.rb
@@ -56,6 +56,10 @@ class WebHook < ActiveRecord::Base
end
def self.enqueue_object_hooks(type, object, event, serializer = nil)
+ if type == :flag
+ Discourse.deprecate("The flags webhook is deprecated. Please use reviewable instead.")
+ end
+
if active_web_hooks(type).exists?
payload = WebHook.generate_payload(type, object, serializer)
diff --git a/app/models/web_hook_event_type.rb b/app/models/web_hook_event_type.rb
index 8841bb64e48..fe0dce46d00 100644
--- a/app/models/web_hook_event_type.rb
+++ b/app/models/web_hook_event_type.rb
@@ -7,6 +7,7 @@ class WebHookEventType < ActiveRecord::Base
TAG = 6
FLAG = 7
QUEUED_POST = 8
+ REVIEWABLE = 9
has_and_belongs_to_many :web_hooks
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index b52b9b36063..39aff7073ad 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -8,7 +8,6 @@ class CurrentUserSerializer < BasicUserSerializer
:read_first_notification?,
:admin?,
:notification_channel_position,
- :site_flagged_posts_count,
:moderator?,
:staff?,
:title,
@@ -30,8 +29,7 @@ class CurrentUserSerializer < BasicUserSerializer
:muted_category_ids,
:dismissed_banner_key,
:is_anonymous,
- :post_queue_new_count,
- :show_queued_posts,
+ :reviewable_count,
:read_faq,
:automatically_unpin_topics,
:mailing_list_mode,
@@ -58,10 +56,6 @@ class CurrentUserSerializer < BasicUserSerializer
scope.can_create_topic?(nil)
end
- def include_site_flagged_posts_count?
- object.staff?
- end
-
def read_faq
object.user_stat.read_faq?
end
@@ -106,10 +100,6 @@ class CurrentUserSerializer < BasicUserSerializer
object.user_option.redirected_to_top
end
- def site_flagged_posts_count
- PostAction.flagged_posts_count
- end
-
def can_send_private_email_messages
scope.can_send_private_messages_to_email?
end
@@ -189,20 +179,8 @@ class CurrentUserSerializer < BasicUserSerializer
object.anonymous?
end
- def post_queue_new_count
- QueuedPost.new_count
- end
-
- def include_post_queue_new_count?
- object.staff?
- end
-
- def show_queued_posts
- true
- end
-
- def include_show_queued_posts?
- object.staff? && (NewPostManager.queue_enabled? || QueuedPost.new_count > 0)
+ def reviewable_count
+ Reviewable.list_for(object).count
end
def mailing_list_mode
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 703d312376c..d70a1062159 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -257,7 +257,7 @@ class PostSerializer < BasicPostSerializer
summary[:can_defer_flags] = true if scope.is_staff? &&
PostActionType.flag_types_without_custom.values.include?(id) &&
active_flags.present? && active_flags.has_key?(id) &&
- active_flags[id].count > 0
+ active_flags[id] > 0
end
if actions.present? && actions.has_key?(id)
diff --git a/app/serializers/queued_post_serializer.rb b/app/serializers/queued_post_serializer.rb
index b2d215cf5e2..4b5f25550b6 100644
--- a/app/serializers/queued_post_serializer.rb
+++ b/app/serializers/queued_post_serializer.rb
@@ -1,28 +1,49 @@
+# Deprecated, should be removed once users have sufficient opportunity to do so
class QueuedPostSerializer < ApplicationSerializer
- attributes :id,
- :queue,
- :user_id,
- :state,
- :topic_id,
- :approved_by_id,
- :rejected_by_id,
- :raw,
- :post_options,
- :created_at,
- :category_id,
- :can_delete_user
-
- has_one :user, serializer: AdminUserListSerializer
+ attributes(
+ :id,
+ :queue,
+ :user_id,
+ :state,
+ :topic_id,
+ :approved_by_id,
+ :rejected_by_id,
+ :raw,
+ :post_options,
+ :created_at,
+ :category_id,
+ :can_delete_user
+ )
+ has_one :created_by, serializer: AdminUserListSerializer, root: :users
has_one :topic, serializer: BasicTopicSerializer
- def category_id
- cat_id = object.topic.try(:category_id) || object.post_options['category']
- cat_id.to_i if cat_id
+ def queue
+ 'default'
end
- def include_category_id?
- category_id.present?
+ def user_id
+ object.created_by_id
+ end
+
+ def state
+ object.status + 1
+ end
+
+ def approved_by_id
+ who_did(:approved)
+ end
+
+ def rejected_by_id
+ who_did(:rejected)
+ end
+
+ def raw
+ object.payload['raw']
+ end
+
+ def post_options
+ object.payload.except('raw')
end
def can_delete_user
@@ -30,7 +51,20 @@ class QueuedPostSerializer < ApplicationSerializer
end
def include_can_delete_user?
- user && user.trust_level == TrustLevel[0]
+ created_by && created_by.trust_level == TrustLevel[0]
+ end
+
+protected
+
+ def who_did(status)
+ object.
+ reviewable_histories.
+ where(
+ reviewable_history_type: ReviewableHistory.types[:transitioned],
+ status: Reviewable.statuses[status]
+ ).
+ order(:created_at)
+ .last&.created_by_id
end
end
diff --git a/app/serializers/reviewable_action_serializer.rb b/app/serializers/reviewable_action_serializer.rb
new file mode 100644
index 00000000000..6942ccd0f59
--- /dev/null
+++ b/app/serializers/reviewable_action_serializer.rb
@@ -0,0 +1,28 @@
+class ReviewableActionSerializer < ApplicationSerializer
+ attributes :id, :icon, :label, :confirm_message, :description, :client_action
+
+ def label
+ I18n.t(object.label)
+ end
+
+ def confirm_message
+ I18n.t(object.confirm_message)
+ end
+
+ def description
+ I18n.t(object.description, default: nil)
+ end
+
+ def include_description?
+ description.present?
+ end
+
+ def include_confirm_message?
+ object.confirm_message.present?
+ end
+
+ def include_client_action?
+ object.client_action.present?
+ end
+
+end
diff --git a/app/serializers/reviewable_bundled_action_serializer.rb b/app/serializers/reviewable_bundled_action_serializer.rb
new file mode 100644
index 00000000000..06654390152
--- /dev/null
+++ b/app/serializers/reviewable_bundled_action_serializer.rb
@@ -0,0 +1,8 @@
+class ReviewableBundledActionSerializer < ApplicationSerializer
+ attributes :id, :icon, :label
+ has_many :actions, serializer: ReviewableActionSerializer, root: 'actions'
+
+ def label
+ I18n.t(object.label)
+ end
+end
diff --git a/app/serializers/reviewable_conversation_post_serializer.rb b/app/serializers/reviewable_conversation_post_serializer.rb
new file mode 100644
index 00000000000..026289ed6cf
--- /dev/null
+++ b/app/serializers/reviewable_conversation_post_serializer.rb
@@ -0,0 +1,4 @@
+class ReviewableConversationPostSerializer < ApplicationSerializer
+ attributes :id, :excerpt
+ has_one :user, serializer: BasicUserSerializer, root: 'users'
+end
diff --git a/app/serializers/reviewable_conversation_serializer.rb b/app/serializers/reviewable_conversation_serializer.rb
new file mode 100644
index 00000000000..d31d0fccdaa
--- /dev/null
+++ b/app/serializers/reviewable_conversation_serializer.rb
@@ -0,0 +1,4 @@
+class ReviewableConversationSerializer < ApplicationSerializer
+ attributes :id, :permalink, :has_more
+ has_many :conversation_posts, serializer: ReviewableConversationPostSerializer
+end
diff --git a/app/serializers/reviewable_editable_field_serializer.rb b/app/serializers/reviewable_editable_field_serializer.rb
new file mode 100644
index 00000000000..d169b69c599
--- /dev/null
+++ b/app/serializers/reviewable_editable_field_serializer.rb
@@ -0,0 +1,3 @@
+class ReviewableEditableFieldSerializer < ApplicationSerializer
+ attributes :id, :type
+end
diff --git a/app/serializers/reviewable_flagged_post_serializer.rb b/app/serializers/reviewable_flagged_post_serializer.rb
new file mode 100644
index 00000000000..ca862bd57c7
--- /dev/null
+++ b/app/serializers/reviewable_flagged_post_serializer.rb
@@ -0,0 +1,3 @@
+class ReviewableFlaggedPostSerializer < ReviewableSerializer
+ target_attributes :cooked, :raw
+end
diff --git a/app/serializers/reviewable_history_serializer.rb b/app/serializers/reviewable_history_serializer.rb
new file mode 100644
index 00000000000..afd937ecaa9
--- /dev/null
+++ b/app/serializers/reviewable_history_serializer.rb
@@ -0,0 +1,6 @@
+class ReviewableHistorySerializer < ApplicationSerializer
+
+ attributes :id, :reviewable_history_type, :status, :created_at
+ has_one :created_by, serializer: BasicUserSerializer, root: 'users'
+
+end
diff --git a/app/serializers/reviewable_perform_result_serializer.rb b/app/serializers/reviewable_perform_result_serializer.rb
new file mode 100644
index 00000000000..813bc5f99a1
--- /dev/null
+++ b/app/serializers/reviewable_perform_result_serializer.rb
@@ -0,0 +1,40 @@
+class ReviewablePerformResultSerializer < ApplicationSerializer
+
+ attributes(
+ :success,
+ :transition_to,
+ :transition_to_id,
+ :created_post_id,
+ :created_post_topic_id,
+ :remove_reviewable_ids,
+ :version
+ )
+
+ def success
+ object.success?
+ end
+
+ def transition_to_id
+ Reviewable.statuses[transition_to]
+ end
+
+ def version
+ object.reviewable.version
+ end
+
+ def created_post_id
+ object.created_post.id
+ end
+
+ def include_created_post_id?
+ object.created_post.present?
+ end
+
+ def created_post_topic_id
+ object.created_post_topic.id
+ end
+
+ def include_created_post_topic_id?
+ object.created_post_topic.present?
+ end
+end
diff --git a/app/serializers/reviewable_queued_post_serializer.rb b/app/serializers/reviewable_queued_post_serializer.rb
new file mode 100644
index 00000000000..a6e97f7d26a
--- /dev/null
+++ b/app/serializers/reviewable_queued_post_serializer.rb
@@ -0,0 +1,19 @@
+class ReviewableQueuedPostSerializer < ReviewableSerializer
+
+ payload_attributes(
+ :raw,
+ :title,
+ :archetype,
+ :category,
+ :visible,
+ :is_warning,
+ :first_post_checks,
+ :featured_link,
+ :reply_to_post_number,
+ :is_poll,
+ :typing_duration_msecs,
+ :composer_open_duration_msecs,
+ :tags
+ )
+
+end
diff --git a/app/serializers/reviewable_score_serializer.rb b/app/serializers/reviewable_score_serializer.rb
new file mode 100644
index 00000000000..017ecbafee8
--- /dev/null
+++ b/app/serializers/reviewable_score_serializer.rb
@@ -0,0 +1,18 @@
+require_dependency 'reviewable_score_type_serializer'
+
+class ReviewableScoreSerializer < ApplicationSerializer
+
+ attributes :id, :score, :agree_stats
+ has_one :user, serializer: BasicUserSerializer, root: 'users'
+ has_one :score_type, serializer: ReviewableScoreTypeSerializer
+ has_one :reviewable_conversation, serializer: ReviewableConversationSerializer
+
+ def agree_stats
+ {
+ agreed: user.user_stat.flags_agreed,
+ disagreed: user.user_stat.flags_disagreed,
+ ignored: user.user_stat.flags_ignored
+ }
+ end
+
+end
diff --git a/app/serializers/reviewable_score_type_serializer.rb b/app/serializers/reviewable_score_type_serializer.rb
new file mode 100644
index 00000000000..c4d8ecd2d53
--- /dev/null
+++ b/app/serializers/reviewable_score_type_serializer.rb
@@ -0,0 +1,10 @@
+class ReviewableScoreTypeSerializer < ApplicationSerializer
+ attributes :id, :title
+
+ # Allow us to share post action type translations for backwards compatibility
+ def title
+ I18n.t("post_action_types.#{ReviewableScore.types[id]}.title", default: nil) ||
+ I18n.t("reviewable_score_types.#{ReviewableScore.types[id]}.title")
+ end
+
+end
diff --git a/app/serializers/reviewable_serializer.rb b/app/serializers/reviewable_serializer.rb
new file mode 100644
index 00000000000..36e0e6f1245
--- /dev/null
+++ b/app/serializers/reviewable_serializer.rb
@@ -0,0 +1,97 @@
+require_dependency 'reviewable_action_serializer'
+require_dependency 'reviewable_editable_field_serializer'
+
+class ReviewableSerializer < ApplicationSerializer
+
+ class_attribute :_payload_for_serialization
+
+ attributes(
+ :id,
+ :status,
+ :type,
+ :topic_id,
+ :category_id,
+ :created_at,
+ :can_edit,
+ :score,
+ :version
+ )
+
+ has_one :created_by, serializer: BasicUserSerializer, root: 'users'
+ has_one :target_created_by, serializer: BasicUserSerializer, root: 'users'
+ has_one :topic, serializer: ListableTopicSerializer
+ has_many :editable_fields, serializer: ReviewableEditableFieldSerializer, embed: :objects
+ has_many :reviewable_scores, serializer: ReviewableScoreSerializer
+ has_many :bundled_actions, serializer: ReviewableBundledActionSerializer
+ has_many :reviewable_histories, serializer: ReviewableHistorySerializer
+
+ # Used to keep track of our payload attributes
+ class_attribute :_payload_for_serialization
+
+ def bundled_actions
+ object.actions_for(scope).bundles
+ end
+
+ def reviewable_actions
+ object.actions_for(scope).to_a
+ end
+
+ def editable_fields
+ object.editable_for(scope).to_a
+ end
+
+ def can_edit
+ editable_fields.present?
+ end
+
+ def self.create_attribute(name, field)
+ attribute(name)
+
+ class_eval <<~GETTER
+ def #{name}
+ #{field}
+ end
+
+ def include_#{name}?
+ #{name}.present?
+ end
+ GETTER
+ end
+
+ # This is easier than creating an AMS method for each attribute
+ def self.target_attributes(*attributes)
+ attributes.each do |a|
+ create_attribute(a, "object.target&.#{a}")
+ end
+ end
+
+ def self.payload_attributes(*attributes)
+ self._payload_for_serialization ||= []
+ self._payload_for_serialization += attributes.map(&:to_s)
+ end
+
+ def attributes
+ data = super
+
+ if object.target.present?
+ # Automatically add the target id as a "good name" for example a target_type of `User`
+ # becomes `user_id`
+ data[:"#{object.target_type.downcase}_id"] = object.target_id
+ end
+
+ if self.class._payload_for_serialization.present?
+ data[:payload] = (object.payload || {}).slice(*self.class._payload_for_serialization)
+ end
+
+ data
+ end
+
+ def include_topic_id?
+ object.topic_id.present?
+ end
+
+ def include_category_id?
+ object.category_id.present?
+ end
+
+end
diff --git a/app/serializers/reviewable_topic_serializer.rb b/app/serializers/reviewable_topic_serializer.rb
new file mode 100644
index 00000000000..e61111defbb
--- /dev/null
+++ b/app/serializers/reviewable_topic_serializer.rb
@@ -0,0 +1,19 @@
+class ReviewableTopicSerializer < ApplicationSerializer
+ attributes(
+ :id,
+ :title,
+ :fancy_title,
+ :slug,
+ :archived,
+ :closed,
+ :visible,
+ :archetype,
+ :relative_url,
+ :stats,
+ :reviewable_score
+ )
+
+ def stats
+ @options[:stats][object.id]
+ end
+end
diff --git a/app/serializers/reviewable_user_serializer.rb b/app/serializers/reviewable_user_serializer.rb
new file mode 100644
index 00000000000..1991a4f4ead
--- /dev/null
+++ b/app/serializers/reviewable_user_serializer.rb
@@ -0,0 +1,9 @@
+class ReviewableUserSerializer < ReviewableSerializer
+
+ target_attributes(
+ :username,
+ :email,
+ :name
+ )
+
+end
diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb
index 0702366a331..8a7105bb91b 100644
--- a/app/serializers/topic_view_serializer.rb
+++ b/app/serializers/topic_view_serializer.rb
@@ -16,58 +16,62 @@ class TopicViewSerializer < ApplicationSerializer
end
end
- attributes_from_topic :id,
- :title,
- :fancy_title,
- :posts_count,
- :created_at,
- :views,
- :reply_count,
- :like_count,
- :last_posted_at,
- :visible,
- :closed,
- :archived,
- :has_summary,
- :archetype,
- :slug,
- :category_id,
- :word_count,
- :deleted_at,
- :pending_posts_count,
- :user_id,
- :featured_link,
- :featured_link_root_domain,
- :pinned_globally,
- :pinned_at,
- :pinned_until
+ attributes_from_topic(
+ :id,
+ :title,
+ :fancy_title,
+ :posts_count,
+ :created_at,
+ :views,
+ :reply_count,
+ :like_count,
+ :last_posted_at,
+ :visible,
+ :closed,
+ :archived,
+ :has_summary,
+ :archetype,
+ :slug,
+ :category_id,
+ :word_count,
+ :deleted_at,
+ :user_id,
+ :featured_link,
+ :featured_link_root_domain,
+ :pinned_globally,
+ :pinned_at,
+ :pinned_until
+ )
- attributes :draft,
- :draft_key,
- :draft_sequence,
- :posted,
- :unpinned,
- :pinned,
- :details,
- :current_post_number,
- :highest_post_number,
- :last_read_post_number,
- :last_read_post_id,
- :deleted_by,
- :has_deleted,
- :actions_summary,
- :expandable_first_post,
- :is_warning,
- :chunk_size,
- :bookmarked,
- :message_archived,
- :topic_timer,
- :private_topic_timer,
- :unicode_title,
- :message_bus_last_id,
- :participant_count,
- :destination_category_id,
- :pm_with_non_human_user
+ attributes(
+ :draft,
+ :draft_key,
+ :draft_sequence,
+ :posted,
+ :unpinned,
+ :pinned,
+ :details,
+ :current_post_number,
+ :highest_post_number,
+ :last_read_post_number,
+ :last_read_post_id,
+ :deleted_by,
+ :has_deleted,
+ :actions_summary,
+ :expandable_first_post,
+ :is_warning,
+ :chunk_size,
+ :bookmarked,
+ :message_archived,
+ :topic_timer,
+ :private_topic_timer,
+ :unicode_title,
+ :message_bus_last_id,
+ :participant_count,
+ :destination_category_id,
+ :pm_with_non_human_user,
+ :pending_posts_count
+ )
# TODO: Split off into proper object / serializer
def details
@@ -242,10 +246,6 @@ class TopicViewSerializer < ApplicationSerializer
object.topic_user&.bookmarked
end
- def include_pending_posts_count?
- scope.is_staff? && NewPostManager.queue_enabled?
- end
-
def topic_timer
TopicTimerSerializer.new(object.topic.public_topic_timer, root: false)
end
@@ -297,6 +297,14 @@ class TopicViewSerializer < ApplicationSerializer
object.topic.shared_draft.present?
end
+ def pending_posts_count
+ ReviewableQueuedPost.viewable_by(scope.user).where(topic_id: object.topic.id).pending.count
+ end
+
+ def include_pending_posts_count?
+ scope.is_staff? && NewPostManager.queue_enabled?
+ end
+
private
def private_message?(topic)
diff --git a/app/serializers/web_hook_flag_serializer.rb b/app/serializers/web_hook_flag_serializer.rb
index 847ae810e3d..895a7b82c6c 100644
--- a/app/serializers/web_hook_flag_serializer.rb
+++ b/app/serializers/web_hook_flag_serializer.rb
@@ -24,18 +24,24 @@ class WebHookFlagSerializer < ApplicationSerializer
end
def resolved_at
- object.disposed_at
+ object.disagreed_at || object.agreed_at || object.deferred_at
end
def include_resolved_at?
- object.disposed_at.present?
+ resolved_at.present?
end
def resolved_by
- User.find(object.disposed_by_id).username
+ User.find(disposed_by_id).username
end
def include_resolved_by?
- object.disposed_by_id.present?
+ disposed_by_id.present?
+ end
+
+protected
+
+ def disposed_by_id
+ object.disagreed_by_id || object.agreed_by_id || object.deferred_by_id
end
end
diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb
index a3402bbcdf7..40d1d41d18b 100644
--- a/app/services/post_alerter.rb
+++ b/app/services/post_alerter.rb
@@ -1,5 +1,5 @@
require_dependency 'distributed_mutex'
-require_dependency 'user_action_creator'
+require_dependency 'user_action_manager'
class PostAlerter
def self.post_created(post, opts = {})
@@ -376,7 +376,7 @@ class PostAlerter
end
end
- UserActionCreator.log_notification(original_post, user, type, opts[:acting_user_id])
+ UserActionManager.notification_created(original_post, user, type, opts[:acting_user_id])
topic_title = post.topic.title
# when sending a private message email, keep the original title
diff --git a/app/services/post_owner_changer.rb b/app/services/post_owner_changer.rb
index df318659dad..5ce235f2f2b 100644
--- a/app/services/post_owner_changer.rb
+++ b/app/services/post_owner_changer.rb
@@ -1,3 +1,5 @@
+require_dependency 'post_action_destroyer'
+
class PostOwnerChanger
def initialize(params)
@@ -21,7 +23,7 @@ class PostOwnerChanger
post.topic = @topic
post.set_owner(@new_owner, @acting_user, @skip_revision)
- PostAction.remove_act(@new_owner, post, PostActionType.types[:like])
+ PostActionDestroyer.destroy(@new_owner, post, :like)
level = post.is_first_post? ? :watching : :tracking
TopicUser.change(@new_owner.id, @topic.id, notification_level: NotificationLevels.topic_levels[level])
diff --git a/app/services/spam_rule/auto_silence.rb b/app/services/spam_rule/auto_silence.rb
index 7eeb9baa9cc..3486e3a0e20 100644
--- a/app/services/spam_rule/auto_silence.rb
+++ b/app/services/spam_rule/auto_silence.rb
@@ -22,47 +22,37 @@ class SpamRule::AutoSilence
return false if @user.staged?
return false if @user.has_trust_level?(TrustLevel[1])
- if SiteSetting.num_spam_flags_to_silence_new_user > 0 &&
+ if SiteSetting.spam_score_to_silence_new_user > 0 &&
SiteSetting.num_users_to_silence_new_user > 0 &&
- num_spam_flags_against_user >= SiteSetting.num_spam_flags_to_silence_new_user &&
- num_users_who_flagged_spam_against_user >= SiteSetting.num_users_to_silence_new_user
- return true
- end
-
- if SiteSetting.num_tl3_flags_to_silence_new_user > 0 &&
- SiteSetting.num_tl3_users_to_silence_new_user > 0 &&
- num_tl3_flags_against_user >= SiteSetting.num_tl3_flags_to_silence_new_user &&
- num_tl3_users_who_flagged >= SiteSetting.num_tl3_users_to_silence_new_user
+ user_spam_stats.total_spam_score >= SiteSetting.spam_score_to_silence_new_user &&
+ user_spam_stats.spam_user_count >= SiteSetting.num_users_to_silence_new_user
return true
end
false
end
- def num_spam_flags_against_user
- Post.where(user_id: @user.id).sum(:spam_count)
- end
+ def user_spam_stats
+ return @user_spam_stats if @user_spam_stats
- def num_users_who_flagged_spam_against_user
- post_ids = Post.where('user_id = ? and spam_count > 0', @user.id).pluck(:id)
- return 0 if post_ids.empty?
- PostAction.spam_flags.where(post_id: post_ids).pluck(:user_id).uniq.size
- end
+ params = {
+ user_id: @user.id,
+ spam_type: PostActionType.types[:spam],
+ pending: ReviewableScore.statuses[:pending],
+ agreed: ReviewableScore.statuses[:agreed]
+ }
- def num_tl3_flags_against_user
- if flagged_post_ids.empty?
- 0
- else
- PostAction.where(post_id: flagged_post_ids).joins(:user).where('users.trust_level >= ?', 3).count
- end
- end
+ result = DB.query(<<~SQL, params)
+ SELECT COALESCE(SUM(rs.score), 0) AS total_spam_score,
+ COUNT(DISTINCT rs.user_id) AS spam_user_count
+ FROM reviewables AS r
+ INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id
+ WHERE r.target_created_by_id = :user_id
+ AND rs.reviewable_score_type = :spam_type
+ AND rs.status IN (:pending, :agreed)
+ SQL
- def num_tl3_users_who_flagged
- if flagged_post_ids.empty?
- 0
- else
- PostAction.where(post_id: flagged_post_ids).joins(:user).where('users.trust_level >= ?', 3).pluck(:user_id).uniq.size
- end
+ @user_spam_stats = result[0]
end
def flagged_post_ids
diff --git a/app/services/spam_rule/flag_sockpuppets.rb b/app/services/spam_rule/flag_sockpuppets.rb
index 16100a75653..d7fd26cdc56 100644
--- a/app/services/spam_rule/flag_sockpuppets.rb
+++ b/app/services/spam_rule/flag_sockpuppets.rb
@@ -33,10 +33,11 @@ class SpamRule::FlagSockpuppets
def flag_sockpuppet_users
message = I18n.t('flag_reason.sockpuppet', ip_address: @post.user.ip_address, base_path: Discourse.base_path)
- PostAction.act(Discourse.system_user, @post, PostActionType.types[:spam], message: message) rescue PostAction::AlreadyActed
+
+ PostActionCreator.create(Discourse.system_user, @post, :spam, message: message)
if (first_post = @post.topic.posts.by_post_number.first).try(:user).try(:new_user?)
- PostAction.act(Discourse.system_user, first_post, PostActionType.types[:spam], message: message) rescue PostAction::AlreadyActed
+ PostActionCreator.create(Discourse.system_user, first_post, :spam, message: message)
end
end
diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb
index 5a87ba1118d..05cfe8a0a39 100644
--- a/app/services/staff_action_logger.rb
+++ b/app/services/staff_action_logger.rb
@@ -554,27 +554,27 @@ class StaffActionLogger
end
def log_post_approved(post, opts = {})
- raise Discourse::InvalidParameters.new(:post) unless post && post.is_a?(Post)
+ raise Discourse::InvalidParameters.new(:post) unless post.is_a?(Post)
UserHistory.create!(params(opts).merge(
action: UserHistory.actions[:post_approved],
post_id: post.id
))
end
- def log_post_rejected(rejected_post, opts = {})
- raise Discourse::InvalidParameters.new(:rejected_post) unless rejected_post && rejected_post.is_a?(QueuedPost)
+ def log_post_rejected(reviewable, rejected_at, opts = {})
+ raise Discourse::InvalidParameters.new(:rejected_post) unless reviewable.is_a?(Reviewable)
- topic = rejected_post.topic || Topic.with_deleted.find_by(id: rejected_post.topic_id)
+ topic = reviewable.topic || Topic.with_deleted.find_by(id: reviewable.topic_id)
topic_title = topic&.title || I18n.t('staff_action_logs.not_found')
- username = rejected_post.user&.username || I18n.t('staff_action_logs.unknown')
- name = rejected_post.user&.name || I18n.t('staff_action_logs.unknown')
+ username = reviewable.created_by.username || I18n.t('staff_action_logs.unknown')
+ name = reviewable.created_by.name || I18n.t('staff_action_logs.unknown')
details = [
- "created_at: #{rejected_post.created_at}",
- "rejected_at: #{rejected_post.rejected_at}",
+ "created_at: #{reviewable.created_at}",
+ "rejected_at: #{rejected_at}",
"user: #{username} (#{name})",
"topic: #{topic_title}",
- "raw: #{rejected_post.raw}",
+ "raw: #{reviewable.payload['raw']}",
]
UserHistory.create!(params(opts).merge(
diff --git a/app/services/user_action_creator.rb b/app/services/user_action_creator.rb
deleted file mode 100644
index 6d4b917bb89..00000000000
--- a/app/services/user_action_creator.rb
+++ /dev/null
@@ -1,153 +0,0 @@
-class UserActionCreator
- def self.disable
- @disabled = true
- end
-
- def self.enable
- @disabled = false
- end
-
- def self.log_notification(post, user, notification_type, acting_user_id = nil)
- return if @disabled
-
- action =
- case notification_type
- when Notification.types[:quoted]
- UserAction::QUOTE
- when Notification.types[:replied]
- UserAction::RESPONSE
- when Notification.types[:mentioned]
- UserAction::MENTION
- when Notification.types[:edited]
- UserAction::EDIT
- end
-
- # skip any invalid items, eg failed to save post and so on
- return unless action && post && user && post.id
-
- row = {
- action_type: action,
- user_id: user.id,
- acting_user_id: acting_user_id || post.user_id,
- target_topic_id: post.topic_id,
- target_post_id: post.id
- }
-
- if post.deleted_at.nil?
- UserAction.log_action!(row)
- else
- UserAction.remove_action!(row)
- end
- end
-
- def self.log_post(model)
- return if @disabled
-
- # first post gets nada
- return if model.is_first_post?
- return if model.topic.blank?
-
- row = {
- action_type: UserAction::REPLY,
- user_id: model.user_id,
- acting_user_id: model.user_id,
- target_post_id: model.id,
- target_topic_id: model.topic_id,
- created_at: model.created_at
- }
-
- rows = [row]
-
- if model.topic.private_message?
- rows = []
- model.topic.topic_allowed_users.each do |ta|
- row = row.dup
- row[:user_id] = ta.user_id
- row[:action_type] = ta.user_id == model.user_id ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::GOT_PRIVATE_MESSAGE
- rows << row
- end
- end
-
- rows.each do |r|
- if model.deleted_at.nil?
- UserAction.log_action!(r)
- else
- UserAction.remove_action!(r)
- end
- end
- end
-
- def self.log_topic(model)
- return if @disabled
-
- # no action to log here, this can happen if a user is deleted
- # then topic has no user_id
- return unless model.user_id
-
- row = {
- action_type: model.private_message? ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::NEW_TOPIC,
- user_id: model.user_id,
- acting_user_id: model.user_id,
- target_topic_id: model.id,
- target_post_id: -1,
- created_at: model.created_at
- }
-
- UserAction.remove_action!(row.merge(
- action_type: model.private_message? ? UserAction::NEW_TOPIC : UserAction::NEW_PRIVATE_MESSAGE
- ))
-
- rows = [row]
-
- if model.private_message?
- model.topic_allowed_users.reject { |a| a.user_id == model.user_id }.each do |ta|
- row = row.dup
- row[:user_id] = ta.user_id
- row[:action_type] = UserAction::GOT_PRIVATE_MESSAGE
- rows << row
- end
- end
-
- rows.each do |r|
- if model.deleted_at.nil?
- UserAction.log_action!(r)
- else
- UserAction.remove_action!(r)
- end
- end
- end
-
- def self.log_post_action(model)
- return if @disabled
-
- action = UserAction::BOOKMARK if model.is_bookmark?
- action = UserAction::LIKE if model.is_like?
-
- post = Post.with_deleted.where(id: model.post_id).first
-
- row = {
- action_type: action,
- user_id: model.user_id,
- acting_user_id: model.user_id,
- target_post_id: model.post_id,
- target_topic_id: post.topic_id,
- created_at: model.created_at
- }
-
- if model.deleted_at.nil?
- UserAction.log_action!(row)
- else
- UserAction.remove_action!(row)
- end
-
- if model.is_like?
- row[:action_type] = UserAction::WAS_LIKED
- row[:user_id] = post.user_id
- if model.deleted_at.nil?
- UserAction.log_action!(row)
- else
- UserAction.remove_action!(row)
- end
- end
- end
-end
diff --git a/app/services/user_action_manager.rb b/app/services/user_action_manager.rb
new file mode 100644
index 00000000000..7adc19fef8d
--- /dev/null
+++ b/app/services/user_action_manager.rb
@@ -0,0 +1,130 @@
+class UserActionManager
+
+ def self.disable
+ @disabled = true
+ end
+
+ def self.enable
+ @disabled = false
+ end
+
+ [:notification, :post, :topic, :post_action].each do |type|
+ self.class_eval(<<~METHODS)
+ def self.#{type}_created(*args)
+ return if @disabled
+ #{type}_rows(*args).each { |row| UserAction.log_action!(row) }
+ end
+ def self.#{type}_destroyed(*args)
+ return if @disabled
+ #{type}_rows(*args).each { |row| UserAction.remove_action!(row) }
+ end
+ METHODS
+ end
+
+private
+
+ def self.topic_rows(topic)
+ # no action to log here, this can happen if a user is deleted
+ # then topic has no user_id
+ return [] unless topic.user_id
+
+ row = {
+ action_type: topic.private_message? ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::NEW_TOPIC,
+ user_id: topic.user_id,
+ acting_user_id: topic.user_id,
+ target_topic_id: topic.id,
+ target_post_id: -1,
+ created_at: topic.created_at
+ }
+
+ UserAction.remove_action!(row.merge(
+ action_type: topic.private_message? ? UserAction::NEW_TOPIC : UserAction::NEW_PRIVATE_MESSAGE
+ ))
+
+ rows = [row]
+
+ if topic.private_message?
+ topic.topic_allowed_users.reject { |a| a.user_id == topic.user_id }.each do |ta|
+ row = row.dup
+ row[:user_id] = ta.user_id
+ row[:action_type] = UserAction::GOT_PRIVATE_MESSAGE
+ rows << row
+ end
+ end
+ rows
+ end
+
+ def self.post_rows(post)
+ # first post gets nada
+ return [] if post.is_first_post? || post.topic.blank?
+
+ row = {
+ action_type: UserAction::REPLY,
+ user_id: post.user_id,
+ acting_user_id: post.user_id,
+ target_post_id: post.id,
+ target_topic_id: post.topic_id,
+ created_at: post.created_at
+ }
+
+ rows = [row]
+
+ if post.topic.private_message?
+ rows = []
+ post.topic.topic_allowed_users.each do |ta|
+ row = row.dup
+ row[:user_id] = ta.user_id
+ row[:action_type] = ta.user_id == post.user_id ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::GOT_PRIVATE_MESSAGE
+ rows << row
+ end
+ end
+
+ rows
+ end
+
+ def self.notification_rows(post, user, notification_type, acting_user_id)
+ action =
+ case notification_type
+ when Notification.types[:quoted]
+ UserAction::QUOTE
+ when Notification.types[:replied]
+ UserAction::RESPONSE
+ when Notification.types[:mentioned]
+ UserAction::MENTION
+ when Notification.types[:edited]
+ UserAction::EDIT
+ end
+
+ # skip any invalid items, eg failed to save post and so on
+ return [] unless action && post && user && post.id
+
+ [{
+ action_type: action,
+ user_id: user.id,
+ acting_user_id: acting_user_id || post.user_id,
+ target_topic_id: post.topic_id,
+ target_post_id: post.id
+ }]
+ end
+
+ def self.post_action_rows(post_action)
+ action = UserAction::BOOKMARK if post_action.is_bookmark?
+ action = UserAction::LIKE if post_action.is_like?
+ return [] unless action
+
+ post = Post.with_deleted.where(id: post_action.post_id).first
+
+ row = {
+ action_type: action,
+ user_id: post_action.user_id,
+ acting_user_id: post_action.user_id,
+ target_post_id: post_action.post_id,
+ target_topic_id: post.topic_id,
+ created_at: post_action.created_at
+ }
+
+ post_action.is_like? ?
+ [row, row.merge(action_type: UserAction::WAS_LIKED, user_id: post.user_id)] :
+ [row]
+ end
+end
diff --git a/app/services/user_destroyer.rb b/app/services/user_destroyer.rb
index b2b19ad03f5..be4e91e5caa 100644
--- a/app/services/user_destroyer.rb
+++ b/app/services/user_destroyer.rb
@@ -26,12 +26,15 @@ class UserDestroyer
optional_transaction(open_transaction: opts[:transaction]) do
Draft.where(user_id: user.id).delete_all
- QueuedPost.where(user_id: user.id).delete_all
+ Reviewable.where(created_by_id: user.id).delete_all
if opts[:delete_posts]
user.posts.each do |post|
+
# agree with flags
- PostAction.agree_flags!(post, @actor) if opts[:delete_as_spammer]
+ if opts[:delete_as_spammer] && reviewable = post.reviewable_flag
+ reviewable.perform(@actor, :agree_and_keep)
+ end
# block all external urls
if opts[:block_urls]
diff --git a/app/services/user_merger.rb b/app/services/user_merger.rb
index 475a57a3b86..1f5709fd32d 100644
--- a/app/services/user_merger.rb
+++ b/app/services/user_merger.rb
@@ -281,9 +281,8 @@ class UserMerger
Post.with_deleted.where(locked_by_id: @source_user.id).update_all(locked_by_id: @target_user.id)
Post.with_deleted.where(reply_to_user_id: @source_user.id).update_all(reply_to_user_id: @target_user.id)
- QueuedPost.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
- QueuedPost.where(approved_by_id: @source_user.id).update_all(approved_by_id: @target_user.id)
- QueuedPost.where(rejected_by_id: @source_user.id).update_all(rejected_by_id: @target_user.id)
+ Reviewable.where(created_by_id: @source_user.id).update_all(created_by_id: @target_user.id)
+ ReviewableHistory.where(created_by_id: @source_user.id).update_all(created_by_id: @target_user.id)
SearchLog.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
diff --git a/config/initializers/012-web_hook_events.rb b/config/initializers/012-web_hook_events.rb
index a99b958dc60..3f1c2f9c649 100644
--- a/config/initializers/012-web_hook_events.rb
+++ b/config/initializers/012-web_hook_events.rb
@@ -83,12 +83,24 @@ end
end
end
-%i(
- queued_post_created
- approved_post
- rejected_post
-).each do |event|
- DiscourseEvent.on(event) do |queued_post|
- WebHook.enqueue_object_hooks(:queued_post, queued_post, event, QueuedPostSerializer)
+DiscourseEvent.on(:reviewable_created) do |reviewable|
+ WebHook.enqueue_object_hooks(:reviewable, reviewable, :reviewable_created, reviewable.serializer)
+
+ # TODO: Backwards compatibility for Queued Post webhooks. Remve in favor of Reviewable API
+ if reviewable.is_a?(ReviewableQueuedPost)
+ WebHook.enqueue_object_hooks(:queued_post, reviewable, :queued_post_created, reviewable.serializer)
+ end
+end
+
+DiscourseEvent.on(:reviewable_transitioned_to) do |status, reviewable|
+ WebHook.enqueue_object_hooks(:reviewable, reviewable, :reviewable_transitioned_to, reviewable.serializer)
+
+ # TODO: Backwards compatibility for Queued Post webhooks. Remve in favor of Reviewable API
+ if reviewable.is_a?(ReviewableQueuedPost)
+ if reviewable.approved?
+ WebHook.enqueue_object_hooks(:queued_post, reviewable, :approved_post, QueuedPostSerializer)
+ elsif reviewable.rejected?
+ WebHook.enqueue_object_hooks(:queued_post, reviewable, :rejected_post, QueuedPostSerializer)
+ end
end
end
diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml
index 3f6bb9bb745..126a0f63b37 100644
--- a/config/locales/client.bs_BA.yml
+++ b/config/locales/client.bs_BA.yml
@@ -2484,7 +2484,6 @@ bs_BA:
titles:
active: "Active Users"
new: "New Users"
- pending: "Users Pending Review"
newuser: "Users at Trust Level 0 (New User)"
basic: "Users at Trust Level 1 (Basic User)"
staff: "Zaposleni"
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 2e342b2e83c..3ce03babc62 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -222,7 +222,6 @@ en:
age: "Age"
joined: "Joined"
admin_title: "Admin"
- flags_title: "Flags"
show_more: "show more"
show_help: "options"
links: "Links"
@@ -357,23 +356,77 @@ en:
search: "Search for a Message by title:"
placeholder: "type the message title here"
- queue:
+ review:
+ moderation_history: "Moderation History"
+ view_all: "view all"
+ grouped_by_topic: "grouped by topic"
+ none: "There are no items to review."
+ view_pending: "view pending"
+ topic_has_pending:
+ one: "This topic has 1 post pending approval"
+ other: "This topic has {{count}} posts pending approval"
+ title: "Needs Review"
topic: "Topic:"
- approve: "Approve"
- reject: "Reject"
- delete_user: "Delete User"
- title: "Needs Approval"
- none: "There are no posts to review."
+ filtered_topic: "You have filtered to reviewable content in a single topic."
+ filtered_user: "User:"
+ show_all_topics: "show all topics"
+ topics:
+ topic: "Topic"
+ reviewable_count: "Reviewable Count"
+ reported_by: "Reported by"
+ details: "details"
+ unique_users:
+ one: "1 user"
+ other: "{{count}} users"
+ topic_replies:
+ one: "(1 reply)"
+ other: "({{count}} replies)"
edit: "Edit"
+ save: "Save"
cancel: "Cancel"
- view_pending: "view pending posts"
- has_pending_posts:
- one: "This topic has 1 post awaiting approval"
- other: "This topic has {{count}} posts awaiting approval"
+ new_topic: "New Topic:"
- confirm: "Save Changes"
- delete_prompt: "Are you sure you want to delete %{username}? This will remove all of their posts and block their email and IP address."
+ filters:
+ type:
+ title: "Type:"
+ all: "(all types)"
+ minimum_score: "Minimum Score:"
+ refresh: "Refresh"
+ status: "Status:"
+ category: "Category:"
+ conversation:
+ view_full: "view full conversation"
+ history:
+ title: "History"
+ edited: "Edited"
+ scores:
+ description: "Description"
+ score: "Score"
+ submitted_by: "Submitted By"
+ statuses:
+ pending:
+ title: "Pending"
+ approved:
+ title: "Approved"
+ rejected:
+ title: "Rejected"
+ ignored:
+ title: "Ignored"
+ deleted:
+ title: "Deleted"
+ reviewed:
+ title: "(All Reviewed)"
+ all:
+ title: "(Everything)"
+ types:
+ reviewable_flagged_post:
+ title: 'Flagged Post'
+ flagged_by: "Flagged By"
+ reviewable_queued_post:
+ title: 'Queued Post'
+ reviewable_user:
+ title: 'User'
approval:
title: "Post Needs Approval"
description: "We've received your new post but it needs to be approved by a moderator before it will appear. Please be patient."
@@ -2996,102 +3049,6 @@ en:
latest_changes: "Latest changes: please update often!"
by: "by"
- flags:
- title: "Flags"
- active_posts: "Flagged Posts"
- old_posts: "Old Flagged Posts"
- topics: "Flagged Topics"
- moderation_history: "Moderation History"
-
- agree: "Agree"
- agree_title: "Confirm this flag as valid and correct"
- agree_flag_hide_post: "Hide Post"
- agree_flag_hide_post_title: "Hide this post and automatically send the user a message urging them to edit it."
- agree_flag_restore_post: "Agree and Restore Post"
- agree_flag_restore_post_title: "Restore the post so that all users can see it."
- agree_flag_suspend: "Suspend User"
- agree_flag_suspend_title: "Agree with flag and suspend the user."
- agree_flag_silence: "Silence User"
- agree_flag_silence_title: "Agree with flag and silence the user."
-
- agree_flag: "Keep Post"
- agree_flag_title: "Agree with flag and keep the post unchanged."
- ignore_flag: "Ignore"
- ignore_flag_title: "Remove this flag; it requires no action at this time."
- delete: "Delete"
- delete_title: "Delete the post this flag refers to."
- delete_post_defer_flag: "Delete Post and Ignore flag"
- delete_post_defer_flag_title: "Delete post; if the first post, delete the topic"
- delete_post_agree_flag: "Delete Post and Agree with flag"
- delete_post_agree_flag_title: "Delete post; if the first post, delete the topic"
- delete_flag_modal_title: "Delete and..."
- delete_spammer: "Delete Spammer"
- delete_spammer_title: "Remove the user and all posts and topics by this user."
- 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: "Deny this flag as invalid or incorrect"
- clear_topic_flags: "Done"
- clear_topic_flags_title: "The topic has been investigated and issues have been resolved. Click Done to remove the flags."
- more: "(more replies...)"
- suspend_user: "Suspend User"
- suspend_user_title: "Suspend user for this post"
- replies:
- one: "[1 reply]"
- other: "[%{count} replies]"
- delete_replies:
- one: "Also delete the %{count} reply to this post?"
- other: "Also delete the %{count} replies to this post?"
-
- dispositions:
- agreed: "agreed"
- disagreed: "disagreed"
- deferred: "ignored"
-
- flagged_by: "Flagged by"
- resolved_by: "Resolved by"
- took_action: "Took action"
- system: "System"
- error: "Something went wrong"
- reply_message: "Reply"
- no_results: "There are no flagged posts."
- topic_flagged: "This topic has been flagged."
- show_full: "show full post"
- visit_topic: "Visit the topic to take action"
- was_edited: "Post was edited after the first flag"
- previous_flags_count: "This post has already been flagged {{count}} times."
- show_details: "Show flag details"
-
- user_percentage:
- summary:
- one: "{{agreed}}, {{disagreed}}, {{ignored}} ({{count}} total flag)"
- other: "{{agreed}}, {{disagreed}}, {{ignored}} ({{count}} total flags)"
- agreed:
- one: "{{count}}% agree"
- other: "{{count}}% agree"
- disagreed:
- one: "{{count}}% disagree"
- other: "{{count}}% disagree"
- ignored:
- one: "{{count}}% ignore"
- other: "{{count}}% ignore"
-
- details: "details"
-
- flagged_topics:
- topic: "Topic"
- type: "Type"
- users: "Users"
- last_flagged: "Last Flagged"
- no_results: "There are no flagged topics."
-
- short_names:
- off_topic: "off-topic"
- inappropriate: "inappropriate"
- spam: "spam"
- notify_user: "custom"
- notify_moderators: "custom"
-
groups:
new:
title: "New Group"
@@ -3812,19 +3769,12 @@ en:
nav:
new: "New"
active: "Active"
- pending: "Pending"
staff: "Staff"
suspended: "Suspended"
silenced: "Silenced"
suspect: "Suspect"
staged: "Staged"
approved: "Approved?"
- approved_selected:
- one: "approve user"
- other: "approve users ({{count}})"
- reject_selected:
- one: "reject user"
- other: "reject users ({{count}})"
titles:
active: "Active Users"
new: "New Users"
@@ -3841,12 +3791,6 @@ en:
suspended: "Suspended Users"
suspect: "Suspect Users"
staged: "Staged Users"
- reject_successful:
- one: "Successfully rejected 1 user."
- other: "Successfully rejected %{count} users."
- reject_failures:
- one: "Failed to reject 1 user."
- other: "Failed to reject %{count} users."
not_verified: "Not verified"
check_email:
title: "Reveal this user's email address"
diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml
index 6f52d1d964b..762fb879ea6 100644
--- a/config/locales/server.ar.yml
+++ b/config/locales/server.ar.yml
@@ -884,7 +884,6 @@ ar:
polling_interval: "عندما لا يكون التصويت طويلا، عدد المرات التي يجب تسجيل عملاء التصويت فيها بالملي ثانية."
anon_polling_interval: "المرات التي يجب الاستعلام فيها عن العملاء المجهولين بالملي ثانية."
background_polling_interval: "كم ينبغي أن يكون عدد مرات الاستعلام عن العملاء في المللي ثانية ( عندما يكون الاطار في الخلفية )"
- flags_required_to_hide_post: "عدد العلامات التى تؤدى الى خفى مشاركة تلقائيا وارسال رسالة الى المستخدم ( 0 تعنى عدم الخفى أو الارسال)"
cooldown_minutes_after_hiding_posts: "عدد الدقائق يجب على المستخدم الانتظار قبل أن يتمكنوا من تعديل موضوع مخفي عن طريق رفع الأعلام المجتمع"
max_topics_in_first_day: "أقصى عدد للمواضيع المسموح للمستخدم بانشائها خلال 24 ساعة من نشر أول مشاركة."
max_replies_in_first_day: "أقصى عدد للردود المسموح للمستخدم بنشرها حتى مرور 24 ساعة على نشر أول مشاركة"
diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml
index 4a102f34df4..f920299e6c6 100644
--- a/config/locales/server.ca.yml
+++ b/config/locales/server.ca.yml
@@ -758,7 +758,6 @@ ca:
polling_interval: "Quan no hi ha mostreig llarg, amb quina freqüència en mil·lisegons haurien de connectar-se a clients de mostreig"
anon_polling_interval: "Amb quina freqüència en mil·lisegons podria votar els clients"
background_polling_interval: "Amb quina freqüència en mil·lisegons podrien votar els client (quan la finestra és al fons)"
- flags_required_to_hide_post: "Quantitat de banderes que fan que una publicació s'amagui automàticament i s'enviï un missatge a la persona (0 per a mai)"
cooldown_minutes_after_hiding_posts: "Quantitat de minuts que una persona ha d'esperar abans d'editar una publicació oculta amb les banderes de la comunitat"
max_topics_in_first_day: "Quantitat màxima de temes que una persona pot crear en les 24 hores posteriors a la seva primera publicació"
max_replies_in_first_day: "Quantitat màxima de respostes que una persona pot crear en les 24 hores posteriors a la seva primera publicació"
diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml
index c0afaf62206..caeaea17943 100644
--- a/config/locales/server.da.yml
+++ b/config/locales/server.da.yml
@@ -776,7 +776,6 @@ da:
polling_interval: "Når der ikke hentes data langsomt, hvor tit skal en klient der er logget på hente ny data i millisekunder"
anon_polling_interval: "Hvor ofte skal anonyme brugere polle, i millisekunder."
background_polling_interval: "Hvor tit skal klienter hente data i millisekunder (når vinduet er i baggrunden)"
- flags_required_to_hide_post: "Antal flag der forårsager at et indlæg automatisk skjules, og en bresked sendt til brugeren (0 for never)"
cooldown_minutes_after_hiding_posts: "Antal minutter en bruger må vente indtil de kan redigere et indlæg der er blevet gemt via et flag"
max_topics_in_first_day: "Det maksimale antal af emner en bruger må lave i en 24 timers periode efter at have skrevet det første indlæg"
max_replies_in_first_day: "Det maksimale antal af svar en bruger må foretage i 24 timers perioden efter at have skrevet deres første indlæg"
diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml
index 315911e95cd..da1cf676cbc 100644
--- a/config/locales/server.de.yml
+++ b/config/locales/server.de.yml
@@ -1241,7 +1241,6 @@ de:
polling_interval: "Polling-Intervall in Millisekunden für angemeldete Clients, wenn Long Polling nicht verwendet wird."
anon_polling_interval: "Polling-Intervall in Millisekunden für anonyme Clients."
background_polling_interval: "Polling-Intervall in Millisekunden für Clients, wenn sich das Browser-Fenster im Hintergrund befindet."
- flags_required_to_hide_post: "Anzahl an Meldungen, die dafür sorgen, dass ein Beitrag automatisch versteckt und der Benutzer benachrichtigt wird (0 für nie)"
cooldown_minutes_after_hiding_posts: "Minuten die ein Benutzer warten muss, bevor ein Beitrag bearbeitet werden kann, der wegen Meldungen anderer Benutzer versteckt wurde"
max_topics_in_first_day: "Maximale Anzahl an Themen, die ein Benutzer in den ersten 24 Stunden nach dem Schreiben seines ersten Beitrags erstellen kann."
max_replies_in_first_day: "Maximale Anzahl an Beiträgen, die ein Benutzer in den ersten 24 Stunden nach dem Schreiben seines ersten Beitrags erstellen kann."
@@ -1532,7 +1531,6 @@ de:
auto_silence_fast_typers_max_trust_level: "Maximale Vertrauensstufe, um „Schnelltipper“ stummzuschalten."
auto_silence_first_post_regex: "Regulärer Ausdruck (ohne Groß- und Kleinschreibung), der dafür sorgt dass entsprechende erste Beiträge von Benutzern stummgeschaltet und in die Genehmigungswarteschlange verschoben werden. Beispiel: raging|a[bc]a sorgt dafür, dass Beiträge, die raging, aba oder aca enthalten, zunächst stummgeschaltet werden. Dies betrifft jedoch nur den ersten Beitrag."
flags_default_topics: "Zeige gemeldete Themen standardmäßig im Administrationsbereich"
- min_flags_staff_visibility: "Die minimale Anzahl an Meldungen, die ein Beitrag haben muss, bevor ihn das Team im Administrationsbereich sehen kann."
reply_by_email_enabled: "Aktviere das Antworten auf Themen via E-Mail."
reply_by_email_address: "Vorlage für die Antwort einer per E-Mail eingehender E-Mail-Adresse, zum Beispiel: %%{reply_key}@reply.example.com oder replies+%%{reply_key}@example.com"
alternative_reply_by_email_addresses: "Liste alternativer Vorlagen für eingehende E-Mail-Adressen für das Antworten per E-Mail. Beispiel: %%{reply_key}@antwort.example.com|antworten+%%{reply_key}@example.com"
diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml
index 4252101b08b..78df014e305 100644
--- a/config/locales/server.el.yml
+++ b/config/locales/server.el.yml
@@ -833,7 +833,6 @@ el:
polling_interval: "Όταν δεν χρησιμοποιείται μακρυά μέθοδος εξέτασης, πόσο συχνά θα πρέπει οι συνδεδεμένοι χρήστες να ελέγχονται σε χιλιοστά του δευτερολέπτου"
anon_polling_interval: "Πόσο συχνά θα πρέπει οι ανώνυμοι χρήστες να ελέγχονται σε χιλιοστά του δευτερολέπτου"
background_polling_interval: "Πόσο συχνά θα πρέπει οι συνδεδεμένοι πελάτες να ελέγχονται σε χιλιοστά του δευτερολέπτου (όταν το παράθυρο είναι στο παρασκήνιο)"
- flags_required_to_hide_post: "Αριθμός σημάνσεων που προκαλούν την ανάρτηση να κρυφτεί αυτομάτως και ένα μήνυμα να σταλθεί στον χρήστη (0 για να μην γίνετε ποτέ) "
cooldown_minutes_after_hiding_posts: "Αριθμός λεπτών που ένας χρήστης θα πρέπει να περιμένει πριν να μπορέσει να έπεξεργαστεί ξανά μία ανάρτηση που είναι κρυμμένη λόγω επισήμανσης από χρήστες."
max_topics_in_first_day: "Το ανώτατο όριο νημάτων τα οποία ένας χρήστης μπορεί να δημιουργήσει μέσα σε 24 ώρες από την πρώτη τους ανάρτηση"
max_replies_in_first_day: "Ο ανώτατος αριθμός απαντήσεων που ένας χρήστης δικαιούται να αναρτήσει εντός του ορίου των 24 ωρών από την δημιουργία της πρώτης ανάρτησης"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index b73d57f0077..1762f56554c 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -347,10 +347,6 @@ en:
excerpt_image: "image"
- queue:
- delete_reason: "Deleted via post moderation queue"
- not_found: "Post not found or already updated."
-
groups:
success:
bulk_add:
@@ -793,6 +789,10 @@ en:
grant: "Grant Admin Access"
complete: "%{target_username} is now an administrator."
back_to: "Return to %{title}"
+ reviewable_score_types:
+ needs_approval:
+ title: "Needs Approval"
+
post_action_types:
off_topic:
title: "Off-Topic"
@@ -1365,7 +1365,7 @@ en:
anon_polling_interval: "How often should anonymous clients poll in milliseconds"
background_polling_interval: "How often should the clients poll in milliseconds (when the window is in the background)"
- flags_required_to_hide_post: "Number of flags that cause a post to be automatically hidden and message sent to the user (0 for never)"
+ score_required_to_hide_post: "Score threshold that causes a post to be automatically hidden and message sent to the user (0 to disable)"
cooldown_minutes_after_hiding_posts: "Number of minutes a user must wait before they can edit a post hidden via community flagging"
max_topics_in_first_day: "The maximum number of topics a user is allowed to create in the 24 hour period after creating their first post"
@@ -1375,7 +1375,7 @@ en:
tl3_additional_likes_per_day_multiplier: "Increase limit of likes per day for tl3 (regular) by multiplying with this number"
tl4_additional_likes_per_day_multiplier: "Increase limit of likes per day for tl4 (leader) by multiplying with this number"
- num_spam_flags_to_silence_new_user: "If a new user's posts get this many spam flags from num_users_to_silence_new_user different users, hide all their posts and prevent future posting. 0 to disable."
+ spam_score_to_silence_new_user: "If a new user's posts receive this score from num_users_to_silence_new_user different users, hide all their posts and prevent future posting. 0 to disable."
num_users_to_silence_new_user: "If a new user's posts get num_spam_flags_to_silence_new_user spam flags from this many different users, hide all their posts and prevent future posting. 0 to disable."
num_tl3_flags_to_silence_new_user: "If a new user's posts get this many flags from num_tl3_users_to_silence_new_user different trust level 3 users, hide all their posts and prevent future posting. 0 to disable."
num_tl3_users_to_silence_new_user: "If a new user's posts get num_tl3_flags_to_silence_new_user flags from this many different trust level 3 users, hide all their posts and prevent future posting. 0 to disable."
@@ -1719,7 +1719,7 @@ en:
max_age_unmatched_ips: "Delete unmatched screened IP entries after (N) days."
num_flaggers_to_close_topic: "Minimum number of unique flaggers that is required to automatically pause a topic for intervention"
- num_flags_to_close_topic: "Minimum number of active flags that is required to automatically pause a topic for intervention"
+ score_to_auto_close_topic: "The total flag score of that is required to automatically pause a topic for intervention"
num_hours_to_close_topic: "Number of hours to pause a topic for intervention."
auto_respond_to_flag_actions: "Enable automatic reply when disposing a flag."
@@ -1728,7 +1728,7 @@ en:
auto_silence_fast_typers_max_trust_level: "Maximum trust level to auto silence fast typers"
auto_silence_first_post_regex: "Case insensitive regex that if passed will cause first post by user to be silenced and sent to approval queue. Example: raging|a[bc]a , will cause all posts containing raging or aba or aca to be silenced on first. Only applies to first post."
flags_default_topics: "Show flagged topics by default in the admin section"
- min_flags_staff_visibility: "The minimum amount of flags on a post must have before staff can see it in the admin section"
+ min_score_default_visibility: "Don't show reviewable items unless they meet this score"
reply_by_email_enabled: "Enable replying to topics via email."
reply_by_email_address: "Template for reply by email incoming email address, for example: %%{reply_key}@reply.example.com or replies+%%{reply_key}@example.com"
@@ -2429,8 +2429,8 @@ en:
agreed: "Thanks for letting us know. We agree there is an issue and we're looking into it."
agreed_and_deleted: "Thanks for letting us know. We agree there is an issue and we've removed the post."
disagreed: "Thanks for letting us know. We're looking into it."
- deferred: "Thanks for letting us know. We're looking into it."
- deferred_and_deleted: "Thanks for letting us know. We've removed the post."
+ ignored: "Thanks for letting us know. We're looking into it."
+ ignored_and_deleted: "Thanks for letting us know. We've removed the post."
temporarily_closed_due_to_flags:
one: "This topic is temporarily closed for at least 1 hour due to a large number of community flags."
@@ -4362,3 +4362,53 @@ en:
update: "Updated"
create: "Created"
destroy: "Destroyed"
+
+
+ reviewables:
+ missing_version: "You must supply a version parameter"
+ conflict: "There was an update conflict preventing you from doing that."
+ actions:
+ agree:
+ title: "Agree..."
+ agree_and_keep:
+ title: "Keep Post"
+ description: "Agree with flag and keep the post unchanged."
+ agree_and_suspend:
+ title: "Suspend User"
+ description: "Agree with flag and suspend the user."
+ agree_and_silence:
+ title: "Silence User"
+ description: "Agree with flag and silence the user."
+ agree_and_restore:
+ title: "Restore Post"
+ description: "Restore the post so that all users can see it."
+ agree_and_hide:
+ title: "Hide Post"
+ description: "Hide this post and automatically send the user a message urging them to edit it."
+ delete_spammer:
+ title: "Delete Spammer"
+ description: "Remove the user and all their posts and topics."
+ confirm: "Are you sure you want to delete all that user's posts, topics, and block their IP and email addresses?"
+ delete:
+ title: "Delete..."
+ delete_and_ignore:
+ title: "Delete Post and Ignore flag"
+ description: "Delete post; if the first post, delete the topic as well"
+ delete_and_agree:
+ title: "Delete Post and Agree with flag"
+ description: "Delete post; if the first post, delete the topic as well"
+ disagree_and_restore:
+ title: "Disagree and Restore Post"
+ description: "Restore the post so that all users can see it."
+ disagree:
+ title: "Disagree"
+ ignore:
+ title: "Ignore"
+ approve:
+ title: "Approve"
+ reject:
+ title: "Reject"
+ delete_user:
+ title: "Delete User"
+ confirm: "Are you sure you want to delete that user? This will remove all of their posts and block their email and IP address."
+ reason: "Deleted via review queue"
diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml
index 47f6df8e7ef..0ef9a8eae2e 100644
--- a/config/locales/server.es.yml
+++ b/config/locales/server.es.yml
@@ -1255,7 +1255,6 @@ es:
polling_interval: "Cuando no este en 'long polling', ¿Qué tan frecuente debe los clientes con sesión iniciada hacer 'poll' en milisegundos?"
anon_polling_interval: "¿Cuán a menudo deben hacer poll los usuarios anónimos en milisegundos"
background_polling_interval: "Cada cuántos milisegundos debería hacerse cargarse información (mientras que la ventana esté en segundo plano)"
- flags_required_to_hide_post: "Número de reportes que causan que un post se oculte auomáticamente y le sea enviado un mensaje privado al usuario (0 para nunca)"
cooldown_minutes_after_hiding_posts: "Número de minutos que un usuario debe esperar para poder editar un post oculto por los reportes de la comunidad"
max_topics_in_first_day: "El máximo número de temas que un usuario puede crear en las 24 horas posteriores a publicar su primer post"
max_replies_in_first_day: "El máximo número de respuestas que un usuario puede crear en las 24 horas posteriores a publicar su primer post"
@@ -1543,7 +1542,6 @@ es:
auto_silence_fast_typers_max_trust_level: "Máximo nivel de confianza por debajo del cual se podrán silenciar usuarios que escriban demasiado rápido en su primer post"
auto_silence_first_post_regex: "Expresión regular que no distingue mayúsculas y minúsculas que si es detectada hará que el primer post de un usuario sea silenciado y enviado a la cola de aprobación. Ejemplo: raging|a[bc]a hará que todos los posts que contengan gaging, aba o aca sean silenciados. Sólo tiene efecto en el primer mensaje de un usuario."
flags_default_topics: "Mostrar temas reportados por defecto en la sección de administración"
- min_flags_staff_visibility: "La cantidad mínima de reportes que una publicación debe tener antes de que el staff pueda verla en la sección de administración"
reply_by_email_enabled: "Habilitar la respuesta a temas por email."
reply_by_email_address: "Plantilla para la dirección de email que aparecerá al recibir correos con la función de respuesta por email: %%{reply_key}@respuesta.ejemplo.com o respuestas+%%{reply_key}@ejemplo.com"
alternative_reply_by_email_addresses: "Lista de plantillas alternativa para las direcciones de respuesta por email. Ejemplo: %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com"
diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml
index 5aa0d07ef04..dbd70bf628b 100644
--- a/config/locales/server.fa_IR.yml
+++ b/config/locales/server.fa_IR.yml
@@ -807,7 +807,6 @@ fa_IR:
polling_interval: "وقتی رای گیری طولانی نیست، هر چند مدت باید وارد سیستم شود برای نظرسنجی مشتریها در هر میلیثانیه."
anon_polling_interval: "هر چند مدت باید مشتریهای ناشناس نظرسنجی بشوند در هر میلی ثانیه"
background_polling_interval: "هر چند وقت یکبار مشتریها باید نظرسنجی بشوند در هر ثانیه (وقتی پنجره در پس زمینه است)"
- flags_required_to_hide_post: "تعداد پرچم مورد نیاز برای مخفی شدن خودکار نوشته و پیام ارسالی به کاربر (0 برای هیچوقت)"
cooldown_minutes_after_hiding_posts: "تعداد دقایقی که کاربر باید قبل از ویرایش مخفی یک نوشته از طریق پرچم گزاری انجمن صبر کند"
max_topics_in_first_day: "حداکثر تعداد موضوعاتی که کاربر 24 ساعت بعد از ایجاد اولین نوشتهاش میتواند ایجاد کند."
max_replies_in_first_day: "حداکثر تعداد پاسخهایی که کاربر 24 ساعت بعد از ایجاد اولین نوشتهاش میتواند ارسال کند."
diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml
index 6e7e51ba129..3441c4de4c1 100644
--- a/config/locales/server.fi.yml
+++ b/config/locales/server.fi.yml
@@ -1230,7 +1230,6 @@ fi:
polling_interval: "Kun long polling ei ole käytössä, kuinka usein kirjautuneet käyttäjät pollaavat, millisekunneissa."
anon_polling_interval: "Kuinka usein anonyymit käyttäjät pollaavat millisekunneissa"
background_polling_interval: "Kuinka usein asiakkaat pollaavat, millisekunneissa (kun ikkuna ei ole aktiivisena)"
- flags_required_to_hide_post: "Kuinka monta lippua aiheuttaa viestin automaattisen piilottamisen ja automaattiviestin lähettämisen kirjoittajalle (aseta 0, jos haluat pois käytöstä)"
cooldown_minutes_after_hiding_posts: "Kuinka monta minuuttia käyttäjän tulee odottaa ennen kuin voi muokata viestiään, jonka yhteisö on liputtanut piiloon."
max_topics_in_first_day: "Kuinka monta ketjua käyttäjä voi aloittaa ensimmäistä viestiään seuraavien 24 tunnin aikana"
max_replies_in_first_day: "Kuinka monta vastausta käyttäjä voi kirjoittaa ensimmäistä viestiään seuraavien 24 tunnin aikana"
@@ -1500,7 +1499,6 @@ fi:
auto_silence_fast_typers_max_trust_level: "Enimmäisluottamustaso, jolla nopea kirjoittaja voidaan hiljentää automaattisesti"
auto_silence_first_post_regex: "Isoista ja pienistä kirjaimista riippumaton säännöllinen lauseke, joka osuessaan aiheuttaa käyttäjän ensimmäisen viestin hiljennyksen ja viesti viedään arvioitavaksi. Esimerkki: hemmetti|a[bc]a aiheuttaa hiljennyksen, jos viesti sisältää sanan 'hemmetti', 'aba' tai 'aca'. Koskee vain käyttäjän ensimmäistä viestiä."
flags_default_topics: "Näytä liputetut ketjut oletuksena ylläpito-osiossa"
- min_flags_staff_visibility: "Kuinka monesti viesti täytyy liputtaa, jotta henkilökunta näkee sen ylläpito-osiossa"
reply_by_email_enabled: "Ota käyttöön vastaukset sähköpostin avulla."
reply_by_email_address: "Saapuvien sähköpostivastausten sähköpostiosoitekaava, esimerkiksi: %%{reply_key}@reply.esimerkki.fi or replies+%%{reply_key}@esimerkki.fi"
alternative_reply_by_email_addresses: "Lista vaihtoehtoisista saapuvien sähköpostivastausten sähköpostiosoitekaavoista, esimerkiksi: %%{reply_key}@reply.esimerkki.fi tai replies+%%{reply_key}@esimerkki.fi"
diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml
index 70dfb96bdea..31c4a946db9 100644
--- a/config/locales/server.fr.yml
+++ b/config/locales/server.fr.yml
@@ -1267,7 +1267,6 @@ fr:
polling_interval: "À quelle fréquence les clients connectés devraient-ils requêter le serveur, en millisecondes (sans utiliser les requêtes longues)"
anon_polling_interval: "À quelle fréquence en millisecondes les clients anonymes doivent requêter le serveur"
background_polling_interval: "À quelle fréquence les clients devraient-ils requêter le serveur, en millisecondes (lorsque la fenêtre est en arrière-plan)"
- flags_required_to_hide_post: "Les messages seront automatiquement cachés lorsque le compteur de signalements atteint cette limite (0 pour jamais) et un avertissement sera envoyé à l'utilisateur"
cooldown_minutes_after_hiding_posts: "Nombre de minutes qu'un utilisateur doit attendre avant de pouvoir modifier un message masqué suite à un signalement de la communauté"
max_topics_in_first_day: "Le nombre maximum de sujets qu'un utilisateur est autorisé à créer dans la période de 24h après avoir créé son premier message"
max_replies_in_first_day: "Le nombre maximum de réponses qu'un utilisateur est autorisé à créer dans la période de 24h après avoir créé son premier message"
@@ -1559,7 +1558,6 @@ fr:
auto_silence_fast_typers_max_trust_level: "Niveau de confiance maximum pour automatiquement mettre sous silence les utilisateurs rédigeant des messages trop rapidement."
auto_silence_first_post_regex: "Regex non sensible à la casse qui, si elle est déclenchée, mettre sous silence le premier message de l'utilisateur et l'enverra dans la file d'attente d'approbation.\nExemple: rageux|a[bc]a mettre sous slience les premiers messages contenant rageux ou aba ou aca."
flags_default_topics: "Afficher les sujets signalés par défaut dans la section administrateur"
- min_flags_staff_visibility: "Le nombre minimum de signalements sur un message avant qu'un responsable puisse le voir sous admin."
reply_by_email_enabled: "Activer les réponses aux sujets via courriel."
reply_by_email_address: "Modèle pour la réponse par courriel entrant; exemple : %%{reply_key}@reply.example.com ou replies+%%{reply_key}@example.com"
alternative_reply_by_email_addresses: "Liste des templates alternatifs pour les adresses des courriels entrants de la réponse par courriel. Exemple : %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com"
diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml
index e75e2c9e5cc..5878de7f98b 100644
--- a/config/locales/server.he.yml
+++ b/config/locales/server.he.yml
@@ -890,7 +890,6 @@ he:
polling_interval: "כאשר לא מבצעים תשאול ארוך (long polling), כל כמה זמן לקוחות מחוברים למערכת יבצעו poll, במילי-שניות"
anon_polling_interval: "באיזו תכיפות לקוחות אנונימיים (anonymous clients) יבצעו תשאול (poll), במילי-שניות"
background_polling_interval: "באיזו תכיפות צריכים לקוחות לבצע תשאול (poll) במילישניות (כאשר החלון נמצא ברקע)"
- flags_required_to_hide_post: "מספר דגלים שגורמים להסתרה אוטומטית של פוסט ולהודעה להשלח למשתמש (0 בשביל שלעולם לא יקרה)"
cooldown_minutes_after_hiding_posts: "מספר הדקות שמשתמשים חייבים לחכות לפני שהם יכולים לערוך פוסט שהוסתר בגלל דיגלול קהילתי"
max_topics_in_first_day: "הכמות המקסימלית של נושאים שמשתמשים מורשים ליצור ב 24 השעות הראשונות לאחר הפוסט הראשון שלהם"
max_replies_in_first_day: "הכמות המקסימלית של תגובות שמשתמשים מורשים ליצור ב 24 השעות הראשונות אחרי יצירת הפוסט הראשון שלהם"
diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml
index 0748bb51782..e9952846c14 100644
--- a/config/locales/server.it.yml
+++ b/config/locales/server.it.yml
@@ -1021,7 +1021,6 @@ it:
polling_interval: "Se non si esegue il long polling, quanto spesso i client autenticati devono fare poll in millisecondi"
anon_polling_interval: "Frequenza in millisecondi con cui client anonimi effettuano il poll"
background_polling_interval: "Quanto spesso i client devono fare poll in millisecondi (quando la finestra è in background)"
- flags_required_to_hide_post: "Numero di segnalazioni che rendono un messaggio automaticamente nascosto. L'utente riceve un messaggio privato (0 per mai)"
cooldown_minutes_after_hiding_posts: "Quanti minuti l'utente deve attendere prima di poter modificare un messaggio che è stato nascosto a causa di segnalazioni"
max_topics_in_first_day: "Numero massimo di argomenti che un utente può creare nel periodo di 24 ore successivo alla creazione del suo primo messaggio"
max_replies_in_first_day: "Numero massimo di risposte che un utente può creare nel periodo di 24 ore successivo alla creazione del suo primo messaggio"
diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml
index d23ebab6adf..9080244a2f0 100644
--- a/config/locales/server.ko.yml
+++ b/config/locales/server.ko.yml
@@ -813,7 +813,6 @@ ko:
polling_interval: "long polling을 안 쓸 때 로그인된 클라이언트가 몇 밀리초마다 poll해야 하는 지"
anon_polling_interval: "How often should anonymous clients poll in milliseconds"
background_polling_interval: "(페이지를 안 보고 있을 때) 클라이언트가 몇 밀리초마다 poll해야 하는 지"
- flags_required_to_hide_post: "자동으로 숨김처리 후 사용자에게 메시지를 전송할 신고 수(0은 불가)"
cooldown_minutes_after_hiding_posts: "신고 당해서 숨겨진 글을 사용자가 편집할 수 있기 전까지 기대려야 하는 시간(분)"
max_topics_in_first_day: "첫 포스트 생성 후 24시간동안 사용자가 생성할 수 있는 최대 토픽 수"
max_replies_in_first_day: "첫 포스트 생성 후 24시간동안 사용자가 쓸 수 있는 최대 답글 수"
diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml
index f11f95a088c..6a666fc3903 100644
--- a/config/locales/server.nl.yml
+++ b/config/locales/server.nl.yml
@@ -1261,7 +1261,6 @@ nl:
polling_interval: "Als geen 'long polling' wordt toegepast, hoe vaak aangemelde clients moeten pollen in milliseconden"
anon_polling_interval: "Hoe vaak anonieme clients moeten pollen in milliseconden"
background_polling_interval: "Hoe vaak clients moeten pollen in milliseconden (als het venster in de achtergrond staat)"
- flags_required_to_hide_post: "Aantal markeringen dat zorgt voor het automatisch verbergen van een bericht en het versturen van een bericht naar de gebruiker (0 voor nooit)"
cooldown_minutes_after_hiding_posts: "Het aantal minuten dat een gebruiker moet wachten voordat deze een via de gemeenschap gemarkeerd bericht kan bewerken"
max_topics_in_first_day: "Het maximale aantal topics dat een gebruiker in de 24 uursperiode na het schrijven van een eerste bericht mag aanmaken"
max_replies_in_first_day: "Het maximale aantal antwoorden dat een gebruiker in de 24 uursperiode na het schrijven van een eerste bericht mag aanmaken"
diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml
index 1769c521f2f..16e98f147a9 100644
--- a/config/locales/server.pl_PL.yml
+++ b/config/locales/server.pl_PL.yml
@@ -866,7 +866,6 @@ pl_PL:
polling_interval: "Kiedy nie long polling, jak często zalogowani klienci powinni poll w milisekundach"
anon_polling_interval: "How often should anonymous clients poll in milliseconds"
background_polling_interval: "Jak często klienci powinni poll w milisekundach (kiedy okno jest w tle)"
- flags_required_to_hide_post: "Liczba flag które sprawią że post będzie automatycznie ukryty i wiadomość zostanie wysłana do użytkownika (0 dla nigdy)"
cooldown_minutes_after_hiding_posts: "Liczba minut, ile musi odczekać użytkownik, zanim będzie mógł edytować post ukryty w wyniku oflagowania przez społeczność"
max_topics_in_first_day: "Maksymalna liczba tematów, jakie może stworzyć użytkownik w trakcie 24 godzin po utworzeniu pierwszego posta"
max_replies_in_first_day: "Maksymalna liczba odpowiedzi, jakie może stworzyć użytkownik w trakcie 24 godzin po utworzeniu pierwszego posta"
diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml
index 0c985e067ab..2ff24d5d6d2 100644
--- a/config/locales/server.pt.yml
+++ b/config/locales/server.pt.yml
@@ -787,7 +787,6 @@ pt:
polling_interval: "Quando não está a ocorrer uma solicitação ao servidor, com que frequência devem os clientes ligados requerer uma atualização, em milissegundos"
anon_polling_interval: "Com que frequência os clientes não registados podem fazer solicitações ao servidor, em milisegundos"
background_polling_interval: "Com que frequência deverão os clientes solicitar o servidor, em milissegundos (quando a janela está em plano de fundo)"
- flags_required_to_hide_post: "Número de denúncias que fazem com que uma mensagem seja automaticamente escondida e uma mensagem enviada ao utilizador (0 se nunca)"
cooldown_minutes_after_hiding_posts: "Número de minutos que o utilizador deve esperar antes de poder editar uma mensagem oculta devido a sinalizações por parte da comunidade"
max_topics_in_first_day: "O número máximo de tópicos que o utilizador pode criar no período de 24 horas após criar a sua primeira publicação"
max_replies_in_first_day: "O número máximo de respostas que um utilizador pode criar no período de 24 horas após criar a sua primeira publicação"
diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml
index 1dfff01e701..5a33e5123f3 100644
--- a/config/locales/server.pt_BR.yml
+++ b/config/locales/server.pt_BR.yml
@@ -1020,7 +1020,6 @@ pt_BR:
polling_interval: "Com que frequencia os clientes podem solicitar o servidor em milisegundos"
anon_polling_interval: "Com que frequencia os clientes não registrados podem solicitar o servidor em milisegundos"
background_polling_interval: "Com que frequência os clientes podem solicitar o servidor em milisegundos ( em segundo plano )"
- flags_required_to_hide_post: "Número de sinalizações que fazem com que uma publicação seja automaticamente escondida e que uma mensagem seja enviada ao usuário (0 para nunca)"
cooldown_minutes_after_hiding_posts: "Número de minutos que um usuário deve esperar antes de poder editar uma publicação que foi ocultada devido a sinalização da comunidade."
max_topics_in_first_day: "Número máximo de tópicos que um usuário pode criar num período de 24 horas após a criação de sua primeira publicação"
max_replies_in_first_day: "O número máximo de respostas que um usuário está habilitado para criar é de 24 horas após a criação de sua primeira postagem. "
@@ -1265,7 +1264,6 @@ pt_BR:
auto_silence_fast_typers_max_trust_level: "Nível máximo de confiança para silenciar automaticamente os tipos rápidos"
auto_silence_first_post_regex: "Regex insensitivo a maiúsculas e minúsculas que, se for transmitido, fará com que o primeiro post do usuário seja silenciado e enviado para a fila de aprovação. Exemplo: raging | a [bc] a, fará com que todas as mensagens contendo raging ou aba ou aca sejam silenciadas primeiro. Aplica-se apenas ao primeiro post."
flags_default_topics: "Mostrar os tópicos sinalizados por padrão na seção admin"
- min_flags_staff_visibility: "A quantidade mínima de sinalizadores em uma postagem deve ter antes que a equipe possa vê-la na seção administrativa"
reply_by_email_enabled: "Habilitar responder tópicos via email"
reply_by_email_address: "Template de resposta por email, por exemplo: %%{reply_key}@reply.example.com or replies+%%{reply_key}@example.com"
alternative_reply_by_email_addresses: "Lista de modelos alternativos para responder por e-mail a endereços de e-mail recebidos. Exemplo:%%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com"
diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml
index 2fa8c5f1e03..10406ce6fb3 100644
--- a/config/locales/server.ro.yml
+++ b/config/locales/server.ro.yml
@@ -760,7 +760,6 @@ ro:
polling_interval: "Atunci când nu se face long polling, cât de des ar trebui clienții autentificați să facă interogări, în milisecunde."
anon_polling_interval: "Cât de frecvent să interogheze clienții anonimi, în milisecunde"
background_polling_interval: "Cât de des să interogheze clienții, în milisecunde (când fereastra este fundal)"
- flags_required_to_hide_post: "Număr de marcaje de avertizare care fac ca o postare să fie automat ascunsă și utilizatorului să îi fie trimis automat un mesaj (0 pentru niciodată)"
cooldown_minutes_after_hiding_posts: "Numărul de minute pe care un utilizator trebuie să le aștepte până când va putea edita o postare ascunsă prin marcaje de avertizare ale comunității."
max_topics_in_first_day: "Numărul maxim de subiecte pe care un utilizator are voie să le creeze într-o perioadă de 24 de ore după ce și-a creat prima postare"
max_replies_in_first_day: "Numărul maxim de răspunsuri pe care un utilizator are voie să le creeze într-o perioadă de 24 de ore după ce și-a creat prima postare"
diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml
index 9b566ede946..641218523a8 100644
--- a/config/locales/server.ru.yml
+++ b/config/locales/server.ru.yml
@@ -1218,8 +1218,7 @@ ru:
num_flags_to_close_topic: "Минимальное количество жалоб которое требуется для автоматической временной блокировки топика"
auto_respond_to_flag_actions: "Включить автоматический ответ при использовании флагов на сайте."
min_first_post_typing_time: "Минимальное количество времени в миллисекундах, которое пользователь должен соблюсти при наборе поста во время первой записи. Если порог не соблюден, пост автоматически попадет в очередь утверждения. Установите 0 для отключения (не рекомендуется)"
- min_flags_staff_visibility: "Минимальное количество флагов на пост, прежде чем персонал может видеть его в разделе администратора"
- reply_by_email_enabled: "Разрешить отвечать в темах с помощью электронных писем."
+ reply_by_email_enabled: "Разрешить отвечать в темах с помощью электронным писем."
reply_by_email_address: "Шаблон для ответа по email в формате: %%{reply_key}@reply.example.com или replies+%%{reply_key}@example.com"
strip_images_from_short_emails: "Удалять картинки из писем размером менее 2800 байт"
short_email_length: "Какие письма считать короткими, в байтах"
diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml
index 468b5a1d960..b394d40f4cb 100644
--- a/config/locales/server.sv.yml
+++ b/config/locales/server.sv.yml
@@ -703,7 +703,6 @@ sv:
polling_interval: "När long polling inte är aktiverat, hur ofta bör inloggade klienter polla i millisekunder"
anon_polling_interval: "Hur ofta bör anonyma klienter polla i millisekunder"
background_polling_interval: "Hur ofta bör klienter polla i millisekunder (när fönstret är i bakgrunden)"
- flags_required_to_hide_post: "Antal flaggor som orsakar att ett inlägg automatiskt döljs och skickar ett meddelande till användaren (ange 0 för att det aldrig ska hända)"
cooldown_minutes_after_hiding_posts: "Antal minuter en användare måste vänta innan de kan redigera ett inlägg som dolt på grund av flaggningar från användare"
max_topics_in_first_day: "Högsta antal ämnen en användare får skapa under de första 24 timmarna efter att ha skapat sitt första inlägg"
max_replies_in_first_day: "Högsta antal svar en användare får skapa under de första 24 timmarna efter att ha skapat sitt första inlägg"
diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml
index 474abf9da19..71da3e04465 100644
--- a/config/locales/server.tr_TR.yml
+++ b/config/locales/server.tr_TR.yml
@@ -648,7 +648,6 @@ tr_TR:
polling_interval: "Uzun sorgular yapılmadığı zaman, kaç mili saniyede bir giriş yapmış kullanıcılar poll yapmalı"
anon_polling_interval: "Kaç mili saniyede bir anonim kullanıcılar sorgu yapmalı"
background_polling_interval: "(Pencere arkaplanda olduğu zaman) kaç mili saniyede bir kullanıcılan sorgu yapmalı"
- flags_required_to_hide_post: "Gönderinin otomatik olarak gizlenip kullanıcıya ileti gönderilmesi için gereken toplam bildirim sayısı (hiçbir zaman için 0)"
cooldown_minutes_after_hiding_posts: "Topluluk tarafından bildirilerek gizlenen gönderiyi düzenleyebilmek için, kullanıcının beklemesi gereken dakika süresi "
max_topics_in_first_day: "Kullanıcının ilk gönderisini oluşturduktan sonraki 24 saatlik zaman dilimi içerisinde oluşturmasına izin verilen konu sayısı."
max_replies_in_first_day: "Kullanıcının ilk gönderisini oluşturduktan sonraki 24 saatlik zaman dilimi içerisinde oluşturabileceği cevap sayısı."
diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml
index 1563b74dcf7..3dcb8198e19 100644
--- a/config/locales/server.ur.yml
+++ b/config/locales/server.ur.yml
@@ -901,7 +901,6 @@ ur:
polling_interval: "جب لانگ پولِنگ نہ ہو، تو لاگ ہوئے کلائنٹس کو مِلی سیکنڈوں میں کتنی دفعہ پَول کرنا چاہئے"
anon_polling_interval: "گمنام کلائنٹس کو مِلی سیکنڈوں میں کتنی دفعہ پَول کرنا چاہئے"
background_polling_interval: "کلائنٹس کو مِلی سیکنڈوں میں کتنی دفعہ پَول کرنا چاہئے (جب وِنڈو پسِ منظر میں ہو)"
- flags_required_to_hide_post: "فلَیگز کی تعداد جس کے بعد کسی پوسٹ کو خود کار طریقہ سے چھپا دیا اور صارف کو پیغام بھیج دیا جاتا ہے (کبھی نہیں کیلئے 0)"
cooldown_minutes_after_hiding_posts: "منٹوں کی تعداد جن کیلئے ایک صارف کو کمیونٹی فلَیگ بندی کے ذریعہ چھپائی گئی پوسٹ میں ترمیم کرنے سے پہلے انتظار کرنا لازمی ہے"
max_topics_in_first_day: "اُن کی پہلی پوسٹ بنانے کے بعد 24 گھنٹوں کی مدت میں ایک صارف کی طرف سے تخلیق کردہ ٹاپکس کی زیادہ سے زیادہ تعداد"
max_replies_in_first_day: "اُن کی پہلی پوسٹ بنانے کے بعد 24 گھنٹوں کی مدت میں ایک صارف کی طرف سے تخلیق کردہ جوابات کی زیادہ سے زیادہ تعداد"
diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml
index 1059c8b5dff..2d17b48b943 100644
--- a/config/locales/server.vi.yml
+++ b/config/locales/server.vi.yml
@@ -581,7 +581,6 @@ vi:
polling_interval: "When not long polling, how often should logged on clients poll in milliseconds"
anon_polling_interval: "How often should anonymous clients poll in milliseconds"
background_polling_interval: "How often should the clients poll in milliseconds (when the window is in the background)"
- flags_required_to_hide_post: "Số lần đánh dấu làm cho bài viết sẽ được tự động ẩn và tin nhắn sẽ được gửi đến người dùng (0 là không giới hạn)"
cooldown_minutes_after_hiding_posts: "Số phút một người dùng phải chờ trước khi họ có thể sửa một bài viết ẩn bởi gắn cờ cộng đồng"
tl2_additional_likes_per_day_multiplier: "Tăng giới hạn thích mỗi ngày cho mức độ tin tưởng 2 (thành viên) bằng cách nhân với số này"
tl3_additional_likes_per_day_multiplier: "Tăng giới hạn thích mỗi ngày cho mức độ tin tưởng 3 (bình thường) bằng cách nhân với số này"
diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml
index c0ee6a8bd10..730915275c9 100644
--- a/config/locales/server.zh_CN.yml
+++ b/config/locales/server.zh_CN.yml
@@ -1185,7 +1185,6 @@ zh_CN:
polling_interval: "当不再长轮询时,已登录的客户端多久应该轮询一次,以毫秒为单位"
anon_polling_interval: "匿名客户端轮询时间间隔(单位:毫秒)"
background_polling_interval: "客户端轮询的间隔,以毫秒计(当窗口在后台时)"
- flags_required_to_hide_post: "一个帖子累计多少个标记之后会被自动隐藏,并向帖子作者发送私信通知(0 为从不)"
cooldown_minutes_after_hiding_posts: "当一个帖子因为标记而隐藏之后,用户需要等待多少分钟才能编辑帖子"
max_topics_in_first_day: "新用户在24小时内在发表第一帖后允许创建的主题数"
max_replies_in_first_day: "新用户在24小时内在发表第一帖后允许创建的回复数"
@@ -1459,7 +1458,6 @@ zh_CN:
auto_silence_fast_typers_max_trust_level: "自动禁言输入快速的用户的适用信任等级"
auto_silence_first_post_regex: "检查用户首贴的大小写不相关的正则表达式。如匹配,用户将被禁言并送至审核队列。例子:raging|a[bc]a 将匹配 raging、aba 或者 aca,因此禁言该用户。只对首贴有效。"
flags_default_topics: "在管理栏目中默认显示被标记的主题"
- min_flags_staff_visibility: "在管理人员可以在管理部分中看到之前,帖子中的最小标记量"
reply_by_email_enabled: "启用通过邮件回复。"
reply_by_email_address: "通过邮件回复的回复地址模板,例如:%%{reply_key}@reply.example.com 或 replies+%%{reply_key}@example.com"
alternative_reply_by_email_addresses: "通过邮件回复的回复地址模板,例如:%%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com"
diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml
index 2f09e86aac5..9bbc1a108dc 100644
--- a/config/locales/server.zh_TW.yml
+++ b/config/locales/server.zh_TW.yml
@@ -1097,7 +1097,6 @@ zh_TW:
polling_interval: "當不再長輪詢時,已登錄的客戶端應該多久輪詢一次(單位 毫秒)"
anon_polling_interval: "匿名使用者用戶端輪詢時間間隔(單位 毫秒)"
background_polling_interval: "客戶端輪詢的間隔,以毫秒計(當視窗在後台時)"
- flags_required_to_hide_post: "一個帖子累計多少個標記之後會被自動隱藏,並向帖子作者發送私信通知(0 為從不)"
cooldown_minutes_after_hiding_posts: "如果一個文章因為標記而隱藏,用戶需要等待多少分鐘才能編輯該文章"
max_topics_in_first_day: "新用戶在24小時內在發表第一帖後允許創建的主題數"
max_replies_in_first_day: "新用戶在24小時內在發表第一帖後允許創建的回覆數"
diff --git a/config/routes.rb b/config/routes.rb
index 68620af6952..968bc1f0452 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -94,8 +94,6 @@ Discourse::Application.routes.draw do
get "groups/:type" => "groups#show", constraints: AdminConstraint.new
get "groups/:type/:id" => "groups#show", constraints: AdminConstraint.new
- get "moderation_history" => "moderation_history#index"
-
resources :users, id: RouteFormat.username, except: [:show] do
collection do
get "list" => "users#index"
@@ -319,6 +317,15 @@ Discourse::Application.routes.draw do
end
end
+ get "review" => "reviewables#index" # For ember app
+ get "review/:reviewable_id" => "reviewables#show", constraints: { reviewable_id: /\d+/ }
+ get "review/topics" => "reviewables#topics"
+ put "review/:reviewable_id/perform/:action_id" => "reviewables#perform", constraints: {
+ reviewable_id: /\d+/,
+ action_id: /[a-z\_]+/
+ }
+ put "review/:reviewable_id" => "reviewables#update", constraints: { reviewable_id: /\d+/ }
+
get "session/sso" => "session#sso"
get "session/sso_login" => "session#sso_login"
get "session/sso_provider" => "session#sso_provider"
@@ -440,7 +447,6 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/notifications" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/notifications/:filter" => "users#show", constraints: { username: RouteFormat.username }
- get "#{root_path}/:username/activity/pending" => "users#show", constraints: { username: RouteFormat.username }
delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username, format: /(json|html)/ }
get "#{root_path}/by-external/:external_id" => "users#show", constraints: { external_id: /[^\/]+/ }
get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username }
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 6dcb7eb6eca..0132538bbd2 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -1335,12 +1335,10 @@ onebox:
spam:
add_rel_nofollow_to_user_content: true
- flags_required_to_hide_post: 3
+ score_required_to_hide_post: 10
cooldown_minutes_after_hiding_posts: 10
- num_spam_flags_to_silence_new_user: 3
+ spam_score_to_silence_new_user: 6.0
num_users_to_silence_new_user: 3
- num_tl3_flags_to_silence_new_user: 4
- num_tl3_users_to_silence_new_user: 2
notify_mods_when_user_silenced: false
flag_sockpuppets: false
newuser_spam_host_threshold: 3
@@ -1355,7 +1353,7 @@ spam:
max_age_unmatched_emails: 365
max_age_unmatched_ips: 365
num_flaggers_to_close_topic: 5
- num_flags_to_close_topic: 12
+ score_to_auto_close_topic: 25.0
num_hours_to_close_topic:
default: 4
min: 1
@@ -1367,7 +1365,9 @@ spam:
flags_default_topics:
default: false
client: true
- min_flags_staff_visibility: 1
+ min_score_default_visibility:
+ default: 0.0
+ client: true
rate_limits:
unique_posts_mins: 5
diff --git a/db/fixtures/007_web_hook_event_types.rb b/db/fixtures/007_web_hook_event_types.rb
index 7e94839fd4a..cbe6a323446 100644
--- a/db/fixtures/007_web_hook_event_types.rb
+++ b/db/fixtures/007_web_hook_event_types.rb
@@ -37,3 +37,8 @@ WebHookEventType.seed do |b|
b.id = WebHookEventType::QUEUED_POST
b.name = "queued_post"
end
+
+WebHookEventType.seed do |b|
+ b.id = WebHookEventType::REVIEWABLE
+ b.name = "reviewable"
+end
diff --git a/db/migrate/20190103160533_create_reviewables.rb b/db/migrate/20190103160533_create_reviewables.rb
new file mode 100644
index 00000000000..90da14223b5
--- /dev/null
+++ b/db/migrate/20190103160533_create_reviewables.rb
@@ -0,0 +1,41 @@
+class CreateReviewables < ActiveRecord::Migration[5.2]
+ def change
+ create_table :reviewables do |t|
+ t.string :type, null: false
+ t.integer :status, null: false, default: 0
+ t.integer :created_by_id, null: false
+
+ # Who can review this item? Moderators always can
+ t.boolean :reviewable_by_moderator, null: false, default: false
+ t.integer :reviewable_by_group_id, null: true
+
+ # On some high traffic sites they want things in review to be claimed
+ # so that two people don't work on the same thing.
+ t.integer :claimed_by_id, null: true
+
+ # For filtering
+ t.integer :category_id, null: true
+ t.integer :topic_id, null: true
+ t.float :score, null: false, default: 0
+ t.boolean :potential_spam, null: false, default: false
+
+ # Polymorphic relation of reviewable thing
+ t.integer :target_id, null: true
+ t.string :target_type, null: true
+ t.integer :target_created_by_id, null: true
+
+ t.json :payload, null: true
+
+ # Helps us prevent simultaneous updates
+ t.integer :version, null: false, default: 0
+
+ t.datetime :latest_score, null: true
+ t.timestamps
+ end
+
+ add_index :reviewables, :status
+ add_index :reviewables, [:status, :type]
+ add_index :reviewables, [:status, :score]
+ add_index :reviewables, [:type, :target_id], unique: true
+ end
+end
diff --git a/db/migrate/20190103185626_create_reviewable_users.rb b/db/migrate/20190103185626_create_reviewable_users.rb
new file mode 100644
index 00000000000..b4f53ad9545
--- /dev/null
+++ b/db/migrate/20190103185626_create_reviewable_users.rb
@@ -0,0 +1,56 @@
+class CreateReviewableUsers < ActiveRecord::Migration[5.2]
+ def up
+ # Create reviewables for approved users
+ if DB.query_single("SELECT 1 FROM site_settings WHERE name = 'must_approve_users' AND value = 't'").first
+ execute(<<~SQL)
+ INSERT INTO reviewables (
+ type,
+ status,
+ created_by_id,
+ reviewable_by_moderator,
+ target_type,
+ target_id,
+ created_at,
+ updated_at
+ )
+ SELECT 'ReviewableUser',
+ 0,
+ #{Discourse::SYSTEM_USER_ID},
+ true,
+ 'User',
+ id,
+ created_at,
+ created_at
+ FROM users
+ WHERE approved = false
+ SQL
+
+ # Migrate Created History
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 1,
+ r.created_by_id,
+ r.created_at,
+ r.created_at
+ FROM reviewables AS r
+ WHERE r.type = 'ReviewableUser'
+ SQL
+ end
+ end
+
+ def down
+ execute(<<~SQL)
+ DELETE FROM reviewables
+ WHERE type = 'ReviewableUser'
+ SQL
+ end
+end
diff --git a/db/migrate/20190110212005_create_reviewable_histories.rb b/db/migrate/20190110212005_create_reviewable_histories.rb
new file mode 100644
index 00000000000..0a54fc0629b
--- /dev/null
+++ b/db/migrate/20190110212005_create_reviewable_histories.rb
@@ -0,0 +1,14 @@
+class CreateReviewableHistories < ActiveRecord::Migration[5.2]
+ def change
+ create_table :reviewable_histories do |t|
+ t.integer :reviewable_id, null: false
+ t.integer :reviewable_history_type, null: false
+ t.integer :status, null: false
+ t.integer :created_by_id, null: false
+ t.json :edited, null: true
+ t.timestamps
+ end
+
+ add_index :reviewable_histories, :reviewable_id
+ end
+end
diff --git a/db/migrate/20190111170824_migrate_reviewable_queued_posts.rb b/db/migrate/20190111170824_migrate_reviewable_queued_posts.rb
new file mode 100644
index 00000000000..c673777db93
--- /dev/null
+++ b/db/migrate/20190111170824_migrate_reviewable_queued_posts.rb
@@ -0,0 +1,104 @@
+class MigrateReviewableQueuedPosts < ActiveRecord::Migration[5.2]
+ def up
+ execute(<<~SQL)
+ INSERT INTO reviewables (
+ type,
+ status,
+ created_by_id,
+ reviewable_by_moderator,
+ topic_id,
+ category_id,
+ payload,
+ created_at,
+ updated_at
+ )
+ SELECT 'ReviewableQueuedPost',
+ state - 1,
+ user_id,
+ true,
+ topic_id,
+ nullif(post_options->>'category', '')::int,
+ json_build_object(
+ 'old_queued_post_id', id,
+ 'raw', raw
+ )::jsonb || post_options::jsonb,
+ created_at,
+ updated_at
+ FROM queued_posts
+ SQL
+
+ # Migrate Created History
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 0,
+ 0,
+ qp.user_id,
+ qp.created_at,
+ qp.created_at
+ FROM reviewables AS r
+ INNER JOIN queued_posts AS qp ON qp.id = (payload->>'old_queued_post_id')::int
+ SQL
+
+ # Migrate Approved History
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 1,
+ qp.approved_by_id,
+ qp.approved_at,
+ qp.approved_at
+ FROM reviewables AS r
+ INNER JOIN queued_posts AS qp ON qp.id = (payload->>'old_queued_post_id')::int
+ WHERE qp.state = 2
+ SQL
+
+ # Migrate Rejected History
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 2,
+ qp.rejected_by_id,
+ qp.rejected_at,
+ qp.rejected_at
+ FROM reviewables AS r
+ INNER JOIN queued_posts AS qp ON qp.id = (payload->>'old_queued_post_id')::int
+ WHERE qp.state = 3
+ SQL
+ end
+
+ def down
+ execute(<<~SQL)
+ DELETE FROM reviewable_histories
+ WHERE reviewable_id IN (SELECT id FROM reviewables WHERE type = 'ReviewableQueuedPost')
+ SQL
+
+ execute(<<~SQL)
+ DELETE FROM reviewables
+ WHERE type = 'ReviewableQueuedPost'
+ SQL
+ end
+end
diff --git a/db/migrate/20190121202656_remove_user_action_pending.rb b/db/migrate/20190121202656_remove_user_action_pending.rb
new file mode 100644
index 00000000000..f48e87e2ea5
--- /dev/null
+++ b/db/migrate/20190121202656_remove_user_action_pending.rb
@@ -0,0 +1,8 @@
+class RemoveUserActionPending < ActiveRecord::Migration[5.2]
+ def up
+ execute "DELETE FROM user_actions WHERE action_type = 14"
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20190130163000_create_reviewable_scores.rb b/db/migrate/20190130163000_create_reviewable_scores.rb
new file mode 100644
index 00000000000..2fa7a5a87b4
--- /dev/null
+++ b/db/migrate/20190130163000_create_reviewable_scores.rb
@@ -0,0 +1,19 @@
+class CreateReviewableScores < ActiveRecord::Migration[5.2]
+ def change
+ create_table :reviewable_scores do |t|
+ t.integer :reviewable_id, null: false
+ t.integer :user_id, null: false
+ t.integer :reviewable_score_type, null: false
+ t.integer :status, null: false
+ t.float :score, null: false, default: 0
+ t.float :take_action_bonus, null: false, default: 0
+ t.integer :reviewed_by_id, null: true
+ t.datetime :reviewed_at, null: true
+ t.integer :meta_topic_id, null: true
+ t.timestamps
+ end
+
+ add_index :reviewable_scores, :reviewable_id
+ add_index :reviewable_scores, :user_id
+ end
+end
diff --git a/db/migrate/20190130163001_migrate_reviewable_flagged_posts.rb b/db/migrate/20190130163001_migrate_reviewable_flagged_posts.rb
new file mode 100644
index 00000000000..34dfd894b6a
--- /dev/null
+++ b/db/migrate/20190130163001_migrate_reviewable_flagged_posts.rb
@@ -0,0 +1,117 @@
+class MigrateReviewableFlaggedPosts < ActiveRecord::Migration[5.2]
+ def up
+
+ # for the migration we'll do 1.0 + trust_level and not take into account user flagging accuracy
+ # It should be good enough for old flags whose scores are not as important as pending flags.
+ execute(<<~SQL)
+ INSERT INTO reviewables (
+ type,
+ status,
+ topic_id,
+ category_id,
+ payload,
+ target_type,
+ target_id,
+ target_created_by_id,
+ score,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT 'ReviewableFlaggedPost',
+ CASE
+ WHEN MAX(pa.agreed_at) IS NOT NULL THEN 1
+ WHEN MAX(pa.disagreed_at) IS NOT NULL THEN 2
+ WHEN MAX(pa.deferred_at) IS NOT NULL THEN 3
+ WHEN MAX(pa.deleted_at) IS NOT NULL THEN 4
+ ELSE 0
+ END,
+ t.id,
+ t.category_id,
+ json_build_object(),
+ 'Post',
+ pa.post_id,
+ p.user_id,
+ 0,
+ MAX(pa.user_id),
+ MIN(pa.created_at),
+ MAX(pa.updated_at)
+ FROM post_actions AS pa
+ INNER JOIN posts AS p ON pa.post_id = p.id
+ INNER JOIN topics AS t ON t.id = p.topic_id
+ INNER JOIN post_action_types AS pat ON pat.id = pa.post_action_type_id
+ WHERE pat.is_flag
+ AND pat.name_key <> 'notify_user'
+ AND p.user_id > 0
+ AND p.deleted_at IS NULL
+ AND t.deleted_at IS NULL
+ GROUP BY pa.post_id,
+ t.id,
+ t.category_id,
+ p.user_id
+ SQL
+
+ execute(<<~SQL)
+ INSERT INTO reviewable_scores (
+ reviewable_id,
+ user_id,
+ reviewable_score_type,
+ status,
+ score,
+ meta_topic_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ pa.user_id,
+ pa.post_action_type_id,
+ CASE
+ WHEN pa.agreed_at IS NOT NULL THEN 1
+ WHEN pa.disagreed_at IS NOT NULL THEN 2
+ WHEN pa.deferred_at IS NOT NULL THEN 3
+ WHEN pa.deleted_at IS NOT NULL THEN 3
+ ELSE 0
+ END,
+ 1.0 +
+ (CASE
+ WHEN pau.moderator OR pau.admin THEN 5.0
+ ELSE pau.trust_level
+ END) +
+ (CASE
+ WHEN pa.staff_took_action THEN 5.0
+ ELSE 0.0
+ END),
+ rp.topic_id,
+ pa.created_at,
+ pa.updated_at
+ FROM post_actions AS pa
+ INNER JOIN post_action_types AS pat ON pat.id = pa.post_action_type_id
+ INNER JOIN users AS pau ON pa.user_id = pau.id
+ INNER JOIN reviewables AS r ON pa.post_id = r.target_id
+ LEFT OUTER JOIN posts AS rp ON rp.id = pa.related_post_id
+ WHERE pat.is_flag
+ AND r.type = 'ReviewableFlaggedPost'
+ SQL
+
+ execute(<<~SQL)
+ UPDATE reviewables
+ SET score = COALESCE((
+ SELECT sum(score)
+ FROM reviewable_scores AS rs
+ WHERE rs.reviewable_id = reviewables.id
+ AND rs.status = 0
+ ), 0),
+ potential_spam = EXISTS(
+ SELECT 1
+ FROM reviewable_scores AS rs
+ WHERE rs.reviewable_id = reviewables.id
+ AND rs.reviewable_score_type = 8
+ )
+ SQL
+ end
+
+ def down
+ execute "DELETE FROM reviewables WHERE type = 'ReviewableFlaggedPost'"
+ execute "DELETE FROM reviewable_scores"
+ end
+end
diff --git a/db/migrate/20190215204033_add_score_bonus_to_post_action_types.rb b/db/migrate/20190215204033_add_score_bonus_to_post_action_types.rb
new file mode 100644
index 00000000000..95bcfcf18fa
--- /dev/null
+++ b/db/migrate/20190215204033_add_score_bonus_to_post_action_types.rb
@@ -0,0 +1,5 @@
+class AddScoreBonusToPostActionTypes < ActiveRecord::Migration[5.2]
+ def change
+ add_column :post_action_types, :score_bonus, :float, default: 0.0, null: false
+ end
+end
diff --git a/db/migrate/20190306184409_add_reviewable_score_to_topics.rb b/db/migrate/20190306184409_add_reviewable_score_to_topics.rb
new file mode 100644
index 00000000000..474c1f1ad16
--- /dev/null
+++ b/db/migrate/20190306184409_add_reviewable_score_to_topics.rb
@@ -0,0 +1,22 @@
+class AddReviewableScoreToTopics < ActiveRecord::Migration[5.2]
+ def up
+ add_column :topics, :reviewable_score, :float, null: false, default: 0
+
+ execute(<<~SQL)
+ UPDATE topics
+ SET reviewable_score = sums.score
+ FROM (
+ SELECT SUM(r.score) AS score,
+ r.topic_id
+ FROM reviewables AS r
+ WHERE r.status = 0
+ GROUP BY r.topic_id
+ ) AS sums
+ WHERE sums.topic_id = topics.id
+ SQL
+ end
+
+ def down
+ remove_column :topics, :reviewable_score
+ end
+end
diff --git a/db/migrate/20190313171338_add_indexes_to_reviewables.rb b/db/migrate/20190313171338_add_indexes_to_reviewables.rb
new file mode 100644
index 00000000000..3473c1957ec
--- /dev/null
+++ b/db/migrate/20190313171338_add_indexes_to_reviewables.rb
@@ -0,0 +1,11 @@
+class AddIndexesToReviewables < ActiveRecord::Migration[5.2]
+ def up
+ remove_index :reviewables, :status
+ add_index :reviewables, [:status, :created_at]
+ end
+
+ def down
+ remove_index :reviewables, [:status, :created_at]
+ add_index :reviewables, :status
+ end
+end
diff --git a/db/migrate/20190315170411_add_index_to_reviewable_histories.rb b/db/migrate/20190315170411_add_index_to_reviewable_histories.rb
new file mode 100644
index 00000000000..6953eaefbc7
--- /dev/null
+++ b/db/migrate/20190315170411_add_index_to_reviewable_histories.rb
@@ -0,0 +1,5 @@
+class AddIndexToReviewableHistories < ActiveRecord::Migration[5.2]
+ def change
+ add_index :reviewable_histories, :created_by_id
+ end
+end
diff --git a/db/migrate/20190315174428_migrate_flag_history.rb b/db/migrate/20190315174428_migrate_flag_history.rb
new file mode 100644
index 00000000000..37fc7a5cd35
--- /dev/null
+++ b/db/migrate/20190315174428_migrate_flag_history.rb
@@ -0,0 +1,131 @@
+class MigrateFlagHistory < ActiveRecord::Migration[5.2]
+ def up
+
+ # Migrate Created History
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 0,
+ 0,
+ r.created_by_id,
+ r.created_at,
+ r.created_at
+ FROM reviewables AS r
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND (
+ NOT EXISTS(
+ SELECT 1
+ FROM reviewable_histories AS rh
+ WHERE rh.reviewable_id = r.id
+ AND rh.reviewable_history_type = 0
+ )
+ )
+ SQL
+
+ # Approved
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 1,
+ pa.agreed_by_id,
+ pa.agreed_at,
+ pa.agreed_at
+ FROM reviewables AS r
+ INNER JOIN post_actions AS pa ON pa.post_id = r.target_id
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND pa.agreed_at IS NOT NULL
+ AND pa.agreed_by_id IS NOT NULL
+ SQL
+
+ # Rejected
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 2,
+ pa.disagreed_by_id,
+ pa.disagreed_at,
+ pa.disagreed_at
+ FROM reviewables AS r
+ INNER JOIN post_actions AS pa ON pa.post_id = r.target_id
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND pa.disagreed_at IS NOT NULL
+ AND pa.disagreed_by_id IS NOT NULL
+ SQL
+
+ # Ignored
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 3,
+ pa.deferred_by_id,
+ pa.deferred_at,
+ pa.deferred_at
+ FROM reviewables AS r
+ INNER JOIN post_actions AS pa ON pa.post_id = r.target_id
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND pa.deferred_at IS NOT NULL
+ AND pa.deferred_by_id IS NOT NULL
+ SQL
+
+ # Deleted
+ execute(<<~SQL)
+ INSERT INTO reviewable_histories (
+ reviewable_id,
+ reviewable_history_type,
+ status,
+ created_by_id,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ 1,
+ 4,
+ pa.deleted_by_id,
+ pa.deleted_at,
+ pa.deleted_at
+ FROM reviewables AS r
+ INNER JOIN post_actions AS pa ON pa.post_id = r.target_id
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND pa.deleted_at IS NOT NULL
+ AND pa.deleted_by_id IS NOT NULL
+ SQL
+ end
+
+ def down
+ execute(<<~SQL)
+ DELETE FROM reviewable_histories
+ WHERE reviewable_id IN (SELECT id FROM reviewables WHERE type = 'ReviewableFlaggedPost')
+ SQL
+ end
+end
diff --git a/db/migrate/20190327205525_require_reviewable_scores.rb b/db/migrate/20190327205525_require_reviewable_scores.rb
new file mode 100644
index 00000000000..85ab95f1267
--- /dev/null
+++ b/db/migrate/20190327205525_require_reviewable_scores.rb
@@ -0,0 +1,43 @@
+class RequireReviewableScores < ActiveRecord::Migration[5.2]
+ def up
+ min_score = DB.query_single("SELECT value FROM site_settings WHERE name = 'min_score_default_visibility'")[0].to_f
+ min_score = 1.0 if (min_score < 1.0)
+
+ execute(<<~SQL)
+ INSERT INTO reviewable_scores (
+ reviewable_id,
+ user_id,
+ reviewable_score_type,
+ score,
+ status,
+ created_at,
+ updated_at
+ )
+ SELECT r.id,
+ -1,
+ 9,
+ #{min_score},
+ r.status,
+ r.created_at,
+ r.created_at
+ FROM reviewables AS r
+ WHERE r.type IN ('ReviewableQueuedPost', 'ReviewableUser')
+ SQL
+
+ execute(<<~SQL)
+ UPDATE reviewables SET score = (
+ SELECT SUM(score)
+ FROM reviewable_scores
+ WHERE reviewable_scores.reviewable_id = reviewables.id
+ )
+ SQL
+ end
+
+ def down
+ execute(<<~SQL)
+ DELETE FROM reviewable_scores WHERE reviewable_id IN (
+ SELECT id FROM reviewables WHERE type IN ('ReviewableQueuedPost', 'ReviewableUser')
+ )
+ SQL
+ end
+end
diff --git a/db/post_migrate/20190121203023_drop_queued_post_id_from_user_actions.rb b/db/post_migrate/20190121203023_drop_queued_post_id_from_user_actions.rb
new file mode 100644
index 00000000000..2d504d5401c
--- /dev/null
+++ b/db/post_migrate/20190121203023_drop_queued_post_id_from_user_actions.rb
@@ -0,0 +1,9 @@
+class DropQueuedPostIdFromUserActions < ActiveRecord::Migration[5.2]
+ def up
+ remove_column :user_actions, :queued_post_id
+ end
+
+ def down
+ add_column :user_actions, :queued_post_id, :integer
+ end
+end
diff --git a/db/post_migrate/20190123171817_drop_queued_posts.rb b/db/post_migrate/20190123171817_drop_queued_posts.rb
new file mode 100644
index 00000000000..6f58fa63a48
--- /dev/null
+++ b/db/post_migrate/20190123171817_drop_queued_posts.rb
@@ -0,0 +1,5 @@
+class DropQueuedPosts < ActiveRecord::Migration[5.2]
+ def up
+ drop_table :queued_posts
+ end
+end
diff --git a/lib/discourse.rb b/lib/discourse.rb
index 96e014db23e..37421c4ff29 100644
--- a/lib/discourse.rb
+++ b/lib/discourse.rb
@@ -619,7 +619,7 @@ module Discourse
end
end
- def self.deprecate(warning, drop_from: nil, since: nil, raise_error: false)
+ def self.deprecate(warning, drop_from: nil, since: nil, raise_error: false, output_in_test: false)
location = caller_locations[1].yield_self { |l| "#{l.path}:#{l.lineno}:in \`#{l.label}\`" }
warning = ["Deprecation notice:", warning]
warning << "(deprecated since Discourse #{since})" if since
@@ -635,6 +635,10 @@ module Discourse
STDERR.puts(warning)
end
+ if output_in_test && Rails.env == "test"
+ STDERR.puts(warning)
+ end
+
digest = Digest::MD5.hexdigest(warning)
redis_key = "deprecate-notice-#{digest}"
diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb
index e8b5ac56297..03e9c845ef5 100644
--- a/lib/email/receiver.rb
+++ b/lib/email/receiver.rb
@@ -1,6 +1,5 @@
require "digest"
require_dependency "new_post_manager"
-require_dependency "post_action_creator"
require_dependency "html_to_markdown"
require_dependency "plain_text_to_markdown"
require_dependency "upload_creator"
@@ -936,11 +935,8 @@ module Email
end
def create_post_action(user, post, type)
- PostActionCreator.new(user, post).perform(type)
- rescue PostAction::AlreadyActed
- # it's cool, don't care
- rescue Discourse::InvalidAccess => e
- raise InvalidPostAction.new(e)
+ result = PostActionCreator.new(user, post, type).perform
+ raise InvalidPostAction.new if result.failed? && result.forbidden
end
def is_whitelisted_attachment?(attachment)
diff --git a/lib/flag_query.rb b/lib/flag_query.rb
index cd0ed150892..0f5e8741f90 100644
--- a/lib/flag_query.rb
+++ b/lib/flag_query.rb
@@ -12,34 +12,26 @@ module FlagQuery
end
def self.flagged_posts_report(current_user, opts = nil)
+ Discourse.deprecate("FlagQuery is deprecated, use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+
opts ||= {}
offset = opts[:offset] || 0
per_page = opts[:per_page] || 25
- actions = flagged_post_actions(opts)
+ reviewables = ReviewableFlaggedPost.default_visible.viewable_by(current_user, order: 'created_at DESC')
+ reviewables = reviewables.where(topic_id: opts[:topic_id]) if opts[:topic_id]
+ reviewables = reviewables.where(target_created_by_id: opts[:user_id]) if opts[:user_id]
+ reviewables = reviewables.limit(per_page).offset(offset)
- guardian = Guardian.new(current_user)
-
- if !guardian.is_admin?
- actions = actions.where(
- 'category_id IN (:allowed_category_ids) OR archetype = :private_message',
- allowed_category_ids: guardian.allowed_category_ids,
- private_message: Archetype.private_message
- )
+ if opts[:filter] == 'old'
+ reviewables = reviewables.where("status <> ?", Reviewable.statuses[:pending])
+ else
+ reviewables = reviewables.pending
end
- total_rows = actions.count
+ total_rows = reviewables.count
- post_ids_relation = actions.limit(per_page)
- .offset(offset)
- .group(:post_id)
- .order('MIN(post_actions.created_at) DESC')
-
- if opts[:filter] != "old"
- post_ids_relation = PostAction.apply_minimum_visibility(post_ids_relation)
- end
-
- post_ids = post_ids_relation.pluck(:post_id).uniq
+ post_ids = reviewables.map(&:target_id).uniq
posts = DB.query(<<~SQL, post_ids: post_ids)
SELECT p.id,
@@ -52,7 +44,6 @@ module FlagQuery
p.hidden,
p.deleted_at,
p.user_deleted,
- NULL as post_actions,
NULL as post_action_ids,
(SELECT created_at FROM post_revisions WHERE post_id = p.id AND user_id = p.user_id ORDER BY created_at DESC LIMIT 1) AS last_revised_at,
(SELECT COUNT(*) FROM post_actions WHERE (disagreed_at IS NOT NULL OR agreed_at IS NOT NULL OR deferred_at IS NOT NULL) AND post_id = p.id)::int AS previous_flags_count
@@ -71,68 +62,56 @@ module FlagQuery
post_lookup[p.id] = p
end
- post_actions = actions.order('post_actions.created_at DESC')
- .includes(related_post: { topic: { ordered_posts: :user } })
- .where(post_id: post_ids)
-
all_post_actions = []
+ reviewables.each do |r|
+ post = post_lookup[r.target_id]
+ post.post_action_ids ||= []
- post_actions.each do |pa|
- post = post_lookup[pa.post_id]
+ r.reviewable_scores.order('created_at desc').each do |rs|
+ action = {
+ id: rs.id,
+ post_id: post.id,
+ user_id: rs.user_id,
+ post_action_type_id: rs.reviewable_score_type,
+ created_at: rs.created_at,
+ disposed_by_id: rs.reviewed_by_id,
+ disposed_at: rs.reviewed_at,
+ disposition: ReviewableScore.statuses[rs.status],
+ targets_topic: r.payload['targets_topic'],
+ staff_took_action: rs.took_action?
+ }
+ action[:name_key] = PostActionType.types.key(rs.reviewable_score_type)
- if opts[:rest_api]
- post.post_action_ids ||= []
- else
- post.post_actions ||= []
- end
+ if rs.meta_topic.present?
+ meta_posts = rs.meta_topic.ordered_posts
- # 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)
+ conversation = {}
+ if response = meta_posts[0]
+ action[:related_post_id] = response.id
- if pa.related_post && pa.related_post.topic
- conversation = {}
- related_topic = pa.related_post.topic
- if response = related_topic.ordered_posts[0]
- conversation[:response] = {
- excerpt: excerpt(response.cooked),
- user_id: response.user_id
- }
- user_ids << response.user_id
- if reply = related_topic.ordered_posts[1]
- conversation[:reply] = {
- excerpt: excerpt(reply.cooked),
- user_id: reply.user_id
+ conversation[:response] = {
+ excerpt: excerpt(response.cooked),
+ user_id: response.user_id
}
- user_ids << reply.user_id
- conversation[:has_more] = related_topic.posts_count > 2
+ user_ids << response.user_id
+ if reply = meta_posts[1]
+ conversation[:reply] = {
+ excerpt: excerpt(reply.cooked),
+ user_id: reply.user_id
+ }
+ user_ids << reply.user_id
+ conversation[:has_more] = rs.meta_topic.posts_count > 2
+ end
end
+
+ action.merge!(permalink: rs.meta_topic.relative_url, conversation: conversation)
end
- action.merge!(permalink: related_topic.relative_url, conversation: conversation)
- end
-
- if opts[:rest_api]
post.post_action_ids << action[:id]
all_post_actions << action
- else
- post.post_actions << action
+ user_ids << action[:user_id]
+ user_ids << rs.reviewed_by_id if rs.reviewed_by_id
end
-
- user_ids << pa.user_id
- user_ids << pa.disposed_by_id if pa.disposed_by_id
end
post_custom_field_names = []
@@ -154,6 +133,7 @@ module FlagQuery
result
end
+ guardian = Guardian.new(current_user)
users = User.includes(:user_stat).where(id: user_ids.to_a).to_a
User.preload_custom_fields(users, User.whitelisted_user_custom_fields(guardian))
@@ -167,100 +147,78 @@ module FlagQuery
end
def self.flagged_post_actions(opts = nil)
+ Discourse.deprecate("FlagQuery is deprecated, please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+
opts ||= {}
- post_actions = PostAction.flags
- .joins("INNER JOIN posts ON posts.id = post_actions.post_id")
- .joins("INNER JOIN topics ON topics.id = posts.topic_id")
- .joins("LEFT JOIN users ON users.id = posts.user_id")
- .where("posts.user_id > 0")
-
- if opts[:topic_id]
- post_actions = post_actions.where("topics.id = ?", opts[:topic_id])
- end
-
- if opts[:user_id]
- post_actions = post_actions.where("posts.user_id = ?", opts[:user_id])
- end
+ scores = ReviewableScore.includes(:reviewable).where('reviewables.type' => 'ReviewableFlaggedPost')
+ scores = scores.where('reviewables.topic_id' => opts[:topic_id]) if opts[:topic_id]
+ scores = scores.where('reviewables.target_created_by_id' => opts[:user_id]) if opts[:user_id]
if opts[:filter] == 'without_custom'
- return post_actions.where(
- 'post_action_type_id' => PostActionType.flag_types_without_custom.values
- )
+ return scores.where(reviewable_score_type: PostActionType.flag_types_without_custom.values)
end
if opts[:filter] == "old"
- post_actions.where("post_actions.disagreed_at IS NOT NULL OR
- post_actions.deferred_at IS NOT NULL OR
- post_actions.agreed_at IS NOT NULL")
+ scores = scores.where('reviewables.status <> ?', Reviewable.statuses[:pending])
else
- post_actions.active
- .where("posts.deleted_at" => nil)
- .where("topics.deleted_at" => nil)
+ scores = scores.where('reviewables.status' => Reviewable.statuses[:pending])
end
+ scores
end
def self.flagged_topics
- results = DB.query(<<~SQL)
- SELECT pa.post_action_type_id,
- pa.post_id,
- p.topic_id,
- pa.created_at AS last_flag_at,
+ Discourse.deprecate("FlagQuery has been deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4")
+
+ params = {
+ pending: Reviewable.statuses[:pending],
+ min_score: SiteSetting.min_score_default_visibility
+ }
+
+ results = DB.query(<<~SQL, params)
+ SELECT rs.reviewable_score_type,
+ p.id AS post_id,
+ r.topic_id,
+ rs.created_at,
p.user_id
- FROM post_actions AS pa
- INNER JOIN posts AS p ON pa.post_id = p.id
- INNER JOIN topics AS t ON t.id = p.topic_id
- WHERE pa.post_action_type_id IN (#{PostActionType.notify_flag_type_ids.join(',')})
- AND pa.disagreed_at IS NULL
- AND pa.deferred_at IS NULL
- AND pa.agreed_at IS NULL
- AND pa.deleted_at IS NULL
- AND p.user_id > 0
- AND p.deleted_at IS NULL
- AND t.deleted_at IS NULL
- ORDER BY pa.created_at DESC
+ FROM reviewables AS r
+ INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id
+ INNER JOIN posts AS p ON p.id = r.target_id
+ WHERE r.type = 'ReviewableFlaggedPost'
+ AND r.status = :pending
+ AND r.score >= :min_score
+ ORDER BY rs.created_at DESC
SQL
ft_by_id = {}
- counts_by_post = {}
user_ids = Set.new
- results.each do |pa|
-
- ft = ft_by_id[pa.topic_id] ||= OpenStruct.new(
- topic_id: pa.topic_id,
+ results.each do |r|
+ ft = ft_by_id[r.topic_id] ||= OpenStruct.new(
+ topic_id: r.topic_id,
flag_counts: {},
user_ids: Set.new,
- last_flag_at: pa.last_flag_at,
- meets_minimum: false
+ last_flag_at: r.created_at,
)
- counts_by_post[pa.post_id] ||= 0
- sum = counts_by_post[pa.post_id] += 1
- ft.meets_minimum = true if sum >= SiteSetting.min_flags_staff_visibility
+ ft.flag_counts[r.reviewable_score_type] ||= 0
+ ft.flag_counts[r.reviewable_score_type] += 1
- ft.flag_counts[pa.post_action_type_id] ||= 0
- ft.flag_counts[pa.post_action_type_id] += 1
-
- ft.user_ids << pa.user_id
- user_ids << pa.user_id
+ ft.user_ids << r.user_id
+ user_ids << r.user_id
end
all_topics = Topic.where(id: ft_by_id.keys).to_a
all_topics.each { |t| ft_by_id[t.id].topic = t }
- flagged_topics = ft_by_id.values.select { |ft| ft.meets_minimum }
Topic.preload_custom_fields(all_topics, TopicList.preloaded_custom_fields)
-
{
- flagged_topics: flagged_topics,
+ flagged_topics: ft_by_id.values,
users: User.where(id: user_ids)
}
end
- private
-
def self.excerpt(cooked)
excerpt = Post.excerpt(cooked, 200, keep_emoji_images: true)
# remove the first link if it's the first node
diff --git a/lib/flag_settings.rb b/lib/flag_settings.rb
index 3fb700d7cc6..d82a97ff30c 100644
--- a/lib/flag_settings.rb
+++ b/lib/flag_settings.rb
@@ -17,14 +17,15 @@ class FlagSettings
@without_custom_types = Enum.new
end
- def add(id, name, details = nil)
+ def add(id, name, topic_type: nil, notify_type: nil, auto_action_type: nil, custom_type: nil)
details ||= {}
@all_flag_types[name] = id
- @topic_flag_types[name] = id if !!details[:topic_type]
- @notify_types[name] = id if !!details[:notify_type]
- @auto_action_types[name] = id if !!details[:auto_action_type]
- if !!details[:custom_type]
+ @topic_flag_types[name] = id if !!topic_type
+ @notify_types[name] = id if !!notify_type
+ @auto_action_types[name] = id if !!auto_action_type
+
+ if !!custom_type
@custom_types[name] = id
else
@without_custom_types[name] = id
diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb
index f0e63f6c789..f70d7661202 100644
--- a/lib/guardian/post_guardian.rb
+++ b/lib/guardian/post_guardian.rb
@@ -192,10 +192,11 @@ module PostGuardian
end
def can_delete_post_action?(post_action)
- # You can only undo your own actions
- is_my_own?(post_action) && not(post_action.is_private_message?) &&
+ return false unless is_my_own?(post_action) && !post_action.is_private_message?
+
+ # Bookmarks do not have a time constraint
+ return true if post_action.is_bookmark?
- # Make sure they want to delete it within the window
post_action.created_at > SiteSetting.post_undo_action_window_mins.minutes.ago
end
diff --git a/lib/has_errors.rb b/lib/has_errors.rb
index 6c5dd02a1d8..a68c752ce13 100644
--- a/lib/has_errors.rb
+++ b/lib/has_errors.rb
@@ -2,6 +2,7 @@
# child objects with errors
module HasErrors
attr_reader :errors
+ attr_accessor :forbidden, :not_found, :conflict
def errors
@errors ||= ActiveModel::Errors.new(self)
@@ -23,10 +24,18 @@ module HasErrors
raise ActiveRecord::Rollback.new
end
+ def add_error(msg)
+ errors[:base] << msg unless errors[:base].include?(msg)
+ end
+
def add_errors_from(obj)
- obj.errors.full_messages.each do |msg|
- errors[:base] << msg unless errors[:base].include?(msg)
+ return if obj.blank?
+
+ if obj.is_a?(StandardError)
+ return add_error(obj.message)
end
+
+ obj.errors.full_messages.each { |msg| add_error(msg) }
end
end
diff --git a/lib/new_post_manager.rb b/lib/new_post_manager.rb
index a77082cdc20..92baa71cce4 100644
--- a/lib/new_post_manager.rb
+++ b/lib/new_post_manager.rb
@@ -1,6 +1,5 @@
require_dependency 'post_creator'
require_dependency 'new_post_result'
-require_dependency 'post_enqueuer'
require_dependency 'word_watcher'
# Determines what actions should be taken with new posts.
@@ -127,7 +126,7 @@ class NewPostManager
end
end
- result = manager.enqueue('default')
+ result = manager.enqueue
if is_fast_typer?(manager)
UserSilencer.silence(manager.user, Discourse.system_user, keep_posts: true, reason: I18n.t("user.new_user_typed_too_fast"))
@@ -178,23 +177,44 @@ class NewPostManager
perform_create_post unless handled
end
- # Enqueue this post in a queue
- def enqueue(queue, reason = nil)
+ # Enqueue this post
+ def enqueue(reason = nil)
result = NewPostResult.new(:enqueued)
- enqueuer = PostEnqueuer.new(@user, queue)
- queued_args = { post_options: @args.dup }
- queued_args[:raw] = queued_args[:post_options].delete(:raw)
- queued_args[:topic_id] = queued_args[:post_options].delete(:topic_id)
+ reviewable = ReviewableQueuedPost.new(
+ created_by: @user,
+ payload: { raw: @args[:raw] },
+ topic_id: @args[:topic_id],
+ reviewable_by_moderator: true
+ )
+ reviewable.payload['title'] = @args[:title] if @args[:title].present?
- post = enqueuer.enqueue(queued_args)
+ create_options = reviewable.create_options
- QueuedPost.broadcast_new! if post && post.errors.empty?
+ creator = @args[:topic_id] ?
+ PostCreator.new(@user, create_options) :
+ TopicCreator.new(@user, Guardian.new(@user), create_options)
- result.queued_post = post
+ errors = Set.new
+ creator.valid?
+ creator.errors.full_messages.each { |msg| errors << msg }
+ errors = creator.errors.full_messages.uniq
+ if errors.blank?
+ if reviewable.save
+ reviewable.add_score(
+ Discourse.system_user,
+ ReviewableScore.types[:needs_approval],
+ force_review: true
+ )
+ else
+ reviewable.errors.full_messages.each { |msg| errors << msg }
+ end
+ end
+
+ result.reviewable = reviewable
result.reason = reason if reason
- result.check_errors_from(enqueuer)
- result.pending_count = QueuedPost.new_posts.where(user_id: @user.id).count
+ result.check_errors(errors)
+ result.pending_count = Reviewable.where(created_by: @user).pending.count
result
end
diff --git a/lib/new_post_result.rb b/lib/new_post_result.rb
index 78725ea11f0..f4971545fa0 100644
--- a/lib/new_post_result.rb
+++ b/lib/new_post_result.rb
@@ -7,7 +7,7 @@ class NewPostResult
attr_accessor :reason
attr_accessor :post
- attr_accessor :queued_post
+ attr_accessor :reviewable
attr_accessor :pending_count
def initialize(action, success = false)
@@ -23,6 +23,23 @@ class NewPostResult
end
end
+ def check_errors(arr)
+ if arr.empty?
+ @success = true
+ else
+ arr.each { |e| errors[:base] << e unless errors[:base].include?(e) }
+ end
+ end
+
+ def queued_post
+ Discourse.deprecate(
+ "NewPostManager#queued_post is deprecated. Please use #reviewable instead.",
+ output_in_test: true
+ )
+
+ reviewable
+ end
+
def success?
@success
end
diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb
index 83941d436d0..4a4d81ac9b3 100644
--- a/lib/plugin/instance.rb
+++ b/lib/plugin/instance.rb
@@ -604,6 +604,15 @@ class Plugin::Instance
end
end
+ def register_reviewable_type(reviewable_type_class)
+ types = Reviewable.types
+ types << reviewable_type_class.name
+
+ reloadable_patch do
+ Reviewable.send(:define_singleton_method, :types) { types }
+ end
+ end
+
protected
def register_assets!
diff --git a/lib/post_action_creator.rb b/lib/post_action_creator.rb
index 21b951a638a..ce582cc7cb7 100644
--- a/lib/post_action_creator.rb
+++ b/lib/post_action_creator.rb
@@ -1,23 +1,298 @@
-# creates post actions based on a post and a user
+require_dependency 'post_action_result'
+
class PostActionCreator
+ class CreateResult < PostActionResult
+ attr_accessor :post_action, :reviewable, :reviewable_score
+ end
+
+ # Shortcut methods for easier invocation
+ class << self
+ def create(created_by, post, action_key, message: nil, created_at: nil)
+ new(created_by, post, PostActionType.types[action_key], message: message, created_at: created_at).perform
+ end
+
+ [:like, :off_topic, :spam, :inappropriate, :bookmark].each do |action|
+ define_method(action) do |created_by, post|
+ create(created_by, post, action)
+ end
+ end
+ [:notify_moderators, :notify_user].each do |action|
+ define_method(action) do |created_by, post, message = nil|
+ create(created_by, post, action, message: message)
+ end
+ end
+ end
+
+ def initialize(
+ created_by,
+ post,
+ post_action_type_id,
+ is_warning: false,
+ message: nil,
+ take_action: false,
+ flag_topic: false,
+ created_at: nil
+ )
+ @created_by = created_by
+ @created_at = created_at || Time.zone.now
- def initialize(user, post)
- @user = user
@post = post
+ @post_action_type_id = post_action_type_id
+ @post_action_name = PostActionType.types[@post_action_type_id]
+
+ @is_warning = is_warning
+ @take_action = take_action && guardian.is_staff?
+
+ @message = message
+ @flag_topic = flag_topic
+ @meta_post = nil
end
- def perform(action)
- guardian.ensure_post_can_act!(@post, PostActionType.types[action], opts: {
- taken_actions: PostAction.counts_for([@post].compact, @user)[@post&.id]
- })
+ def perform
+ result = CreateResult.new
- PostAction.act(@user, @post, action)
+ unless guardian.post_can_act?(
+ @post,
+ @post_action_name,
+ opts: {
+ is_warning: @is_warning,
+ taken_actions: PostAction.counts_for([@post].compact, @created_by)[@post&.id]
+ }
+ )
+ result.forbidden = true
+ result.add_error(I18n.t("invalid_access"))
+ return result
+ end
+
+ PostAction.limit_action!(@created_by, @post, @post_action_type_id)
+
+ # create meta topic / post if needed
+ if @message.present? && [:notify_moderators, :notify_user, :spam].include?(@post_action_name)
+ creator = create_message_creator
+ post = creator.create
+ if creator.errors.present?
+ result.add_errors_from(creator)
+ return result
+ end
+ @meta_post = post
+ end
+
+ begin
+ post_action = create_post_action
+
+ if post_action.blank? || post_action.errors.present?
+ result.add_errors_from(post_action)
+ else
+ create_reviewable(result)
+ enforce_rules
+ UserActionManager.post_action_created(post_action)
+ PostActionNotifier.post_action_created(post_action)
+ notify_subscribers
+
+ result.success = true
+ result.post_action = post_action
+
+ end
+ rescue ActiveRecord::RecordNotUnique
+ # If the user already performed this action, it's proably due to a different browser tab
+ # or non-debounced clicking. We can ignore.
+ result.success = true
+ result.post_action = PostAction.find_by(
+ user: @created_by,
+ post: @post,
+ post_action_type_id: @post_action_type_id
+ )
+ end
+
+ result
end
- private
+private
+
+ def notify_subscribers
+ if self.class.notify_types.include?(@post_action_name)
+ @post.publish_change_to_clients! :acted
+ end
+ end
+
+ def self.notify_types
+ @notify_types ||= ([:like] + PostActionType.notify_flag_types.keys)
+ end
+
+ def enforce_rules
+ auto_close_if_threshold_reached
+ auto_hide_if_needed
+ SpamRule::AutoSilence.new(@post.user, @post).perform
+ end
+
+ def auto_close_if_threshold_reached
+ return if topic.nil? || topic.closed?
+ return unless topic.auto_close_threshold_reached?
+
+ # the threshold has been reached, we will close the topic waiting for intervention
+ topic.update_status("closed", true, Discourse.system_user,
+ message: I18n.t(
+ "temporarily_closed_due_to_flags",
+ count: SiteSetting.num_hours_to_close_topic
+ )
+ )
+
+ topic.set_or_create_timer(
+ TopicTimer.types[:open],
+ SiteSetting.num_hours_to_close_topic,
+ by_user: Discourse.system_user
+ )
+ end
+
+ def auto_hide_if_needed
+ return if @post.hidden?
+ return if !@created_by.staff? && @post.user&.staff?
+
+ if @post_action_name == :spam &&
+ @created_by.has_trust_level?(TrustLevel[3]) &&
+ @post.user&.trust_level == TrustLevel[0]
+ @post.hide!(@post_action_type_id, Post.hidden_reasons[:flagged_by_tl3_user])
+ elsif PostActionType.auto_action_flag_types.include?(@post_action_name)
+ if @created_by.has_trust_level?(TrustLevel[4]) &&
+ !@created_by.staff? &&
+ @post.user&.trust_level != TrustLevel[4]
+
+ @post.hide!(@post_action_type_id, Post.hidden_reasons[:flagged_by_tl4_user])
+ elsif SiteSetting.score_required_to_hide_post > 0
+ score = ReviewableFlaggedPost.find_by(target: @post)&.score || 0
+ if score >= SiteSetting.score_required_to_hide_post
+ @post.hide!(@post_action_type_id)
+ end
+ end
+ end
+ end
+
+ def create_post_action
+ @targets_topic = !!(
+ if @flag_topic && @post.topic
+ @post.topic.reload.posts_count != 1
+ end
+ )
+
+ where_attrs = {
+ post_id: @post.id,
+ user_id: @created_by.id,
+ post_action_type_id: @post_action_type_id
+ }
+
+ action_attrs = {
+ staff_took_action: @take_action,
+ related_post_id: @meta_post&.id,
+ targets_topic: @targets_topic,
+ created_at: @created_at
+ }
+
+ # First try to revive a trashed record
+ post_action = PostAction.where(where_attrs)
+ .with_deleted
+ .where("deleted_at IS NOT NULL")
+ .first
+
+ if post_action
+ post_action.recover!
+ action_attrs.each { |attr, val| post_action.send("#{attr}=", val) }
+ post_action.save
+ PostActionNotifier.post_action_created(post_action)
+ else
+ post_action = PostAction.create(where_attrs.merge(action_attrs))
+ if post_action && post_action.errors.count == 0
+ BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: post_action)
+ end
+ end
+
+ if post_action && PostActionType.notify_flag_type_ids.include?(@post_action_type_id)
+ DiscourseEvent.trigger(:flag_created, post_action)
+ end
+
+ GivenDailyLike.increment_for(@created_by.id) if @post_action_type_id == PostActionType.types[:like]
+
+ # agree with other flags
+ if @take_action && reviewable = @post.reviewable_flag
+ reviewable.perform(@created_by, :agree_and_keep)
+ post_action.try(:update_counters)
+ end
+
+ post_action
+ rescue ActiveRecord::RecordNotUnique
+ # can happen despite being .create
+ # since already bookmarked
+ PostAction.where(where_attrs).first
+ end
+
+ def create_message_creator
+ title = I18n.t(
+ "post_action_types.#{@post_action_name}.email_title",
+ title: @post.topic.title,
+ locale: SiteSetting.default_locale
+ )
+
+ body = I18n.t(
+ "post_action_types.#{@post_action_name}.email_body",
+ message: @message,
+ link: "#{Discourse.base_url}#{@post.url}",
+ locale: SiteSetting.default_locale
+ )
+
+ create_args = {
+ archetype: Archetype.private_message,
+ is_warning: @is_warning,
+ title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/),
+ raw: body
+ }
+
+ if [:notify_moderators, :spam].include?(@post_action_name)
+ create_args[:subtype] = TopicSubtype.notify_moderators
+ create_args[:target_group_names] = Group[:moderators].name
+ else
+ create_args[:subtype] = TopicSubtype.notify_user
+
+ create_args[:target_usernames] =
+ if @post_action_name == :notify_user
+ @post.user.username
+ elsif @post_action_name != :notify_moderators
+ # this is a hack to allow a PM with no recipients, we should think through
+ # a cleaner technique, a PM with myself is valid for flagging
+ 'x'
+ end
+ end
+
+ PostCreator.new(@created_by, create_args)
+ end
+
+ def create_reviewable(result)
+ return unless PostActionType.notify_flag_type_ids.include?(@post_action_type_id)
+ return if @post.user_id.to_i < 0
+
+ result.reviewable = ReviewableFlaggedPost.needs_review!(
+ created_by: @created_by,
+ target: @post,
+ topic: @post.topic,
+ reviewable_by_moderator: true,
+ potential_spam: @post_action_type_id == PostActionType.types[:spam],
+ payload: {
+ targets_topic: @targets_topic
+ }
+ )
+ result.reviewable_score = result.reviewable.add_score(
+ @created_by,
+ @post_action_type_id,
+ created_at: @created_at,
+ take_action: @take_action,
+ meta_topic_id: @meta_post&.topic_id,
+ )
+ end
def guardian
- @guardian ||= Guardian.new(@user)
+ @guardian ||= Guardian.new(@created_by)
+ end
+
+ def topic
+ @post.topic
end
end
diff --git a/lib/post_action_destroyer.rb b/lib/post_action_destroyer.rb
new file mode 100644
index 00000000000..f4569f13011
--- /dev/null
+++ b/lib/post_action_destroyer.rb
@@ -0,0 +1,63 @@
+require_dependency 'post_action_result'
+
+class PostActionDestroyer
+ class DestroyResult < PostActionResult
+ attr_accessor :post
+ end
+
+ def initialize(destroyed_by, post, post_action_type_id)
+ @destroyed_by, @post, @post_action_type_id = destroyed_by, post, post_action_type_id
+ end
+
+ def self.destroy(destroyed_by, post, action_key)
+ new(destroyed_by, post, PostActionType.types[action_key]).perform
+ end
+
+ def perform
+ result = DestroyResult.new
+
+ if @post.blank?
+ result.not_found = true
+ return result
+ end
+
+ finder = PostAction.where(
+ user: @destroyed_by,
+ post: @post,
+ post_action_type_id: @post_action_type_id
+ )
+ finder = finder.with_deleted if @destroyed_by.staff?
+ post_action = finder.first
+
+ if post_action.blank?
+ result.not_found = true
+ return result
+ end
+
+ unless guardian.can_delete?(post_action)
+ result.forbidden = true
+ result.add_error(I18n.t("invalid_access"))
+ return result
+ end
+
+ RateLimiter.new(@destroyed_by, "post_action-#{@post.id}_#{@post_action_type_id}", 4, 1.minute).performed!
+
+ post_action.remove_act!(@destroyed_by)
+ post_action.post.unhide! if post_action.staff_took_action
+ GivenDailyLike.decrement_for(@destroyed_by.id) if @post_action_type_id == PostActionType.types[:like]
+
+ UserActionManager.post_action_destroyed(post_action)
+ PostActionNotifier.post_action_deleted(post_action)
+
+ result.success = true
+ result.post = @post.reload
+
+ result
+ end
+
+protected
+
+ def guardian
+ @guardian ||= Guardian.new(@destroyed_by)
+ end
+end
diff --git a/lib/post_action_result.rb b/lib/post_action_result.rb
new file mode 100644
index 00000000000..a6586e628ed
--- /dev/null
+++ b/lib/post_action_result.rb
@@ -0,0 +1,19 @@
+require_dependency 'has_errors'
+
+class PostActionResult
+ include HasErrors
+
+ attr_accessor :success
+
+ def initialize
+ @success = false
+ end
+
+ def success?
+ @success
+ end
+
+ def failed?
+ !success
+ end
+end
diff --git a/lib/post_creator.rb b/lib/post_creator.rb
index 68f23ea59e1..e05bd0c4d7e 100644
--- a/lib/post_creator.rb
+++ b/lib/post_creator.rb
@@ -169,6 +169,7 @@ class PostCreator
create_topic
create_post_notice
save_post
+ UserActionManager.post_created(@post)
extract_links
track_topic
update_topic_stats
diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb
index 04512fcbc8e..4719d4f5d1d 100644
--- a/lib/post_destroyer.rb
+++ b/lib/post_destroyer.rb
@@ -60,10 +60,14 @@ class PostDestroyer
elsif @user.id == @post.user_id
mark_for_deletion(delete_removed_posts_after)
end
+
+ UserActionManager.post_destroyed(@post)
+
DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user)
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
if @post.is_first_post? && @post.topic
+ UserActionManager.topic_destroyed(@post.topic)
DiscourseEvent.trigger(:topic_destroyed, @post.topic, @user)
WebHook.enqueue_topic_hooks(:topic_destroyed, @post.topic, topic_payload)
end
@@ -76,11 +80,12 @@ class PostDestroyer
user_recovered
end
topic = Topic.with_deleted.find @post.topic_id
- topic.recover! if @post.is_first_post?
+ topic.recover!(@user) if @post.is_first_post?
topic.update_statistics
- recover_user_actions
+ UserActionManager.post_created(@post)
DiscourseEvent.trigger(:post_recovered, @post, @opts, @user)
if @post.is_first_post?
+ UserActionManager.topic_created(@post.topic)
DiscourseEvent.trigger(:topic_recovered, topic, @user)
StaffActionLogger.new(@user).log_topic_delete_recover(topic, "recover_topic", @opts.slice(:context)) if @user.id != @post.user_id
end
@@ -129,7 +134,6 @@ class PostDestroyer
end
trash_public_post_actions
trash_user_actions
- @post.update_flagged_posts_count
remove_associated_replies
remove_associated_notifications
if @post.topic && @post.is_first_post?
@@ -141,11 +145,14 @@ class PostDestroyer
update_associated_category_latest_topic
update_user_counts
TopicUser.update_post_action_cache(post_id: @post.id)
+
DB.after_commit do
- if @opts[:defer_flags]
- defer_flags
- else
- agree_with_flags
+ if reviewable = @post.reviewable_flag
+ if @opts[:defer_flags]
+ ignore(reviewable)
+ else
+ agree(reviewable)
+ end
end
end
end
@@ -167,7 +174,6 @@ class PostDestroyer
Post.transaction do
@post.update_column(:user_deleted, true)
- @post.update_flagged_posts_count
@post.topic_links.each(&:destroy)
end
end
@@ -177,7 +183,6 @@ class PostDestroyer
Post.transaction do
@post.update_column(:user_deleted, false)
@post.skip_unique_check = true
- @post.update_flagged_posts_count
end
# has internal transactions, if we nest then there are some very high risk deadlocks
@@ -240,8 +245,8 @@ class PostDestroyer
end
end
- def agree_with_flags
- if @post.has_active_flag? && @user.human? && @user.staff?
+ def agree(reviewable)
+ if @user.human? && @user.staff? && rs = reviewable.reviewable_scores.order('created_at DESC').first
Jobs.enqueue(
:send_system_message,
user_id: @post.user_id,
@@ -250,7 +255,7 @@ class PostDestroyer
flagged_post_raw_content: @post.raw,
url: @post.url,
flag_reason: I18n.t(
- "flag_reasons.#{@post.active_flags.last.post_action_type.name_key}",
+ "flag_reasons.#{PostActionType.types[rs.reviewable_score_type]}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path
)
@@ -258,11 +263,13 @@ class PostDestroyer
)
end
- PostAction.agree_flags!(@post, @user, delete_post: true)
+ result = reviewable.perform(@user, :agree_and_keep, post_was_deleted: true)
+ reviewable.transition_to(result.transition_to, @user)
end
- def defer_flags
- PostAction.defer_flags!(@post, @user, delete_post: true)
+ def ignore(reviewable)
+ reviewable.perform_ignore(@user, post_was_deleted: true)
+ reviewable.transition_to(:ignored, @user)
end
def trash_user_actions
@@ -278,11 +285,6 @@ class PostDestroyer
end
end
- def recover_user_actions
- # TODO: Use a trash concept for `user_actions` to avoid churn and simplify this?
- UserActionCreator.log_post(@post)
- end
-
def remove_associated_replies
post_ids = PostReply.where(reply_id: @post.id).pluck(:post_id)
diff --git a/lib/post_enqueuer.rb b/lib/post_enqueuer.rb
deleted file mode 100644
index a62caa46447..00000000000
--- a/lib/post_enqueuer.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-require_dependency 'topic_creator'
-require_dependency 'queued_post'
-require_dependency 'has_errors'
-
-class PostEnqueuer
- include HasErrors
-
- def initialize(user, queue)
- @user = user
- @queue = queue
- end
-
- def enqueue(args)
- queued_post = QueuedPost.new(queue: @queue,
- state: QueuedPost.states[:new],
- user_id: @user.id,
- topic_id: args[:topic_id],
- raw: args[:raw],
- post_options: args[:post_options] || {})
-
- validate_method = :"validate_#{@queue}"
- if respond_to?(validate_method)
- return unless send(validate_method, queued_post.create_options)
- end
-
- if queued_post.save
- queued_post.create_pending_action
- else
- add_errors_from(queued_post)
- end
-
- queued_post
- end
-
- def validate_new_topic(create_options)
- topic_creator = TopicCreator.new(@user, Guardian.new(@user), create_options)
- validate_child(topic_creator)
- end
-
- def validate_new_post(create_options)
- post_creator = PostCreator.new(@user, create_options)
- validate_child(post_creator)
- end
-
-end
diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb
index bd0a800596f..adb93820c2e 100644
--- a/lib/post_revisor.rb
+++ b/lib/post_revisor.rb
@@ -292,8 +292,8 @@ class PostRevisor
end
def ninja_edit?
- return false if @post.has_active_flag?
return false if (@revised_at - @last_version_at) > SiteSetting.editing_grace_period.to_i
+ return false if @post.reviewable_flag.present?
if new_raw = @fields[:raw]
@@ -343,18 +343,16 @@ class PostRevisor
prev_owner = User.find(@post.user_id)
new_owner = User.find(@fields["user_id"])
- # UserActionCreator will create new UserAction records for the new owner
-
UserAction.where(target_post_id: @post.id)
.where(user_id: prev_owner.id)
.where(action_type: USER_ACTIONS_TO_REMOVE)
- .destroy_all
+ .update_all(user_id: new_owner.id)
if @post.post_number == 1
UserAction.where(target_topic_id: @post.topic_id)
.where(user_id: prev_owner.id)
.where(action_type: UserAction::NEW_TOPIC)
- .destroy_all
+ .update_all(user_id: new_owner.id)
end
end
diff --git a/lib/reviewable/actions.rb b/lib/reviewable/actions.rb
new file mode 100644
index 00000000000..ae0e353f3e4
--- /dev/null
+++ b/lib/reviewable/actions.rb
@@ -0,0 +1,57 @@
+require_dependency 'reviewable/collection'
+
+class Reviewable < ActiveRecord::Base
+ class Actions < Reviewable::Collection
+ attr_reader :bundles
+
+ def initialize(reviewable, guardian, args = nil)
+ super(reviewable, guardian, args)
+ @bundles = []
+ end
+
+ # Add common actions here to make them easier for reviewables to re-use. If it's a
+ # one off, add it manually.
+ def self.common_actions
+ {
+ approve: Action.new(:approve, 'thumbs-up', 'reviewables.actions.approve.title'),
+ reject: Action.new(:reject, 'thumbs-down', 'reviewables.actions.reject.title'),
+ }
+ end
+
+ class Bundle < Item
+ attr_accessor :icon, :label, :actions
+
+ def initialize(id, icon: nil, label: nil)
+ super(id)
+ @icon = icon
+ @label = label
+ @actions = []
+ end
+ end
+
+ class Action < Item
+ attr_accessor :icon, :label, :description, :confirm_message, :client_action
+
+ def initialize(id, icon = nil, label = nil)
+ super(id)
+ @icon, @label = icon, label
+ end
+ end
+
+ def add_bundle(id, icon: nil, label: nil)
+ bundle = Bundle.new(id, icon: icon, label: label)
+ @bundles << bundle
+ bundle
+ end
+
+ def add(id, bundle: nil)
+ action = Actions.common_actions[id] || Action.new(id)
+ yield action if block_given?
+ @content << action
+
+ bundle ||= add_bundle(id)
+ bundle.actions << action
+ end
+
+ end
+end
diff --git a/lib/reviewable/collection.rb b/lib/reviewable/collection.rb
new file mode 100644
index 00000000000..8604045efdc
--- /dev/null
+++ b/lib/reviewable/collection.rb
@@ -0,0 +1,39 @@
+class Reviewable < ActiveRecord::Base
+ class Collection
+ class Item
+ include ActiveModel::Serialization
+ attr_reader :id
+
+ def initialize(id)
+ @id = id
+ end
+ end
+
+ def initialize(reviewable, guardian, args = nil)
+ args ||= {}
+
+ @reviewable, @guardian, @args = reviewable, guardian, args
+ @content = []
+ end
+
+ def has?(id)
+ @content.any? { |a| a.id.to_s == id.to_s }
+ end
+
+ def blank?
+ @content.blank?
+ end
+
+ def present?
+ !blank?
+ end
+
+ def each
+ @content.each { |i| yield i }
+ end
+
+ def to_a
+ @content
+ end
+ end
+end
diff --git a/lib/reviewable/conversation.rb b/lib/reviewable/conversation.rb
new file mode 100644
index 00000000000..9ca7194d280
--- /dev/null
+++ b/lib/reviewable/conversation.rb
@@ -0,0 +1,30 @@
+class Reviewable < ActiveRecord::Base
+ class Conversation
+ include ActiveModel::Serialization
+
+ class Post
+ include ActiveModel::Serialization
+ attr_reader :id, :user, :excerpt
+
+ def initialize(post)
+ @user = post.user
+ @id = post.id
+ @excerpt = FlagQuery.excerpt(post.cooked)
+ end
+ end
+
+ attr_reader :id, :permalink, :has_more, :conversation_posts
+
+ def initialize(meta_topic)
+ @id = meta_topic.id
+ @has_more = false
+ @permalink = meta_topic.relative_url
+ @posts = []
+
+ meta_posts = meta_topic.ordered_posts.where(post_type: ::Post.types[:regular]).limit(2)
+
+ @conversation_posts = meta_posts.map { |mp| Reviewable::Conversation::Post.new(mp) }
+ @has_more = meta_topic.posts_count > 2
+ end
+ end
+end
diff --git a/lib/reviewable/editable_fields.rb b/lib/reviewable/editable_fields.rb
new file mode 100644
index 00000000000..0106d20e1e2
--- /dev/null
+++ b/lib/reviewable/editable_fields.rb
@@ -0,0 +1,18 @@
+require_dependency 'reviewable/collection'
+
+class Reviewable < ActiveRecord::Base
+ class EditableFields < Reviewable::Collection
+ class Field < Item
+ attr_reader :type
+
+ def initialize(id, type)
+ super(id)
+ @type = type
+ end
+ end
+
+ def add(id, type)
+ @content << Field.new(id, type)
+ end
+ end
+end
diff --git a/lib/reviewable/perform_result.rb b/lib/reviewable/perform_result.rb
new file mode 100644
index 00000000000..239e0da8006
--- /dev/null
+++ b/lib/reviewable/perform_result.rb
@@ -0,0 +1,24 @@
+class Reviewable < ActiveRecord::Base
+ class PerformResult
+ include ActiveModel::Serialization
+
+ attr_reader :reviewable, :status, :created_post, :created_post_topic
+ attr_accessor :transition_to, :remove_reviewable_ids, :errors, :recalculate_score,
+ :update_flag_stats
+
+ def initialize(reviewable, status)
+ @status = status
+ @reviewable = reviewable
+ @remove_reviewable_ids = [reviewable.id] if success?
+ end
+
+ def created_post=(created_post)
+ @created_post = created_post
+ @created_post_topic = created_post.topic
+ end
+
+ def success?
+ @status == :success
+ end
+ end
+end
diff --git a/lib/tasks/user_actions.rake b/lib/tasks/user_actions.rake
index f457454978e..555e894b79c 100644
--- a/lib/tasks/user_actions.rake
+++ b/lib/tasks/user_actions.rake
@@ -2,13 +2,38 @@ desc "rebuild the user_actions table"
task "user_actions:rebuild" => :environment do
MessageBus.off
UserAction.delete_all
- PostAction.all.each { |i| UserActionCreator.log_post_action(i) }
- Topic.all.each { |i| UserActionCreator.log_topic(i) }
- Post.all.each { |i| UserActionCreator.log_post(i) }
+ PostAction.all.each do |i|
+ if i.deleted_at.nil?
+ UserActionManager.post_action_created(i)
+ else
+ UserActionManager.post_action_destroyed(i)
+ end
+ end
+ Topic.all.each { |i| UserActionManager.log_topic(i) }
+ Post.all.each do |i|
+ if i.deleted_at.nil?
+ UserActionManager.post_created(i)
+ else
+ UserActionManager.post_destroyed(i)
+ end
+ end
Notification.all.each do |notification|
- UserActionCreator.log_notification(notification.post,
- notification.user,
- notification.notification_type,
- notification.user)
+
+ if notification.post.deleted_at.nil?
+ UserActionManager.notification_created(
+ notification.post,
+ notification.user,
+ notification.notification_type,
+ notification.user
+ )
+ else
+ UserActionManager.notification_destroyed(
+ notification.post,
+ notification.user,
+ notification.notification_type,
+ notification.user
+ )
+ end
+
end
end
diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb
index 49831cddcd2..65a439e4731 100644
--- a/lib/topic_creator.rb
+++ b/lib/topic_creator.rb
@@ -44,6 +44,7 @@ class TopicCreator
create_warning(topic)
watch_topic(topic)
create_shared_draft(topic)
+ UserActionManager.topic_created(topic)
topic
end
diff --git a/lib/topic_view.rb b/lib/topic_view.rb
index 08cfa02eadb..5705ab3ff7c 100644
--- a/lib/topic_view.rb
+++ b/lib/topic_view.rb
@@ -383,7 +383,7 @@ class TopicView
end
def all_active_flags
- @all_active_flags ||= PostAction.active_flags_counts_for(@posts)
+ @all_active_flags ||= ReviewableFlaggedPost.counts_for(@posts)
end
def links
@@ -421,10 +421,10 @@ class TopicView
end
# This is pending a larger refactor, that allows custom orders
- # for now we need to look for the highest_post_number in the stream
- # the cache on topics is not correct if there are deleted posts at
- # the end of the stream (for mods), nor is it correct for filtered
- # streams
+ # for now we need to look for the highest_post_number in the stream
+ # the cache on topics is not correct if there are deleted posts at
+ # the end of the stream (for mods), nor is it correct for filtered
+ # streams
def highest_post_number
@highest_post_number ||= @filtered_posts.maximum(:post_number)
end
diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb
index 0239d695dc2..4de4b2b0c1b 100644
--- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb
+++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb
@@ -149,13 +149,7 @@ module DiscourseNarrativeBot
if SiteSetting.delete_removed_posts_after < 1
opts[:delete_removed_posts_after] = 1
- # Flag it and defer so the stub doesn't get destroyed
- flag = PostAction.create!(
- user: self.discobot_user,
- post: post, post_action_type_id:
- PostActionType.types[:notify_moderators]
- )
-
+ flag = PostActionCreator.notify_moderators(self.discobot_user, post).post_action
PostAction.defer_flags!(post, self.discobot_user)
end
diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb
index 600ee90664b..d26a61ae603 100644
--- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb
+++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb
@@ -523,7 +523,7 @@ module DiscourseNarrativeBot
end
def like_post(post)
- PostAction.act(self.discobot_user, post, PostActionType.types[:like])
+ PostActionCreator.like(self.discobot_user, post)
end
def welcome_topic
diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb
index 74bd2a455d1..a5a86ec536d 100644
--- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb
+++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/track_selector.rb
@@ -238,7 +238,7 @@ module DiscourseNarrativeBot
def like_user_post
if @post.raw.match(/thank/i)
- PostAction.act(self.discobot_user, @post, PostActionType.types[:like])
+ PostActionCreator.like(self.discobot_user, @post)
end
end
diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb
index 7377d37ee30..a74ca8b4603 100644
--- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb
+++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb
@@ -422,7 +422,7 @@ describe DiscourseNarrativeBot::NewUserNarrative do
context 'when image is not found' do
it 'should create the right replies' do
- PostAction.act(user, post_2, PostActionType.types[:like])
+ PostActionCreator.like(user, post_2)
described_class.any_instance.expects(:enqueue_timeout_job).with(user)
DiscourseNarrativeBot::TrackSelector.new(:reply, user, post_id: post.id).select
@@ -503,7 +503,7 @@ describe DiscourseNarrativeBot::NewUserNarrative do
.to eq(new_post.id)
described_class.any_instance.expects(:enqueue_timeout_job).with(user)
- PostAction.act(user, post_2, PostActionType.types[:like])
+ PostActionCreator.like(user, post_2)
expected_raw = <<~RAW
#{I18n.t('discourse_narrative_bot.new_user_narrative.images.reply')}
diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb
index 027d2fc4dbe..353cb93415b 100644
--- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb
+++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb
@@ -656,7 +656,7 @@ describe DiscourseNarrativeBot::TrackSelector do
user
expect do
- PostAction.act(user, another_post, PostActionType.types[:like])
+ PostActionCreator.like(user, another_post)
end.to_not change { Post.count }
end
end
diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb
index 20de8e64b8d..7b64ddf5790 100644
--- a/plugins/poll/plugin.rb
+++ b/plugins/poll/plugin.rb
@@ -419,7 +419,7 @@ after_initialize do
end
on(:approved_post) do |queued_post, created_post|
- if queued_post.post_options["is_poll"]
+ if queued_post.payload["is_poll"]
created_post.validate_polls(true)
end
end
diff --git a/plugins/poll/spec/lib/new_post_manager_spec.rb b/plugins/poll/spec/lib/new_post_manager_spec.rb
index 19edb832e8d..bce4cbd30c1 100644
--- a/plugins/poll/spec/lib/new_post_manager_spec.rb
+++ b/plugins/poll/spec/lib/new_post_manager_spec.rb
@@ -26,12 +26,12 @@ describe NewPostManager do
first_post_checks: true
}
- expect { NewPostManager.new(user, params).perform }
- .to change { QueuedPost.count }.by(1)
+ result = NewPostManager.new(user, params).perform
+ expect(result.action).to eq(:enqueued)
+ expect(result.reviewable).to be_present
- QueuedPost.last.approve!(admin)
-
- expect(Poll.where(post: Post.last).exists?).to eq(true)
+ review_result = result.reviewable.perform(admin, :approve)
+ expect(Poll.where(post: review_result.post).exists?).to eq(true)
end
end
end
diff --git a/script/import_scripts/base.rb b/script/import_scripts/base.rb
index 454b88395a1..8b8f3a841dc 100644
--- a/script/import_scripts/base.rb
+++ b/script/import_scripts/base.rb
@@ -596,12 +596,9 @@ class ImportScripts::Base
skipped += 1
puts "Skipping bookmark for user id #{params[:user_id]} and post id #{params[:post_id]}"
else
- begin
- PostAction.act(user, post, PostActionType.types[:bookmark])
- created += 1
- rescue PostAction::AlreadyActed
- skipped += 1
- end
+ result = PostActionCreator.create(user, post, :bookmark)
+ created += 1 if result.success?
+ skipped += 1 if result.failed?
end
end
diff --git a/script/import_scripts/discuz_x.rb b/script/import_scripts/discuz_x.rb
index 83d2cae916e..a575f2a84c8 100644
--- a/script/import_scripts/discuz_x.rb
+++ b/script/import_scripts/discuz_x.rb
@@ -398,7 +398,7 @@ class ImportScripts::DiscuzX < ImportScripts::Base
end
elsif (m['status'] & 2) >> 1 == 1 # waiting for approve
mapped[:post_create_action] = lambda do |action_post|
- PostAction.act(Discourse.system_user, action_post, 6, take_action: false)
+ PostActionCreator.notify_user(Discourse.system_user, action_post)
end
end
skip ? nil : mapped
diff --git a/script/import_scripts/jive_api.rb b/script/import_scripts/jive_api.rb
index 923904aef83..8594df95171 100644
--- a/script/import_scripts/jive_api.rb
+++ b/script/import_scripts/jive_api.rb
@@ -184,8 +184,7 @@ class ImportScripts::JiveApi < ImportScripts::Base
break if likes["error"]
likes["list"].each do |like|
next unless user_id = user_id_from_imported_user_id(like["id"])
- next if PostAction.exists?(user_id: user_id, post_id: post_id, post_action_type_id: PostActionType.types[:like])
- PostAction.act(User.find(user_id), Post.find(post_id), PostActionType.types[:like])
+ PostActionCreator.like(User.find(user_id), Post.find(post_id))
end
break if likes["list"].size < USER_COUNT || likes.dig("links", "next").blank?
@@ -287,8 +286,7 @@ class ImportScripts::JiveApi < ImportScripts::Base
favorites["list"].each do |favorite|
next unless user_id = user_id_from_imported_user_id(favorite["author"]["id"])
next unless post_id = post_id_from_imported_post_id(favorite["favoriteObject"]["id"])
- next if PostAction.exists?(user_id: user_id, post_id: post_id, post_action_type_id: PostActionType.types[:bookmark])
- PostAction.act(User.find(user_id), Post.find(post_id), PostActionType.types[:bookmark])
+ PostActionCreator.create(User.find(user_id), Post.find(post_id), :bookmark)
end
break if favorites["list"].size < POST_COUNT || favorites.dig("links", "next").blank?
diff --git a/script/import_scripts/nodebb/nodebb.rb b/script/import_scripts/nodebb/nodebb.rb
index adec5a7680c..d198d5d8c25 100644
--- a/script/import_scripts/nodebb/nodebb.rb
+++ b/script/import_scripts/nodebb/nodebb.rb
@@ -410,11 +410,7 @@ class ImportScripts::NodeBB < ImportScripts::Base
post["upvoted_by"].each do |upvoter_id|
user = User.new
user.id = user_id_from_imported_user_id(upvoter_id) || Discourse::SYSTEM_USER_ID
-
- begin
- PostAction.act(user, p, PostActionType.types[:like])
- rescue PostAction::AlreadyActed
- end
+ PostActionCreator.like(user, p)
end
end
}
diff --git a/script/import_scripts/question2answer.rb b/script/import_scripts/question2answer.rb
index f9d248791b5..701b0a98ee7 100644
--- a/script/import_scripts/question2answer.rb
+++ b/script/import_scripts/question2answer.rb
@@ -310,7 +310,7 @@ EOM
post = Post.find_by(id: post_id_from_imported_post_id("thread-#{like['postid']}"))
user = User.find_by(id: user_id_from_imported_user_id(like["userid"]))
begin
- PostAction.act(user, post, 2) if user && post
+ PostActionCreator.like(user, post) if user && post
rescue => e
puts "error acting on post #{e}"
end
diff --git a/script/import_scripts/stack_overflow.rb b/script/import_scripts/stack_overflow.rb
index 06754fa0b26..d8ab14cc627 100644
--- a/script/import_scripts/stack_overflow.rb
+++ b/script/import_scripts/stack_overflow.rb
@@ -165,8 +165,6 @@ class ImportScripts::StackOverflow < ImportScripts::Base
end
end
- LIKE ||= PostActionType.types[:like]
-
def import_likes
puts "", "Importing post likes..."
@@ -196,7 +194,7 @@ class ImportScripts::StackOverflow < ImportScripts::Base
next unless post_id = post_id_from_imported_post_id(l["PostId"])
next unless user = User.find_by(id: user_id)
next unless post = Post.find_by(id: post_id)
- PostAction.act(user, post, LIKE) rescue nil
+ PostActionCreator.like(user, post) rescue nil
end
end
@@ -229,7 +227,7 @@ class ImportScripts::StackOverflow < ImportScripts::Base
next unless post_id = post_id_from_imported_post_id(l["PostCommentId"])
next unless user = User.find_by(id: user_id)
next unless post = Post.find_by(id: post_id)
- PostAction.act(user, post, LIKE) rescue nil
+ PostActionCreator.like(user, post) rescue nil
end
end
end
diff --git a/spec/components/filter_best_posts_spec.rb b/spec/components/filter_best_posts_spec.rb
index f1b06dfd566..532c1f4c5fd 100644
--- a/spec/components/filter_best_posts_spec.rb
+++ b/spec/components/filter_best_posts_spec.rb
@@ -75,13 +75,13 @@ describe FilterBestPosts do
end
it "doesn't count likes from admins" do
- PostAction.act(admin, p3, PostActionType.types[:like])
+ PostActionCreator.like(admin, p3)
best = FilterBestPosts.new(topic, @filtered_posts, 99, only_moderator_liked: true)
expect(best.posts.count).to eq(0)
end
it "should find the post liked by the moderator" do
- PostAction.act(moderator, p2, PostActionType.types[:like])
+ PostActionCreator.like(moderator, p2)
best = FilterBestPosts.new(topic, @filtered_posts, 99, only_moderator_liked: true)
expect(best.posts.count).to eq(1)
end
diff --git a/spec/components/flag_query_spec.rb b/spec/components/flag_query_spec.rb
index aee160780e7..804de7adfe1 100644
--- a/spec/components/flag_query_spec.rb
+++ b/spec/components/flag_query_spec.rb
@@ -6,15 +6,14 @@ describe FlagQuery do
let(:codinghorror) { Fabricate(:coding_horror) }
describe "flagged_topics" do
- it "respects `min_flags_staff_visibility`" do
+ it "respects `min_score_default_visibility`" do
admin = Fabricate(:admin)
moderator = Fabricate(:moderator)
post = create_post
- PostAction.act(moderator, post, PostActionType.types[:spam])
-
- SiteSetting.min_flags_staff_visibility = 1
+ SiteSetting.min_score_default_visibility = 2.0
+ PostActionCreator.spam(moderator, post)
result = FlagQuery.flagged_topics
expect(result[:flagged_topics]).to be_present
@@ -22,12 +21,12 @@ describe FlagQuery do
expect(ft.topic).to eq(post.topic)
expect(ft.flag_counts).to eq(PostActionType.types[:spam] => 1)
- SiteSetting.min_flags_staff_visibility = 2
+ SiteSetting.min_score_default_visibility = 10.0
result = FlagQuery.flagged_topics
expect(result[:flagged_topics]).to be_blank
- PostAction.act(admin, post, PostActionType.types[:inappropriate])
+ PostActionCreator.create(admin, post, :inappropriate)
result = FlagQuery.flagged_topics
expect(result[:flagged_topics]).to be_present
ft = result[:flagged_topics].first
@@ -37,14 +36,24 @@ describe FlagQuery do
PostActionType.types[:inappropriate] => 1
)
end
+ end
+ describe "flagged_post_actions" do
+
+ it "returns the proper count" do
+ moderator = Fabricate(:moderator)
+ post = create_post
+ PostActionCreator.spam(moderator, post)
+ expect(FlagQuery.flagged_post_actions(topic_id: post.topic_id).count).to eq(1)
+ expect(FlagQuery.flagged_post_actions(topic_id: post.topic_id, filter: 'old').count).to eq(0)
+ end
end
describe "flagged_posts_report" do
it "does not return flags on system posts" do
admin = Fabricate(:admin)
post = create_post(user: Discourse.system_user)
- PostAction.act(codinghorror, post, PostActionType.types[:spam])
+ PostActionCreator.create(codinghorror, post, :spam)
posts, topics, users = FlagQuery.flagged_posts_report(admin)
expect(posts).to be_blank
@@ -62,28 +71,35 @@ describe FlagQuery do
user2 = Fabricate(:user)
user3 = Fabricate(:user)
- PostAction.act(codinghorror, post, PostActionType.types[:spam])
- PostAction.act(user2, post, PostActionType.types[:spam])
- mod_message = PostAction.act(user3, post, PostActionType.types[:notify_moderators], message: "this is a :one::zero:")
+ PostActionCreator.spam(codinghorror, post)
+ PostActionCreator.create(user2, post, :spam)
+ result = PostActionCreator.new(
+ user3,
+ post,
+ PostActionType.types[:notify_moderators],
+ message: "this is a :one::zero:"
+ ).perform
+ mod_message = result.post_action
- PostAction.act(codinghorror, post2, PostActionType.types[:spam])
- PostAction.act(user2, post2, PostActionType.types[:spam])
+ PostActionCreator.spam(codinghorror, post2)
+ PostActionCreator.spam(user2, post2)
- posts, topics, users = FlagQuery.flagged_posts_report(admin)
+ posts, topics, users, all_actions = FlagQuery.flagged_posts_report(admin)
expect(posts.count).to eq(2)
first = posts.first
expect(users.count).to eq(5)
- expect(first[:post_actions].count).to eq(2)
+ expect(first[:post_action_ids].count).to eq(2)
expect(topics.count).to eq(2)
second = posts[1]
+ expect(second[:post_action_ids].count).to eq(3)
- expect(second[:post_actions].count).to eq(3)
- expect(second[:post_actions].first[:permalink]).to eq(mod_message.related_post.topic.relative_url)
- expect(second[:post_actions].first[:conversation][:response][:excerpt]).to match(" |