FEATURE: Add ability to claim topics on flagged topics page

This commit is contained in:
Robin Ward 2017-09-07 12:23:12 -04:00
parent 99db4337fa
commit 7ca73b293a
20 changed files with 787 additions and 366 deletions

101
.eslintrc Normal file
View File

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

112
.rubocop.yml Normal file
View File

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

View File

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

View File

@ -0,0 +1,4 @@
<div class='assign-controls'>
{{flagged-topic-listener topic=topic}}
{{claim-topic topic=topic}}
</div>

View File

@ -0,0 +1 @@
<th class='topic-assigned-to'>{{i18n "discourse_assign.assigned"}}</th>

View File

@ -0,0 +1,3 @@
<td>
{{claim-topic topic=topic}}
</td>

View File

@ -0,0 +1,7 @@
export default {
actions: {
claim(topic) {
console.log('claim:', topic);
}
}
}

View File

@ -0,0 +1 @@
{{flagged-topic-listener flaggedTopics=flaggedTopics}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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."

6
config/routes.rb Normal file
View File

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

View File

@ -0,0 +1,6 @@
module ::DiscourseAssign
class Engine < ::Rails::Engine
engine_name "discourse_assign"
isolate_namespace DiscourseAssign
end
end

View File

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

202
lib/topic_assigner.rb Normal file
View File

@ -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
View File

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