FEATURE: Add ability to claim topics on flagged topics page
This commit is contained in:
parent
99db4337fa
commit
7ca73b293a
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"env": {
|
||||
"jasmine": true,
|
||||
"node": true,
|
||||
"mocha": true,
|
||||
"browser": true,
|
||||
"builtin": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 7,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"globals":
|
||||
{"Ember":true,
|
||||
"jQuery":true,
|
||||
"$":true,
|
||||
"QUnit":true,
|
||||
"RSVP":true,
|
||||
"Discourse":true,
|
||||
"Em":true,
|
||||
"Handlebars":true,
|
||||
"I18n":true,
|
||||
"bootbox":true,
|
||||
"moduleFor":true,
|
||||
"moduleForComponent":true,
|
||||
"Pretender":true,
|
||||
"sandbox":true,
|
||||
"controllerFor":true,
|
||||
"test":true,
|
||||
"visit":true,
|
||||
"andThen":true,
|
||||
"click":true,
|
||||
"currentPath":true,
|
||||
"currentRouteName":true,
|
||||
"currentURL":true,
|
||||
"fillIn":true,
|
||||
"keyEvent":true,
|
||||
"triggerEvent":true,
|
||||
"count":true,
|
||||
"exists":true,
|
||||
"visible":true,
|
||||
"invisible":true,
|
||||
"asyncRender":true,
|
||||
"selectDropdown":true,
|
||||
"selectBox":true,
|
||||
"asyncTestDiscourse":true,
|
||||
"fixture":true,
|
||||
"find":true,
|
||||
"sinon":true,
|
||||
"moment":true,
|
||||
"_":true,
|
||||
"alert":true,
|
||||
"define":true,
|
||||
"require":true,
|
||||
"requirejs":true,
|
||||
"hasModule":true,
|
||||
"Blob":true,
|
||||
"File":true},
|
||||
"rules": {
|
||||
"block-scoped-var": 2,
|
||||
"dot-notation": 0,
|
||||
"eqeqeq": [
|
||||
2,
|
||||
"allow-null"
|
||||
],
|
||||
"guard-for-in": 2,
|
||||
"no-bitwise": 2,
|
||||
"no-caller": 2,
|
||||
"no-cond-assign": 0,
|
||||
"no-debugger": 2,
|
||||
"no-empty": 0,
|
||||
"no-eval": 2,
|
||||
"no-extend-native": 2,
|
||||
"no-extra-parens": 0,
|
||||
"no-inner-declarations": 2,
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-iterator": 2,
|
||||
"no-loop-func": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-new": 2,
|
||||
"no-plusplus": 0,
|
||||
"no-proto": 2,
|
||||
"no-script-url": 2,
|
||||
"no-sequences": 2,
|
||||
"no-shadow": 2,
|
||||
"no-undef": 2,
|
||||
"no-unused-vars": 2,
|
||||
"no-with": 2,
|
||||
"no-this-before-super": 2,
|
||||
"semi": 2,
|
||||
"strict": 0,
|
||||
"valid-typeof": 2,
|
||||
"wrap-iife": [
|
||||
2,
|
||||
"inside"
|
||||
],
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-trailing-spaces": 2
|
||||
},
|
||||
"parser": "babel-eslint"
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
AllCops:
|
||||
TargetRubyVersion: 2.4
|
||||
DisabledByDefault: true
|
||||
Exclude:
|
||||
- 'db/schema.rb'
|
||||
- 'bundle/**/*'
|
||||
- 'vendor/**/*'
|
||||
- 'node_modules/**/*'
|
||||
|
||||
# Prefer &&/|| over and/or.
|
||||
Style/AndOr:
|
||||
Enabled: true
|
||||
|
||||
# Do not use braces for hash literals when they are the last argument of a
|
||||
# method call.
|
||||
Style/BracesAroundHashParameters:
|
||||
Enabled: true
|
||||
|
||||
# Align `when` with `case`.
|
||||
Layout/CaseIndentation:
|
||||
Enabled: true
|
||||
|
||||
# Align comments with method definitions.
|
||||
Layout/CommentIndentation:
|
||||
Enabled: true
|
||||
|
||||
# No extra empty lines.
|
||||
Layout/EmptyLines:
|
||||
Enabled: true
|
||||
|
||||
# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }.
|
||||
Style/HashSyntax:
|
||||
Enabled: true
|
||||
|
||||
# Two spaces, no tabs (for indentation).
|
||||
Layout/IndentationWidth:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceAfterColon:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceAfterComma:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceAroundEqualsInParameterDefault:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceAroundKeyword:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceAroundOperators:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceBeforeFirstArg:
|
||||
Enabled: true
|
||||
|
||||
# Defining a method with parameters needs parentheses.
|
||||
Style/MethodDefParentheses:
|
||||
Enabled: true
|
||||
|
||||
# Use `foo {}` not `foo{}`.
|
||||
Layout/SpaceBeforeBlockBraces:
|
||||
Enabled: true
|
||||
|
||||
# Use `foo { bar }` not `foo {bar}`.
|
||||
Layout/SpaceInsideBlockBraces:
|
||||
Enabled: true
|
||||
|
||||
# Use `{ a: 1 }` not `{a:1}`.
|
||||
Layout/SpaceInsideHashLiteralBraces:
|
||||
Enabled: true
|
||||
|
||||
Layout/SpaceInsideParens:
|
||||
Enabled: true
|
||||
|
||||
# Detect hard tabs, no hard tabs.
|
||||
Layout/Tab:
|
||||
Enabled: true
|
||||
|
||||
# Blank lines should not have any spaces.
|
||||
Layout/TrailingBlankLines:
|
||||
Enabled: true
|
||||
|
||||
# No trailing whitespace.
|
||||
Layout/TrailingWhitespace:
|
||||
Enabled: true
|
||||
|
||||
Lint/Debugger:
|
||||
Enabled: true
|
||||
|
||||
Lint/BlockAlignment:
|
||||
Enabled: true
|
||||
|
||||
# Align `end` with the matching keyword or starting expression except for
|
||||
# assignments, where it should be aligned with the LHS.
|
||||
Lint/EndAlignment:
|
||||
Enabled: true
|
||||
EnforcedStyleAlignWith: variable
|
||||
|
||||
# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg.
|
||||
Lint/RequireParentheses:
|
||||
Enabled: true
|
||||
|
||||
Layout/MultilineMethodCallIndentation:
|
||||
Enabled: true
|
||||
EnforcedStyle: indented
|
||||
|
||||
Layout/AlignHash:
|
||||
Enabled: true
|
||||
|
||||
Bundler/OrderedGems:
|
||||
Enabled: false
|
|
@ -0,0 +1,74 @@
|
|||
module DiscourseAssign
|
||||
class AssignController < Admin::AdminController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def suggestions
|
||||
users = [current_user]
|
||||
users += User
|
||||
.where('admin OR moderator')
|
||||
.where('users.id <> ?', current_user.id)
|
||||
.joins("join (
|
||||
SELECT value::integer user_id, MAX(created_at) last_assigned
|
||||
FROM topic_custom_fields
|
||||
WHERE name = 'assigned_to_id'
|
||||
GROUP BY value::integer
|
||||
) as X ON X.user_id = users.id")
|
||||
.order('X.last_assigned DESC')
|
||||
.limit(6)
|
||||
|
||||
render json: ActiveModel::ArraySerializer.new(users,
|
||||
scope: guardian, each_serializer: BasicUserSerializer)
|
||||
end
|
||||
|
||||
def claim
|
||||
topic_id = params.require(:topic_id).to_i
|
||||
topic = Topic.find(topic_id)
|
||||
|
||||
assigned = TopicCustomField.where(
|
||||
"topic_id = :topic_id AND name = 'assigned_to_id' AND value IS NOT NULL",
|
||||
topic_id: topic_id
|
||||
).pluck(:value)
|
||||
|
||||
if assigned && user_id = assigned[0]
|
||||
extras = nil
|
||||
if user = User.where(id: user_id).first
|
||||
extras = {
|
||||
assigned_to: serialize_data(user, BasicUserSerializer, root: false)
|
||||
}
|
||||
end
|
||||
return render_json_error(I18n.t('discourse_assign.already_claimed'), extras: extras)
|
||||
end
|
||||
|
||||
assigner = TopicAssigner.new(topic, current_user)
|
||||
assigner.assign(current_user)
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def unassign
|
||||
topic_id = params.require(:topic_id)
|
||||
topic = Topic.find(topic_id.to_i)
|
||||
assigner = TopicAssigner.new(topic, current_user)
|
||||
assigner.unassign
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def assign
|
||||
topic_id = params.require(:topic_id)
|
||||
username = params.require(:username)
|
||||
|
||||
topic = Topic.find(topic_id.to_i)
|
||||
assign_to = User.find_by(username_lower: username.downcase)
|
||||
|
||||
raise Discourse::NotFound unless assign_to
|
||||
|
||||
assigner = TopicAssigner.new(topic, current_user)
|
||||
|
||||
# perhaps?
|
||||
#Scheduler::Defer.later "assign topic" do
|
||||
assigner.assign(assign_to)
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,4 @@
|
|||
<div class='assign-controls'>
|
||||
{{flagged-topic-listener topic=topic}}
|
||||
{{claim-topic topic=topic}}
|
||||
</div>
|
|
@ -0,0 +1 @@
|
|||
<th class='topic-assigned-to'>{{i18n "discourse_assign.assigned"}}</th>
|
|
@ -0,0 +1,3 @@
|
|||
<td>
|
||||
{{claim-topic topic=topic}}
|
||||
</td>
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
actions: {
|
||||
claim(topic) {
|
||||
console.log('claim:', topic);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
{{flagged-topic-listener flaggedTopics=flaggedTopics}}
|
|
@ -6,6 +6,8 @@ import { observes } from 'ember-addons/ember-computed-decorators';
|
|||
import Topic from 'discourse/models/topic';
|
||||
import TopicFooterDropdown from 'discourse/components/topic-footer-mobile-dropdown';
|
||||
import showModal from 'discourse/lib/show-modal';
|
||||
import { iconNode } from 'discourse-common/lib/icon-library';
|
||||
import { h } from 'virtual-dom';
|
||||
|
||||
function initialize(api, container) {
|
||||
|
||||
|
@ -84,16 +86,28 @@ function initialize(api, container) {
|
|||
});
|
||||
}
|
||||
|
||||
api.createWidget('assigned-to', {
|
||||
html(attrs) {
|
||||
let { assignedToUser, href } = attrs;
|
||||
|
||||
return h('p.assigned-to', [
|
||||
iconNode('user-plus'),
|
||||
h('span.assign-text', I18n.t('discourse_assign.assigned_to')),
|
||||
h('a', { attributes: { class: 'assigned-to-username', href } }, assignedToUser.username)
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
api.decorateWidget('post-contents:after-cooked', dec => {
|
||||
if (dec.attrs.post_number === 1) {
|
||||
const postModel = dec.getModel();
|
||||
if (postModel) {
|
||||
const assignedToUser = postModel.get('topic.assigned_to_user');
|
||||
if (assignedToUser) {
|
||||
const path = postModel.get('topic.assignedToUserPath');
|
||||
const userLink = `<a href='${path}'>${assignedToUser.username}</a>`;
|
||||
const html = I18n.t('discourse_assign.assign_html', {userLink});
|
||||
return dec.rawHtml(html);
|
||||
return dec.widget.attach('assigned-to', {
|
||||
assignedToUser,
|
||||
href: postModel.get('topic.assignedToUserPath')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { ajax } from 'discourse/lib/ajax';
|
||||
import { popupAjaxError } from 'discourse/lib/ajax-error';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
tagName: '',
|
||||
claiming: false,
|
||||
unassigning: false,
|
||||
|
||||
actions: {
|
||||
unassign() {
|
||||
this.set('unassigning', true);
|
||||
return ajax("/assign/unassign", {
|
||||
type: 'PUT',
|
||||
data: { topic_id: this.get('topic.id') }
|
||||
}).then(() => {
|
||||
this.set('topic.assigned_to_user', null);
|
||||
}).catch(popupAjaxError).finally(() => {
|
||||
this.set('unassigning', false);
|
||||
});
|
||||
},
|
||||
|
||||
claim() {
|
||||
this.set('claiming', true);
|
||||
|
||||
let topic = this.get('topic');
|
||||
ajax(`/assign/claim/${topic.id}`, {
|
||||
method: 'PUT'
|
||||
}).then(() => {
|
||||
this.set('topic.assigned_to_user', this.currentUser);
|
||||
}).catch(e => {
|
||||
if (e.jqXHR && e.jqXHR.responseJSON) {
|
||||
let json = e.jqXHR.responseJSON;
|
||||
if (json && json.extras) {
|
||||
this.set('topic.assigned_to_user', json.extras.assigned_to);
|
||||
}
|
||||
}
|
||||
return popupAjaxError(e);
|
||||
}).finally(() => {
|
||||
if (this.isDestroying || this.isDestroyed) { return; }
|
||||
this.set('claiming', false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
function assignIfEqual(topic, data) {
|
||||
if (topic && topic.id === data.topic_id) {
|
||||
Ember.set(topic, 'assigned_to_user', data.assigned_to);
|
||||
}
|
||||
}
|
||||
|
||||
export default Ember.Component.extend({
|
||||
didInsertElement() {
|
||||
this._super();
|
||||
this.messageBus.subscribe("/staff/topic-assignment", data => {
|
||||
let flaggedTopics = this.get('flaggedTopics');
|
||||
if (flaggedTopics) {
|
||||
flaggedTopics.forEach(ft => assignIfEqual(ft.topic, data));
|
||||
} else {
|
||||
assignIfEqual(this.get('topic'), data);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this._super();
|
||||
this.messageBus.unsubscribe("/staff/topic-assignment");
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{{#if topic.assigned_to_user}}
|
||||
<div class='assigned-to-user'>
|
||||
{{avatar topic.assigned_to_user imageSize="small"}}
|
||||
<span class='assigned-username'>
|
||||
{{topic.assigned_to_user.username}}
|
||||
</span>
|
||||
{{d-button
|
||||
icon="times"
|
||||
class="btn-small unassign"
|
||||
action=(action "unassign")
|
||||
disabled=unassigning
|
||||
title="discourse_assign.unassign.help"}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{d-button class="btn-small assign"
|
||||
icon="user-plus"
|
||||
action=(action "claim")
|
||||
disabled=claiming
|
||||
label="discourse_assign.claim.title"
|
||||
title="discourse_assign.claim.help"}}
|
||||
{{/if}}
|
|
@ -1,6 +1,29 @@
|
|||
a.assigned-to .fa.fa-user-plus {
|
||||
margin-right: 2px;
|
||||
color: #999;
|
||||
.assigned-to {
|
||||
.d-icon, i.fa {
|
||||
margin-right: 0.5em;
|
||||
color: $primary-medium;
|
||||
}
|
||||
.assign-text {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.assigned-to-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img.avatar {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
.unassign {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.topic-assigned-to {
|
||||
min-width: 15%;
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.list-tags.assigned {
|
||||
|
|
|
@ -5,7 +5,7 @@ en:
|
|||
unassigned: "unassigned %{who} %{when}"
|
||||
discourse_assign:
|
||||
assigned: "Assigned"
|
||||
assign_html: "<p class='assigned-to'><i class='fa-user-plus fa'></i> Assigned to {{userLink}}</p>"
|
||||
assigned_to: "Assigned to"
|
||||
assign_notification: "<i title='assigned' class='fa fa-user-plus'></i><p><span>{{username}}</span> {{description}}</p>"
|
||||
unassign:
|
||||
title: "Unassign"
|
||||
|
@ -17,3 +17,6 @@ en:
|
|||
title: "Assign Topic"
|
||||
description: "Enter the username of the person you'd like to assign this topic"
|
||||
assign: "Assign"
|
||||
claim:
|
||||
title: "claim"
|
||||
help: "Assign topic to yourself"
|
||||
|
|
|
@ -12,3 +12,4 @@ en:
|
|||
discourse_assign:
|
||||
assigned_to: "Topic assigned to @%{username}"
|
||||
unassigned: "Topic was unassigned"
|
||||
already_claimed: "That topic has already been claimed."
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
DiscourseAssign::Engine.routes.draw do
|
||||
put "/claim/:topic_id" => "assign#claim"
|
||||
put "/assign" => "assign#assign"
|
||||
put "/unassign" => "assign#unassign"
|
||||
get "/suggestions" => "assign#suggestions"
|
||||
end
|
|
@ -0,0 +1,6 @@
|
|||
module ::DiscourseAssign
|
||||
class Engine < ::Rails::Engine
|
||||
engine_name "discourse_assign"
|
||||
isolate_namespace DiscourseAssign
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
module DiscourseAssign
|
||||
module Helpers
|
||||
def self.build_assigned_to_user(assigned_to_user_id, topic)
|
||||
if assigned_to_user_id && user = User.find_by(id: assigned_to_user_id)
|
||||
assigned_at = TopicCustomField.where(
|
||||
topic_id: topic.id,
|
||||
name: "assigned_to_id"
|
||||
).pluck(:created_at).first
|
||||
|
||||
{
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
avatar_template: user.avatar_template,
|
||||
assigned_at: assigned_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,202 @@
|
|||
class ::TopicAssigner
|
||||
def self.backfill_auto_assign
|
||||
staff_mention = User.where('moderator OR admin')
|
||||
.pluck('username')
|
||||
.map { |name| "p.cooked ILIKE '%mention%@#{name}%'" }
|
||||
.join(' OR ')
|
||||
|
||||
sql = <<SQL
|
||||
SELECT p.topic_id, MAX(post_number) post_number
|
||||
FROM posts p
|
||||
JOIN topics t ON t.id = p.topic_id
|
||||
LEFT JOIN topic_custom_fields tc ON tc.name = 'assigned_to_id' AND tc.topic_id = p.topic_id
|
||||
WHERE p.user_id IN (SELECT id FROM users WHERE moderator OR admin) AND
|
||||
( #{staff_mention} ) AND tc.value IS NULL AND NOT t.closed AND t.deleted_at IS NULL
|
||||
GROUP BY p.topic_id
|
||||
SQL
|
||||
|
||||
assigned = 0
|
||||
puts
|
||||
|
||||
ActiveRecord::Base.connection.raw_connection.exec(sql).to_a.each do |row|
|
||||
post = Post.find_by(post_number: row["post_number"].to_i,
|
||||
topic_id: row["topic_id"].to_i)
|
||||
assigned += 1 if post && auto_assign(post)
|
||||
putc "."
|
||||
end
|
||||
|
||||
puts
|
||||
puts "#{assigned} topics where automatically assigned to staff members"
|
||||
end
|
||||
|
||||
def self.assign_self_passes?(post)
|
||||
return false unless SiteSetting.assign_self_regex.present?
|
||||
regex = Regexp.new(SiteSetting.assign_self_regex) rescue nil
|
||||
|
||||
!!(regex && regex.match(post.raw))
|
||||
end
|
||||
|
||||
def self.assign_other_passes?(post)
|
||||
return true unless SiteSetting.assign_other_regex.present?
|
||||
regex = Regexp.new(SiteSetting.assign_other_regex) rescue nil
|
||||
|
||||
!!(regex && regex.match(post.raw))
|
||||
end
|
||||
|
||||
def self.auto_assign(post, force: false)
|
||||
|
||||
if SiteSetting.unassign_on_close && post.topic && post.topic.closed
|
||||
assigner = new(post.topic, Discourse.system_user)
|
||||
assigner.unassign(silent: true)
|
||||
end
|
||||
|
||||
return unless SiteSetting.assigns_by_staff_mention
|
||||
|
||||
if post.user && post.topic && post.user.staff?
|
||||
can_assign = force || post.topic.custom_fields["assigned_to_id"].nil?
|
||||
|
||||
assign_other = assign_other_passes?(post) && mentioned_staff(post)
|
||||
assign_self = assign_self_passes?(post) && post.user
|
||||
|
||||
if can_assign && is_last_staff_post?(post)
|
||||
assigner = new(post.topic, post.user)
|
||||
if assign_other
|
||||
assigner.assign(assign_other, silent: true)
|
||||
elsif assign_self
|
||||
assigner.assign(assign_self, silent: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.is_last_staff_post?(post)
|
||||
Post.exec_sql("SELECT 1 FROM posts p
|
||||
JOIN users u ON u.id = p.user_id AND (moderator OR admin)
|
||||
WHERE p.deleted_at IS NULL AND p.topic_id = :topic_id
|
||||
having max(post_number) = :post_number
|
||||
",
|
||||
topic_id: post.topic_id,
|
||||
post_number: post.post_number
|
||||
).to_a.length == 1
|
||||
end
|
||||
|
||||
def self.mentioned_staff(post)
|
||||
mentions = post.raw_mentions
|
||||
if mentions.present?
|
||||
User.where('moderator OR admin')
|
||||
.where('username_lower IN (?)', mentions.map(&:downcase))
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(topic, user)
|
||||
@assigned_by = user
|
||||
@topic = topic
|
||||
end
|
||||
|
||||
def staff_ids
|
||||
User.real.staff.pluck(:id)
|
||||
end
|
||||
|
||||
def assign(assign_to, silent: false)
|
||||
@topic.custom_fields["assigned_to_id"] = assign_to.id
|
||||
@topic.custom_fields["assigned_by_id"] = @assigned_by.id
|
||||
@topic.save!
|
||||
|
||||
first_post = @topic.posts.find_by(post_number: 1)
|
||||
first_post.publish_change_to_clients!(:revised, reload_topic: true)
|
||||
|
||||
MessageBus.publish(
|
||||
"/staff/topic-assignment",
|
||||
{
|
||||
type: 'assigned',
|
||||
topic_id: @topic.id,
|
||||
assigned_to: BasicUserSerializer.new(assign_to, root: false).as_json
|
||||
},
|
||||
user_ids: staff_ids
|
||||
)
|
||||
|
||||
UserAction.log_action!(
|
||||
action_type: UserAction::ASSIGNED,
|
||||
user_id: assign_to.id,
|
||||
acting_user_id: @assigned_by.id,
|
||||
target_post_id: first_post.id,
|
||||
target_topic_id: @topic.id
|
||||
)
|
||||
|
||||
post_type = SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper]
|
||||
|
||||
unless silent
|
||||
@topic.add_moderator_post(
|
||||
@assigned_by,
|
||||
nil,
|
||||
bump: false,
|
||||
post_type: post_type,
|
||||
action_code: "assigned",
|
||||
custom_fields: { "action_code_who" => assign_to.username }
|
||||
)
|
||||
|
||||
unless @assigned_by.id == assign_to.id
|
||||
|
||||
Notification.create!(
|
||||
notification_type: Notification.types[:custom],
|
||||
user_id: assign_to.id,
|
||||
topic_id: @topic.id,
|
||||
post_number: 1,
|
||||
data: {
|
||||
message: 'discourse_assign.assign_notification',
|
||||
display_username: @assigned_by.username,
|
||||
topic_title: @topic.title
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def unassign(silent: false)
|
||||
if assigned_to_id = @topic.custom_fields["assigned_to_id"]
|
||||
@topic.custom_fields["assigned_to_id"] = nil
|
||||
@topic.custom_fields["assigned_by_id"] = nil
|
||||
@topic.save!
|
||||
|
||||
post = @topic.posts.where(post_number: 1).first
|
||||
post.publish_change_to_clients!(:revised, reload_topic: true)
|
||||
|
||||
assigned_user = User.find_by(id: assigned_to_id)
|
||||
MessageBus.publish(
|
||||
"/staff/topic-assignment",
|
||||
{
|
||||
type: 'unassigned',
|
||||
topic_id: @topic.id,
|
||||
},
|
||||
user_ids: staff_ids
|
||||
)
|
||||
|
||||
UserAction.where(
|
||||
action_type: UserAction::ASSIGNED,
|
||||
target_post_id: post.id
|
||||
).destroy_all
|
||||
|
||||
# yank notification
|
||||
Notification.where(
|
||||
notification_type: Notification.types[:custom],
|
||||
user_id: assigned_user.try(:id),
|
||||
topic_id: @topic.id,
|
||||
post_number: 1
|
||||
).where("data like '%discourse_assign.assign_notification%'").destroy_all
|
||||
|
||||
if SiteSetting.unassign_creates_tracking_post && !silent
|
||||
post_type = SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper]
|
||||
@topic.add_moderator_post(
|
||||
@assigned_by, nil,
|
||||
bump: false,
|
||||
post_type: post_type,
|
||||
custom_fields: { "action_code_who" => assigned_user&.username },
|
||||
action_code: "unassigned"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
470
plugin.rb
470
plugin.rb
|
@ -6,392 +6,146 @@
|
|||
enabled_site_setting :assign_enabled
|
||||
|
||||
register_asset 'stylesheets/assigns.scss'
|
||||
load File.expand_path('../lib/discourse_assign/engine.rb', __FILE__)
|
||||
load File.expand_path('../lib/discourse_assign/helpers.rb', __FILE__)
|
||||
|
||||
Discourse::Application.routes.append do
|
||||
mount ::DiscourseAssign::Engine, at: "/assign"
|
||||
get "topics/private-messages-assigned/:username" => "list#private_messages_assigned", as: "topics_private_messages_assigned", constraints: { username: /[\w.\-]+?/ }
|
||||
end
|
||||
|
||||
after_initialize do
|
||||
require 'topic_assigner'
|
||||
|
||||
module ::DiscourseAssign
|
||||
class Engine < ::Rails::Engine
|
||||
engine_name "discourse_assign"
|
||||
isolate_namespace DiscourseAssign
|
||||
end
|
||||
end
|
||||
|
||||
class ::TopicAssigner
|
||||
|
||||
def self.backfill_auto_assign
|
||||
staff_mention = User.where('moderator OR admin')
|
||||
.pluck('username')
|
||||
.map { |name| "p.cooked ILIKE '%mention%@#{name}%'" }
|
||||
.join(' OR ')
|
||||
|
||||
sql = <<SQL
|
||||
SELECT p.topic_id, MAX(post_number) post_number
|
||||
FROM posts p
|
||||
JOIN topics t ON t.id = p.topic_id
|
||||
LEFT JOIN topic_custom_fields tc ON tc.name = 'assigned_to_id' AND tc.topic_id = p.topic_id
|
||||
WHERE p.user_id IN (SELECT id FROM users WHERE moderator OR admin) AND
|
||||
( #{staff_mention} ) AND tc.value IS NULL AND NOT t.closed AND t.deleted_at IS NULL
|
||||
GROUP BY p.topic_id
|
||||
SQL
|
||||
|
||||
assigned = 0
|
||||
puts
|
||||
|
||||
ActiveRecord::Base.connection.raw_connection.exec(sql).to_a.each do |row|
|
||||
post = Post.find_by(post_number: row["post_number"].to_i,
|
||||
topic_id: row["topic_id"].to_i)
|
||||
assigned += 1 if post && auto_assign(post)
|
||||
putc "."
|
||||
end
|
||||
|
||||
puts
|
||||
puts "#{assigned} topics where automatically assigned to staff members"
|
||||
end
|
||||
|
||||
def self.assign_self_passes?(post)
|
||||
return false unless SiteSetting.assign_self_regex.present?
|
||||
regex = Regexp.new(SiteSetting.assign_self_regex) rescue nil
|
||||
|
||||
!!(regex && regex.match(post.raw))
|
||||
end
|
||||
|
||||
def self.assign_other_passes?(post)
|
||||
return true unless SiteSetting.assign_other_regex.present?
|
||||
regex = Regexp.new(SiteSetting.assign_other_regex) rescue nil
|
||||
|
||||
!!(regex && regex.match(post.raw))
|
||||
end
|
||||
|
||||
def self.auto_assign(post, force: false)
|
||||
|
||||
if SiteSetting.unassign_on_close && post.topic && post.topic.closed
|
||||
assigner = new(post.topic, Discourse.system_user)
|
||||
assigner.unassign(silent: true)
|
||||
end
|
||||
|
||||
return unless SiteSetting.assigns_by_staff_mention
|
||||
|
||||
if post.user && post.topic && post.user.staff?
|
||||
can_assign = force || post.topic.custom_fields["assigned_to_id"].nil?
|
||||
|
||||
assign_other = assign_other_passes?(post) && mentioned_staff(post)
|
||||
assign_self = assign_self_passes?(post) && post.user
|
||||
|
||||
if can_assign && is_last_staff_post?(post)
|
||||
assigner = new(post.topic, post.user)
|
||||
if assign_other
|
||||
assigner.assign(assign_other, silent: true)
|
||||
elsif assign_self
|
||||
assigner.assign(assign_self, silent: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.is_last_staff_post?(post)
|
||||
Post.exec_sql("SELECT 1 FROM posts p
|
||||
JOIN users u ON u.id = p.user_id AND (moderator OR admin)
|
||||
WHERE p.deleted_at IS NULL AND p.topic_id = :topic_id
|
||||
having max(post_number) = :post_number
|
||||
",
|
||||
topic_id: post.topic_id,
|
||||
post_number: post.post_number
|
||||
).to_a.length == 1
|
||||
end
|
||||
|
||||
def self.mentioned_staff(post)
|
||||
mentions = post.raw_mentions
|
||||
if mentions.present?
|
||||
User.where('moderator OR admin')
|
||||
.where('username_lower IN (?)', mentions.map(&:downcase))
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def initialize(topic, user)
|
||||
@assigned_by = user
|
||||
@topic = topic
|
||||
end
|
||||
|
||||
def assign(assign_to, silent: false)
|
||||
@topic.custom_fields["assigned_to_id"] = assign_to.id
|
||||
@topic.custom_fields["assigned_by_id"] = @assigned_by.id
|
||||
@topic.save!
|
||||
|
||||
first_post = @topic.posts.find_by(post_number: 1)
|
||||
first_post.publish_change_to_clients!(:revised,
|
||||
{ reload_topic: true })
|
||||
|
||||
|
||||
UserAction.log_action!(action_type: UserAction::ASSIGNED,
|
||||
user_id: assign_to.id,
|
||||
acting_user_id: @assigned_by.id,
|
||||
target_post_id: first_post.id,
|
||||
target_topic_id: @topic.id)
|
||||
|
||||
post_type = SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper]
|
||||
|
||||
unless silent
|
||||
@topic.add_moderator_post(@assigned_by, nil,
|
||||
{ bump: false,
|
||||
post_type: post_type,
|
||||
action_code: "assigned",
|
||||
custom_fields: {"action_code_who" => assign_to.username}
|
||||
})
|
||||
|
||||
unless @assigned_by.id == assign_to.id
|
||||
|
||||
Notification.create!(notification_type: Notification.types[:custom],
|
||||
user_id: assign_to.id,
|
||||
topic_id: @topic.id,
|
||||
post_number: 1,
|
||||
data: {
|
||||
message: 'discourse_assign.assign_notification',
|
||||
display_username: @assigned_by.username,
|
||||
topic_title: @topic.title
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def unassign(silent: false)
|
||||
if assigned_to_id = @topic.custom_fields["assigned_to_id"]
|
||||
@topic.custom_fields["assigned_to_id"] = nil
|
||||
@topic.custom_fields["assigned_by_id"] = nil
|
||||
@topic.save!
|
||||
|
||||
post = @topic.posts.where(post_number: 1).first
|
||||
post.publish_change_to_clients!(:revised, { reload_topic: true })
|
||||
|
||||
assigned_user = User.find_by(id: assigned_to_id)
|
||||
|
||||
UserAction.where(
|
||||
action_type: UserAction::ASSIGNED,
|
||||
target_post_id: post.id
|
||||
).destroy_all
|
||||
|
||||
# yank notification
|
||||
Notification.where(
|
||||
notification_type: Notification.types[:custom],
|
||||
user_id: assigned_user.try(:id),
|
||||
topic_id: @topic.id,
|
||||
post_number: 1
|
||||
).where("data like '%discourse_assign.assign_notification%'")
|
||||
.destroy_all
|
||||
|
||||
if SiteSetting.unassign_creates_tracking_post && !silent
|
||||
post_type = SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper]
|
||||
@topic.add_moderator_post(@assigned_by, nil,
|
||||
{ bump: false,
|
||||
post_type: post_type,
|
||||
custom_fields: {"action_code_who" => assigned_user&.username},
|
||||
action_code: "unassigned"})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ::DiscourseAssign::AssignController < Admin::AdminController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def suggestions
|
||||
users = [current_user]
|
||||
users += User
|
||||
.where('admin OR moderator')
|
||||
.where('users.id <> ?', current_user.id)
|
||||
.joins("join (
|
||||
SELECT value::integer user_id, MAX(created_at) last_assigned
|
||||
FROM topic_custom_fields
|
||||
WHERE name = 'assigned_to_id'
|
||||
GROUP BY value::integer
|
||||
) as X ON X.user_id = users.id")
|
||||
.order('X.last_assigned DESC')
|
||||
.limit(6)
|
||||
|
||||
render json: ActiveModel::ArraySerializer.new(users,
|
||||
scope: guardian, each_serializer: BasicUserSerializer)
|
||||
end
|
||||
|
||||
def unassign
|
||||
topic_id = params.require(:topic_id)
|
||||
topic = Topic.find(topic_id.to_i)
|
||||
assigner = TopicAssigner.new(topic, current_user)
|
||||
assigner.unassign
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def assign
|
||||
topic_id = params.require(:topic_id)
|
||||
username = params.require(:username)
|
||||
|
||||
topic = Topic.find(topic_id.to_i)
|
||||
assign_to = User.find_by(username_lower: username.downcase)
|
||||
|
||||
raise Discourse::NotFound unless assign_to
|
||||
|
||||
assigner = TopicAssigner.new(topic, current_user)
|
||||
|
||||
# perhaps?
|
||||
#Scheduler::Defer.later "assign topic" do
|
||||
assigner.assign(assign_to)
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
class ::Topic
|
||||
def assigned_to_user
|
||||
@assigned_to_user ||
|
||||
if user_id = custom_fields["assigned_to_id"]
|
||||
@assigned_to_user = User.find_by(id: user_id)
|
||||
end
|
||||
end
|
||||
|
||||
def preload_assigned_to_user(assigned_to_user)
|
||||
@assigned_to_user = assigned_to_user
|
||||
end
|
||||
end
|
||||
|
||||
# We can remove this check once this method is stable
|
||||
if respond_to?(:add_preloaded_topic_list_custom_field)
|
||||
add_preloaded_topic_list_custom_field('assigned_to_id')
|
||||
else
|
||||
TopicList.preloaded_custom_fields << "assigned_to_id"
|
||||
end
|
||||
|
||||
TopicList.on_preload do |topics, topic_list|
|
||||
is_staff = topic_list.current_user && topic_list.current_user.staff?
|
||||
allowed_access = SiteSetting.assigns_public || is_staff
|
||||
TopicList.on_preload do |topics, topic_list|
|
||||
is_staff = topic_list.current_user && topic_list.current_user.staff?
|
||||
allowed_access = SiteSetting.assigns_public || is_staff
|
||||
|
||||
if allowed_access && topics.length > 0
|
||||
users = User.where("users.id in (
|
||||
SELECT value::int
|
||||
FROM topic_custom_fields
|
||||
WHERE name = 'assigned_to_id' AND topic_id IN (?)
|
||||
)", topics.map(&:id))
|
||||
.joins('join user_emails on user_emails.user_id = users.id AND user_emails.primary')
|
||||
.select(:id, 'user_emails.email', :username, :uploaded_avatar_id)
|
||||
if allowed_access && topics.length > 0
|
||||
users = User.where("users.id in (
|
||||
SELECT value::int
|
||||
FROM topic_custom_fields
|
||||
WHERE name = 'assigned_to_id' AND topic_id IN (?)
|
||||
)", topics.map(&:id))
|
||||
.joins('join user_emails on user_emails.user_id = users.id AND user_emails.primary')
|
||||
.select(:id, 'user_emails.email', :username, :uploaded_avatar_id)
|
||||
|
||||
map = {}
|
||||
users.each { |u| map[u.id] = u }
|
||||
map = {}
|
||||
users.each { |u| map[u.id] = u }
|
||||
|
||||
topics.each do |t|
|
||||
if id = t.custom_fields['assigned_to_id']
|
||||
t.preload_assigned_to_user(map[id.to_i])
|
||||
end
|
||||
topics.each do |t|
|
||||
if id = t.custom_fields['assigned_to_id']
|
||||
t.preload_assigned_to_user(map[id.to_i])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'topic_query'
|
||||
TopicQuery.add_custom_filter(:assigned) do |results, topic_query|
|
||||
if topic_query.guardian.is_staff? || SiteSetting.assigns_public
|
||||
username = topic_query.options[:assigned]
|
||||
require_dependency 'topic_query'
|
||||
TopicQuery.add_custom_filter(:assigned) do |results, topic_query|
|
||||
if topic_query.guardian.is_staff? || SiteSetting.assigns_public
|
||||
username = topic_query.options[:assigned]
|
||||
|
||||
user_id = topic_query.guardian.user.id if username == "me"
|
||||
user_id = topic_query.guardian.user.id if username == "me"
|
||||
|
||||
special = ["*", "nobody"].include?(username)
|
||||
special = ["*", "nobody"].include?(username)
|
||||
|
||||
if username.present? && !special
|
||||
user_id ||= User.where(username_lower: username.downcase).pluck(:id).first
|
||||
end
|
||||
if username.present? && !special
|
||||
user_id ||= User.where(username_lower: username.downcase).pluck(:id).first
|
||||
end
|
||||
|
||||
if user_id || special
|
||||
if user_id || special
|
||||
|
||||
if username == "nobody"
|
||||
results = results.joins("LEFT JOIN topic_custom_fields tc_assign ON
|
||||
topics.id = tc_assign.topic_id AND
|
||||
tc_assign.name = 'assigned_to_id'")
|
||||
.where("tc_assign.name IS NULL")
|
||||
if username == "nobody"
|
||||
results = results.joins("LEFT JOIN topic_custom_fields tc_assign ON
|
||||
topics.id = tc_assign.topic_id AND
|
||||
tc_assign.name = 'assigned_to_id'")
|
||||
.where("tc_assign.name IS NULL")
|
||||
else
|
||||
|
||||
if username == "*"
|
||||
filter = "AND tc_assign.value IS NOT NULL"
|
||||
else
|
||||
|
||||
if username == "*"
|
||||
filter = "AND tc_assign.value IS NOT NULL"
|
||||
else
|
||||
filter = "AND tc_assign.value = '#{user_id.to_i.to_s}'"
|
||||
end
|
||||
|
||||
results = results.joins("JOIN topic_custom_fields tc_assign ON
|
||||
topics.id = tc_assign.topic_id AND
|
||||
tc_assign.name = 'assigned_to_id'
|
||||
#{filter}
|
||||
")
|
||||
filter = "AND tc_assign.value = '#{user_id.to_i.to_s}'"
|
||||
end
|
||||
|
||||
results = results.joins("JOIN topic_custom_fields tc_assign ON
|
||||
topics.id = tc_assign.topic_id AND
|
||||
tc_assign.name = 'assigned_to_id'
|
||||
#{filter}
|
||||
")
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
require_dependency 'topic_list_item_serializer'
|
||||
class ::TopicListItemSerializer
|
||||
has_one :assigned_to_user, serializer: BasicUserSerializer, embed: :objects
|
||||
results
|
||||
end
|
||||
|
||||
def include_assigned_to_user?
|
||||
(SiteSetting.assigns_public || scope.is_staff?) && object.assigned_to_user
|
||||
require_dependency 'topic_list_item_serializer'
|
||||
class ::TopicListItemSerializer
|
||||
has_one :assigned_to_user, serializer: BasicUserSerializer, embed: :objects
|
||||
end
|
||||
|
||||
require_dependency 'list_controller'
|
||||
class ::ListController
|
||||
generate_message_route(:private_messages_assigned)
|
||||
end
|
||||
|
||||
add_to_class(:topic_query, :list_private_messages_assigned) do |user|
|
||||
list = private_messages_for(user, :all)
|
||||
list = list.where("topics.id IN (
|
||||
SELECT topic_id FROM topic_custom_fields WHERE name = 'assigned_to_id' AND value = ?
|
||||
)", user.id.to_s)
|
||||
create_list(:private_messages, {}, list)
|
||||
end
|
||||
|
||||
add_to_class(:topic, :assigned_to_user) do
|
||||
@assigned_to_user ||
|
||||
if user_id = custom_fields["assigned_to_id"]
|
||||
@assigned_to_user = User.find_by(id: user_id)
|
||||
end
|
||||
end
|
||||
|
||||
add_to_class(:topic, :preload_assigned_to_user) do |assigned_to_user|
|
||||
@assigned_to_user = assigned_to_user
|
||||
end
|
||||
|
||||
add_to_serializer(:topic_list_item, 'include_assigned_to_user?') do
|
||||
(SiteSetting.assigns_public || scope.is_staff?) && object.assigned_to_user
|
||||
end
|
||||
|
||||
add_to_serializer(:topic_view, :assigned_to_user, false) do
|
||||
DiscourseAssign::Helpers.build_assigned_to_user(assigned_to_user_id, object.topic)
|
||||
end
|
||||
|
||||
add_to_class(:topic_view_serializer, :assigned_to_user_id) do
|
||||
id = object.topic.custom_fields["assigned_to_id"]
|
||||
# a bit messy but race conditions can give us an array here, avoid
|
||||
id && id.to_i rescue nil
|
||||
end
|
||||
|
||||
add_to_serializer(:topic_view, 'include_assigned_to_user?') do
|
||||
if SiteSetting.assigns_public || scope.is_staff?
|
||||
# subtle but need to catch cases where stuff is not assigned
|
||||
object.topic.custom_fields.keys.include?("assigned_to_id")
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'topic_view_serializer'
|
||||
class ::TopicViewSerializer
|
||||
attributes :assigned_to_user
|
||||
add_to_serializer(:flagged_topic, :assigned_to_user) do
|
||||
DiscourseAssign::Helpers.build_assigned_to_user(assigned_to_user_id, object)
|
||||
end
|
||||
|
||||
def assigned_to_user
|
||||
if assigned_to_user_id && user = User.find_by(id: assigned_to_user_id)
|
||||
|
||||
assigned_at = TopicCustomField.where(
|
||||
topic_id: object.topic.id,
|
||||
name: "assigned_to_id"
|
||||
).pluck(:created_at).first
|
||||
|
||||
{
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
avatar_template: user.avatar_template,
|
||||
assigned_at: assigned_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def include_assigned_to_user?
|
||||
if SiteSetting.assigns_public || scope.is_staff?
|
||||
# subtle but need to catch cases where stuff is not assigned
|
||||
object.topic.custom_fields.keys.include?("assigned_to_id")
|
||||
end
|
||||
end
|
||||
|
||||
def assigned_to_user_id
|
||||
id = object.topic.custom_fields["assigned_to_id"]
|
||||
# a bit messy but race conditions can give us an array here, avoid
|
||||
id && id.to_i rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'topic_query'
|
||||
class ::TopicQuery
|
||||
def list_private_messages_assigned(user)
|
||||
list = private_messages_for(user, :all)
|
||||
list = list.where("topics.id IN (
|
||||
SELECT topic_id FROM topic_custom_fields WHERE name = 'assigned_to_id' AND value = ?
|
||||
)", user.id.to_s)
|
||||
create_list(:private_messages, {}, list)
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'list_controller'
|
||||
class ::ListController
|
||||
generate_message_route(:private_messages_assigned)
|
||||
end
|
||||
|
||||
DiscourseAssign::Engine.routes.draw do
|
||||
put "/assign" => "assign#assign"
|
||||
put "/unassign" => "assign#unassign"
|
||||
get "/suggestions" => "assign#suggestions"
|
||||
end
|
||||
|
||||
Discourse::Application.routes.append do
|
||||
mount ::DiscourseAssign::Engine, at: "/assign"
|
||||
get "topics/private-messages-assigned/:username" => "list#private_messages_assigned", as: "topics_private_messages_assigned", constraints: { username: /[\w.\-]+?/ }
|
||||
end
|
||||
add_to_serializer(:flagged_topic, :assigned_to_user_id) do
|
||||
id = object.custom_fields["assigned_to_id"]
|
||||
# a bit messy but race conditions can give us an array here, avoid
|
||||
id && id.to_i rescue nil
|
||||
end
|
||||
|
||||
on(:post_created) do |post|
|
||||
|
|
Loading…
Reference in New Issue